Repository: elixir-error-tracker/error-tracker Branch: main Commit: 3fd3e105c4a1 Files: 80 Total size: 193.3 KB Directory structure: gitextract_9aka67fi/ ├── .formatter.exs ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ └── workflows/ │ └── elixir.yml ├── .gitignore ├── .tool-versions ├── CHANGELOG.md ├── LICENSE ├── README.md ├── assets/ │ ├── bun.lockb │ ├── css/ │ │ └── app.css │ ├── js/ │ │ └── app.js │ ├── package.json │ └── tailwind.config.js ├── config/ │ ├── config.exs │ ├── dev.example.exs │ └── test.example.exs ├── dev.exs ├── guides/ │ └── Getting Started.md ├── lib/ │ ├── error_tracker/ │ │ ├── application.ex │ │ ├── filter.ex │ │ ├── ignorer.ex │ │ ├── integrations/ │ │ │ ├── oban.ex │ │ │ ├── phoenix.ex │ │ │ └── plug.ex │ │ ├── migration/ │ │ │ ├── mysql/ │ │ │ │ ├── v03.ex │ │ │ │ ├── v04.ex │ │ │ │ └── v05.ex │ │ │ ├── mysql.ex │ │ │ ├── postgres/ │ │ │ │ ├── v01.ex │ │ │ │ ├── v02.ex │ │ │ │ ├── v03.ex │ │ │ │ ├── v04.ex │ │ │ │ └── v05.ex │ │ │ ├── postgres.ex │ │ │ ├── sql_migrator.ex │ │ │ ├── sqlite/ │ │ │ │ ├── v02.ex │ │ │ │ ├── v03.ex │ │ │ │ ├── v04.ex │ │ │ │ └── v05.ex │ │ │ └── sqlite.ex │ │ ├── migration.ex │ │ ├── plugins/ │ │ │ └── pruner.ex │ │ ├── repo.ex │ │ ├── schemas/ │ │ │ ├── error.ex │ │ │ ├── occurrence.ex │ │ │ └── stacktrace.ex │ │ ├── telemetry.ex │ │ ├── web/ │ │ │ ├── components/ │ │ │ │ ├── core_components.ex │ │ │ │ ├── layouts/ │ │ │ │ │ ├── live.html.heex │ │ │ │ │ └── root.html.heex │ │ │ │ └── layouts.ex │ │ │ ├── helpers.ex │ │ │ ├── hooks/ │ │ │ │ └── set_assigns.ex │ │ │ ├── live/ │ │ │ │ ├── dashboard.ex │ │ │ │ ├── dashboard.html.heex │ │ │ │ ├── show.ex │ │ │ │ └── show.html.heex │ │ │ ├── router/ │ │ │ │ └── routes.ex │ │ │ ├── router.ex │ │ │ └── search.ex │ │ └── web.ex │ ├── error_tracker.ex │ └── mix/ │ └── tasks/ │ └── error_tracker.install.ex ├── mix.exs ├── priv/ │ ├── repo/ │ │ ├── migrations/ │ │ │ └── 20240527155639_create_error_tracker_tables.exs │ │ └── seeds.exs │ └── static/ │ ├── app.css │ └── app.js └── test/ ├── error_tracker/ │ ├── filter_test.exs │ ├── ignorer_test.exs │ ├── schemas/ │ │ └── occurrence_test.exs │ └── telemetry_test.exs ├── error_tracker_test.exs ├── integrations/ │ └── plug_test.exs ├── support/ │ ├── case.ex │ ├── lite_repo.ex │ ├── mysql_repo.ex │ └── repo.ex └── test_helper.exs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .formatter.exs ================================================ # Used by "mix format" locals_without_parens = [error_tracker_dashboard: 1, error_tracker_dashboard: 2] # Parse SemVer minor elixir version from project configuration # eg `"~> 1.15"` version requirement will yield `"1.15"` [elixir_minor_version | _] = Regex.run(~r/([\d\.]+)/, Mix.Project.config()[:elixir]) [ import_deps: [:ecto, :ecto_sql, :plug, :phoenix], inputs: ["{mix,.formatter,dev,dev.*}.exs", "{config,lib,test}/**/*.{heex,ex,exs}"], plugins: [Phoenix.LiveView.HTMLFormatter, Styler], locals_without_parens: locals_without_parens, export: [locals_without_parens: locals_without_parens], styler: [ minimum_supported_elixir_version: "#{elixir_minor_version}.0" ] ] ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: bug assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. Remember that we don't provide support to third-party libraries such as Tower. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: enhancement assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/workflows/elixir.yml ================================================ name: CI on: push: branches: - main paths-ignore: - 'guides/**' pull_request: paths-ignore: - 'guides/**' env: MIX_ENV: test jobs: code_quality_and_tests: runs-on: ubuntu-latest strategy: fail-fast: false matrix: include: - elixir: "1.15.x" erlang: "24.x" - elixir: "1.16.x" erlang: "24.x" - elixir: "1.17.x" erlang: "27.x" - elixir: "1.18.x" erlang: "27.x" - elixir: "1.19.x" erlang: "28.x" - elixir: "latest" erlang: "28.x" services: db: image: postgres:15 ports: ["5432:5432"] env: POSTGRES_PASSWORD: postgres options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 mariadb: image: mariadb:11 ports: ["3306:3306"] env: MARIADB_ROOT_PASSWORD: root options: >- --health-cmd "healthcheck.sh --connect --innodb_initialized" --health-interval 10s --health-timeout 5s --health-retries 5 name: Elixir v${{ matrix.elixir }}, Erlang v${{ matrix.erlang }} steps: - uses: actions/checkout@v4 - name: Generate test configuration run: cp config/test.example.exs config/test.exs - uses: erlef/setup-beam@v1 with: otp-version: ${{ matrix.erlang }} elixir-version: ${{ matrix.elixir }} - name: Retrieve Dependencies Cache uses: actions/cache@v4 id: mix-cache with: path: | deps _build key: ${{ runner.os }}-${{ matrix.erlang }}-${{ matrix.elixir }}-mix-${{ hashFiles('**/mix.lock') }} - name: Install Mix Dependencies run: mix deps.get - name: Check unused dependencies run: mix deps.unlock --check-unused - name: Compile dependencies run: mix deps.compile - name: Check format run: mix format --check-formatted - name: Check application compile warnings run: mix compile --force --warnings-as-errors - name: Run Tests - SQLite3 run: mix test --warnings-as-errors env: DB: sqlite - name: Run Tests - PostgreSQL run: mix test --warnings-as-errors env: DB: postgres - name: Run Tests - MySQL/MariaDB run: mix test --warnings-as-errors env: DB: mysql ================================================ FILE: .gitignore ================================================ # The directory Mix will write compiled artifacts to. /_build/ # If you run "mix test --cover", coverage assets end up here. /cover/ # The directory Mix downloads your dependencies sources to. /deps/ # Where third-party dependencies like ExDoc output generated docs. /doc/ # Ignore .fetch files in case you like to edit your project deps locally. /.fetch # If the VM crashes, it generates a dump, let's ignore it too. erl_crash.dump # Also ignore archive artifacts (built via "mix archive.build"). *.ez # Ignore package tarball (built via "mix hex.build"). error_tracker-*.tar # Temporary files, for example, from tests. /tmp/ # Configuration files (only the examples are committed) /config/dev.exs /config/test.exs # Assets /assets/node_modules # SQLite3 databases *.db *.db-shm *.db-wal ================================================ FILE: .tool-versions ================================================ elixir 1.19 erlang 28.1 ================================================ FILE: CHANGELOG.md ================================================ # Changelog Please see [our GitHub "Releases" page](https://github.com/elixir-error-tracker/error-tracker/releases). ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [2024] [elixir-error-tracker] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================ # 🐛 ErrorTracker GitHub CI Latest release View documentation **An Elixir based built-in error tracking solution.** ErrorTracker captures errors in your application and stores them in the database. It also provides a web dashboard from where you can find, inspect and resolve captured errors. **Does it send notifications or integrate with issue trackers?** ErrorTrackers's goal is to track errors. Period. It provides a nice Telemetry integration that you can attach to and use to send notifications, open tickets in your issue tracker and whatnot. **Why another error tracker?** While there are multiple SaaS error trackers available, this is the only Elixir-native built-in error tracker that runs as part of your application. It gives you full control over where, how and what data is stored so it is always on your control and doesn't leave your system.\ You can see a more detailed explanation [here](https://crbelaus.com/2024/07/31/built-in-elixir-error-reporting-tracking). ErrorTracker web dashboard ErrorTracker error detail ## Configuration Take a look at the [Getting Started](/guides/Getting%20Started.md) guide. ## Development ### Initial setup and dependencies If this is the first time that you set up this project you will to generate the configuration files and adapt their content to your local environment: ``` cp config/dev.example.exs config/dev.exs cp config/test.example.exs config/test.exs ``` Then, you will need to download the dependencies: ``` mix deps.get ``` ### Assets In order to participate in the development of this project, you may need to know how to compile the assets needed to use the Web UI. To do so, you need to first make a clean build: ``` mix do assets.install, assets.build ``` That task will build the JS and CSS of the project. The JS is not expected to change too much because we rely in LiveView, but if you make any change just execute that command again and you are good to go. In the case of CSS, as it is automatically generated by Tailwind, you need to start the watcher when your intention is to modify the classes used. To do so you can execute this task in a separate terminal: ``` mix assets.watch ``` ### Development server We have a `dev.exs` script based on [Phoenix Playground](https://github.com/phoenix-playground/phoenix_playground) that starts a development server. ``` iex dev.exs ``` ================================================ FILE: assets/css/app.css ================================================ @import "tailwindcss/base"; @import "tailwindcss/components"; @import "tailwindcss/utilities"; ::-webkit-scrollbar { height: 6px; width: 6px; @apply bg-gray-300; } ::-webkit-scrollbar-thumb { @apply bg-gray-500; border-radius: 4px; } ================================================ FILE: assets/js/app.js ================================================ // Phoenix assets are imported from dependencies. import topbar from "topbar"; let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content"); let livePath = document.querySelector("meta[name='live-path']").getAttribute("content"); let liveTransport = document .querySelector("meta[name='live-transport']") .getAttribute("content"); const Hooks = { JsonPrettyPrint: { mounted() { this.formatJson(); }, updated() { this.formatJson(); }, formatJson() { try { // Get the raw JSON content const rawJson = this.el.textContent.trim(); // Parse and stringify with indentation const formattedJson = JSON.stringify(JSON.parse(rawJson), null, 2); // Update the element content this.el.textContent = formattedJson; } catch (error) { console.error("Error formatting JSON:", error); // Keep the original content if there's an error } } } }; let liveSocket = new LiveView.LiveSocket(livePath, Phoenix.Socket, { transport: liveTransport === "longpoll" ? Phoenix.LongPoll : WebSocket, params: { _csrf_token: csrfToken }, hooks: Hooks }); // Show progress bar on live navigation and form submits topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" }); window.addEventListener("phx:page-loading-start", (_info) => topbar.show(300)); window.addEventListener("phx:page-loading-stop", (_info) => topbar.hide()); // connect if there are any LiveViews on the page liveSocket.connect(); window.liveSocket = liveSocket; ================================================ FILE: assets/package.json ================================================ { "workspaces": [ "../deps/*" ], "dependencies": { "phoenix": "workspace:*", "phoenix_live_view": "workspace:*", "topbar": "^3.0.0" } } ================================================ FILE: assets/tailwind.config.js ================================================ // See the Tailwind configuration guide for advanced usage // https://tailwindcss.com/docs/configuration let plugin = require('tailwindcss/plugin') module.exports = { content: [ './js/**/*.js', '../lib/error_tracker/web.ex', '../lib/error_tracker/web/**/*.*ex' ], theme: { extend: {}, }, plugins: [ require('@tailwindcss/forms'), plugin(({addVariant}) => addVariant('phx-no-feedback', ['&.phx-no-feedback', '.phx-no-feedback &'])), plugin(({addVariant}) => addVariant('phx-click-loading', ['&.phx-click-loading', '.phx-click-loading &'])), plugin(({addVariant}) => addVariant('phx-submit-loading', ['&.phx-submit-loading', '.phx-submit-loading &'])), plugin(({addVariant}) => addVariant('phx-change-loading', ['&.phx-change-loading', '.phx-change-loading &'])) ] } ================================================ FILE: config/config.exs ================================================ import Config import_config "#{config_env()}.exs" ================================================ FILE: config/dev.example.exs ================================================ import Config config :bun, version: "1.1.18", default: [ args: ~w(build app.js --outdir=../../priv/static), cd: Path.expand("../assets/js", __DIR__), env: %{} ] # MySQL/MariaDB adapter # # To use MySQL/MariaDB on your local development machine uncomment these lines and # comment the lines of other adapters. # # config :error_tracker, :ecto_adapter, :mysql # # config :error_tracker, ErrorTrackerDev.Repo, # url: "ecto://root:root@127.0.0.1/error_tracker_dev" # # SQLite3 adapter # # To use SQLite3 on your local development machine uncomment these lines and # comment the lines of other adapters. # # config :error_tracker, :ecto_adapter, :sqlite3 # # config :error_tracker, ErrorTrackerDev.Repo, # database: System.get_env("SQLITE_DB") || "dev.db" # # PostgreSQL adapter # # To use PostgreSQL on your local development machine uncomment these lines and # comment the lines of other adapters. config :error_tracker, ErrorTrackerDev.Repo, url: "ecto://postgres:postgres@127.0.0.1/error_tracker_dev" config :error_tracker, :ecto_adapter, :postgres config :tailwind, version: "3.4.3", default: [ args: ~w( --config=tailwind.config.js --input=css/app.css --output=../priv/static/app.css ), cd: Path.expand("../assets", __DIR__) ] ================================================ FILE: config/test.example.exs ================================================ import Config alias Ecto.Adapters.SQL.Sandbox alias ErrorTracker.Test.Repo config :error_tracker, ErrorTracker.Test.LiteRepo, database: "priv/lite_repo/test.db", pool: Sandbox, log: false, # Use the same migrations as the PostgreSQL repo priv: "priv/repo" config :error_tracker, ErrorTracker.Test.MySQLRepo, url: "ecto://root:root@127.0.0.1/error_tracker_test", pool: Sandbox, log: false, # Use the same migrations as the PostgreSQL repo priv: "priv/repo" config :error_tracker, Repo, url: "ecto://postgres:postgres@127.0.0.1/error_tracker_test", pool: Sandbox, log: false config :error_tracker, ecto_repos: [Repo] # Repo is selected in the test_helper.exs based on the given ENV vars config :error_tracker, otp_app: :error_tracker ================================================ FILE: dev.exs ================================================ # This is the development server for Errortracker built on the PhoenixLiveDashboard project. # To start the development server run: # $ iex dev.exs # Mix.install([ {:ecto_sqlite3, ">= 0.0.0"}, {:error_tracker, path: ".", force: true}, {:phoenix_playground, "~> 0.1.8"} ]) otp_app = :error_tracker_dev Application.put_all_env( error_tracker_dev: [ {ErrorTrackerDev.Repo, [database: "priv/repo/dev.db"]} ], error_tracker: [ {:application, otp_app}, {:otp_app, otp_app}, {:repo, ErrorTrackerDev.Repo} ] ) defmodule ErrorTrackerDev.Repo do use Ecto.Repo, otp_app: otp_app, adapter: Ecto.Adapters.SQLite3 require Logger defmodule Migration do @moduledoc false use Ecto.Migration def up, do: ErrorTracker.Migration.up() def down, do: ErrorTracker.Migration.down() end def migrate do Ecto.Migrator.run(__MODULE__, [{0, __MODULE__.Migration}], :up, all: true) end end defmodule ErrorTrackerDev.Controller do use Phoenix.Controller, formats: [:html] use Phoenix.Component plug :put_layout, false plug :put_view, __MODULE__ def index(conn, _params) do render(conn) end def index(assigns) do ~H"""

ErrorTracker Dev server

""" end def noroute(conn, _params) do ErrorTracker.add_breadcrumb("ErrorTrackerDev.Controller.noroute/2") raise Phoenix.Router.NoRouteError, conn: conn, router: ErrorTrackerDev.Router end def exception(_conn, _params) do ErrorTracker.add_breadcrumb("ErrorTrackerDev.Controller.exception/2") raise ErrorTrackerDev.Exception, message: "This is a controller exception", bread_crumbs: ["First", "Second"] end def exit(_conn, _params) do ErrorTracker.add_breadcrumb("ErrorTrackerDev.Controller.exit/2") exit(:timeout) end end defmodule ErrorTrackerDev.Live do @moduledoc false use Phoenix.LiveView def mount(params, _session, socket) do if params["crash_on_mount"] do raise("Crashed on mount/3") end {:ok, socket} end def handle_params(params, _uri, socket) do if params["crash_on_handle_params"] do raise "Crashed on handle_params/3" end {:noreply, socket} end def handle_event("crash_on_handle_event", _params, _socket) do raise "Crashed on handle_event/3" end def handle_event("crash_on_render", _params, socket) do {:noreply, assign(socket, crash_on_render: true)} end def handle_event("genserver-timeout", _params, socket) do GenServer.call(ErrorTrackerDev.GenServer, :timeout, 2000) {:noreply, socket} end def render(assigns) do if Map.has_key?(assigns, :crash_on_render) do raise "Crashed on render/1" end ~H"""

ErrorTracker Dev server

<.link href="/dev/errors" target="_blank">Open the ErrorTracker dashboard

Errors are stored in the priv/repo/dev.db database, which is automatically created by this script.
If you want to clear the state stop the script, run the following command and start it again.

rm priv/repo/dev.db priv/repo/dev.db-shm priv/repo/dev.db-wal

LiveView examples

Controller examples

""" end end defmodule ErrorTrackerDev.Router do use Phoenix.Router use ErrorTracker.Web, :router import Phoenix.LiveView.Router pipeline :browser do plug :accepts, [:html] plug :put_root_layout, html: {PhoenixPlayground.Layout, :root} plug :put_secure_browser_headers end scope "/" do pipe_through :browser live "/", ErrorTrackerDev.Live get "/noroute", ErrorTrackerDev.Controller, :noroute get "/exception", ErrorTrackerDev.Controller, :exception get "/exit", ErrorTrackerDev.Controller, :exit scope "/dev" do error_tracker_dashboard "/errors", csp_nonce_assign_key: :custom_csp_nonce end end end defmodule ErrorTrackerDev.Endpoint do use Phoenix.Endpoint, otp_app: :phoenix_playground use ErrorTracker.Integrations.Plug # Default PhoenixPlayground.Endpoint plug Plug.Logger socket "/live", Phoenix.LiveView.Socket plug Plug.Static, from: {:phoenix, "priv/static"}, at: "/assets/phoenix" plug Plug.Static, from: {:phoenix_live_view, "priv/static"}, at: "/assets/phoenix_live_view" socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket plug Phoenix.LiveReloader plug Phoenix.CodeReloader, reloader: &PhoenixPlayground.CodeReloader.reload/2 # Use a custom Content Security Policy plug :set_csp # Raise an exception in the /plug_exception path plug :plug_exception # Our custom router which allows us to have regular controllers and live views plug ErrorTrackerDev.Router defp set_csp(conn, _opts) do nonce = 10 |> :crypto.strong_rand_bytes() |> Base.encode64() policies = [ "script-src 'self' 'nonce-#{nonce}';", "style-src 'self' 'nonce-#{nonce}';" ] conn |> Plug.Conn.assign(:custom_csp_nonce, "#{nonce}") |> Plug.Conn.put_resp_header("content-security-policy", Enum.join(policies, " ")) end defp plug_exception(%Plug.Conn{path_info: path_info} = conn, _opts) when is_list(path_info) do if "plug_exception" in path_info, do: raise("Crashed in Endpoint"), else: conn end end defmodule ErrorTrackerDev.ErrorView do def render("404.html", _assigns) do "This is a 404" end def render("500.html", _assigns) do "This is a 500" end end defmodule ErrorTrackerDev.GenServer do @moduledoc false use GenServer # Client def start_link(_) do GenServer.start_link(__MODULE__, %{}) end # Server (callbacks) @impl true def init(initial_state) do {:ok, initial_state} end @impl true def handle_call(:timeout, _from, state) do :timer.sleep(5000) {:reply, state, state} end end defmodule ErrorTrackerDev.Exception do @moduledoc false defexception [:message, :bread_crumbs] end defmodule ErrorTrackerDev.Telemetry do @moduledoc false def handle_event(event, measure, metadata, _opts) do dbg([event, measure, metadata]) end end PhoenixPlayground.start( endpoint: ErrorTrackerDev.Endpoint, child_specs: [ {ErrorTrackerDev.Repo, []}, {ErrorTrackerDev.GenServer, [name: ErrorTrackerDev.GenServer]} ], open_browser: false, debug_errors: false ) ErrorTrackerDev.Repo.migrate() :telemetry.attach_many( "error-tracker-events", [ [:error_tracker, :error, :new], [:error_tracker, :error, :resolved], [:error_tracker, :error, :unresolved], [:error_tracker, :occurrence, :new] ], &ErrorTrackerDev.Telemetry.handle_event/4, [] ) ================================================ FILE: guides/Getting Started.md ================================================ # Getting Started This guide is an introduction to ErrorTracker, an Elixir-based built-in error tracking solution. ErrorTracker provides a basic and free error-tracking solution integrated in your own application. It is designed to be easy to install and easy to use so you can integrate it in your existing project with minimal changes. The only requirement is a relational database in which errors will be tracked. In this guide we will learn how to install ErrorTracker in an Elixir project so you can start reporting errors as soon as possible. We will also cover more advanced topics such as how to report custom errors and how to add extra context to reported errors. **This guide requires you to have set up Ecto with PostgreSQL, MySQL/MariaDB or SQLite3 beforehand.** ## Automatic installation using Igniter The ErrorTracker includes an [igniter](https://hex.pm/packages/igniter) installer that will add the latest version of ErrorTracker to your dependencies before running the installer. Installation will use the application's default Ecto repo and Phoenix router, configure ErrorTracker and create the necessary database migrations. It will basically automate all the installation steps listed in the [manual installation](#manual-installation) section. ### If Igniter is already available ErrorTracker may be installed and configured with a single command: ```bash mix igniter.install error_tracker ``` ### If Igniter is not yet available If the `igniter.install` escript is not available. First, add `error_tracker` and `igniter` to your deps in `mix.exs`: ```elixir {:error_tracker, "~> 0.8"}, {:igniter, "~> 0.5", only: [:dev]}, ``` Run `mix deps.get` to fetch the dependencies, then run the install task: ```bash mix error_tracker.install ``` ## Manual Installation The first step to add ErrorTracker to your application is to declare the package as a dependency in your `mix.exs` file: ```elixir # mix.exs defp deps do [ {:error_tracker, "~> 0.8"} ] end ``` Once ErrorTracker is declared as a dependency of your application, you can install it with the following command: ```bash mix deps.get ``` ### Configuring ErrorTracker ErrorTracker needs a few configuration options to work. This configuration should be added to your `config/config.exs` file: ```elixir config :error_tracker, repo: MyApp.Repo, otp_app: :my_app, enabled: true ``` The `:repo` option specifies the repository that will be used by ErrorTracker. You can use your regular application repository or a different one if you prefer to keep errors in a different database. The `:otp_app` option specifies your application name. When an error occurs, ErrorTracker will use this information to understand which parts of the stack trace belong to your application and which parts belong to third-party dependencies. This allows you to filter in-app vs third-party frames when viewing errors. The `:enabled` option (defaults to `true` if not present) allows to disable the ErrorTracker on certain environments. This is useful to avoid filling your dev database with errors, for example. ### Setting up the database Since ErrorTracker stores errors in the database you must create a database migration to add the required tables: ``` mix ecto.gen.migration add_error_tracker ``` Open the generated migration and call the `up` and `down` functions on `ErrorTracker.Migration`: ```elixir defmodule MyApp.Repo.Migrations.AddErrorTracker do use Ecto.Migration def up, do: ErrorTracker.Migration.up(version: 5) # We specify `version: 1` in `down`, to ensure we remove all migrations. def down, do: ErrorTracker.Migration.down(version: 1) end ``` You can run the migration and apply the database changes with the following command: ```bash mix ecto.migrate ``` For more information about how to handle migrations, take a look at the `ErrorTracker.Migration` module docs. ## Automatic error tracking At this point, ErrorTracker is ready to track errors. It will automatically start when your application boots and track errors that occur in your Phoenix controllers, Phoenix LiveViews and Oban jobs. The `ErrorTracker.Integrations.Phoenix` and `ErrorTracker.Integrations.Oban` provide detailed information about how this works. If your application uses Plug but not Phoenix, you will need to add the relevant integration in your `Plug.Builder` or `Plug.Router` module. ```elixir defmodule MyApp.Router do use Plug.Router use ErrorTracker.Integrations.Plug # Your code here end ``` This is also required if you want to track errors that happen in your Phoenix endpoint, before the Phoenix router starts handling the request. Keep in mind that this won't be needed in most cases as endpoint errors are infrequent. ```elixir defmodule MyApp.Endpoint do use Phoenix.Endpoint use ErrorTracker.Integrations.Plug # Your code here end ``` You can learn more about this in the `ErrorTracker.Integrations.Plug` module documentation. ## Error context The default integrations include some additional context when tracking errors. You can take a look at the relevant integration modules to see what is being tracked out of the box. In certain cases, you may want to include some additional information when tracking errors. For example it may be useful to track the user ID that was using the application when an error happened. Fortunately, ErrorTracker allows you to enrich the default context with custom information. The `ErrorTracker.set_context/1` function stores the given context in the current process so any errors that occur in that process (for example, a Phoenix request or an Oban job) will include this given context along with the default integration context. There are some requirements on the type of data that can be included in the context, so we recommend taking a look at `ErrorTracker.set_context/1` documentation ```elixir ErrorTracker.set_context(%{user_id: conn.assigns.current_user.id}) ``` You may also want to sanitize or filter out some information from the context before saving it. To do that you can take a look at the `ErrorTracker.Filter` behaviour. ## Manual error tracking If you want to report custom errors that fall outside the default integration scope, you may use `ErrorTracker.report/2`. This allows you to report an exception yourself: ```elixir try do # your code catch e -> ErrorTracker.report(e, __STACKTRACE__) end ``` You can also use `ErrorTracker.report/3` and set some custom context that will be included along with the reported error. ## Web UI ErrorTracker also provides a dashboard built with Phoenix LiveView that can be used to see and manage the recorded errors. This is completely optional, and you can find more information about it in the `ErrorTracker.Web` module documentation. ## Notifications Currently ErrorTracker does not support notifications out of the box. However, it provides some detailed Telemetry events that you may use to implement your own notifications following your custom rules and notification channels. If you want to take a look at the events you can attach to, take a look at `ErrorTracker.Telemetry` module documentation. ## Pruning resolved errors By default errors are kept in the database indefinitely. This is not ideal for production environments where you may want to prune old errors that have been resolved. The `ErrorTracker.Plugins.Pruner` module provides automatic pruning functionality with a configurable interval and error age. ## Ignoring and Muting Errors ErrorTracker provides two different ways to silence errors: ### Ignoring Errors ErrorTracker tracks every error by default. In certain cases some errors may be expected or just not interesting to track. The `ErrorTracker.Ignorer` behaviour allows you to ignore errors based on their attributes and context. When an error is ignored, its occurrences are not tracked at all. This is useful for expected errors that you don't want to store in your database. For example, if you had an integration with an unreliable third-party system that was frequently timing out, you could ignore those errors like so: ```elixir defmodule MyApp.ErrorIgnores do @behaviour ErrorTracker.Ignorer @impl ErrorTracker.Ignorer def ignore?(%{kind: "Elixir.UnreliableThirdParty.Error", reason: ":timeout"} = _error, _context) do true end end ``` ### Muting Errors Sometimes you may want to keep tracking error occurrences but avoid receiving notifications about them. For these cases, ErrorTracker allows you to mute specific errors. When an error is muted: - New occurrences are still tracked and stored in the database - You can still see the error and its occurrences in the web UI - [Telemetry events](ErrorTracker.Telemetry.html) for new occurrences include the `muted: true` flag so you can ignore them as needed. This is particularly useful for noisy errors that you want to keep tracking but don't want to receive notifications about. You can mute and unmute errors manually through the web UI or programmatically using the `ErrorTracker.mute/1` and `ErrorTracker.unmute/1` functions. ================================================ FILE: lib/error_tracker/application.ex ================================================ defmodule ErrorTracker.Application do @moduledoc false use Application def start(_type, _args) do children = Application.get_env(:error_tracker, :plugins, []) attach_handlers() Supervisor.start_link(children, strategy: :one_for_one, name: __MODULE__) end defp attach_handlers do ErrorTracker.Integrations.Oban.attach() ErrorTracker.Integrations.Phoenix.attach() end end ================================================ FILE: lib/error_tracker/filter.ex ================================================ defmodule ErrorTracker.Filter do @moduledoc """ Behaviour for sanitizing & modifying the error context before it's saved. defmodule MyApp.ErrorFilter do @behaviour ErrorTracker.Filter @impl true def sanitize(context) do context # Modify the context object (add or remove fields as much as you need.) end end Once implemented, include it in the ErrorTracker configuration: config :error_tracker, filter: MyApp.Filter With this configuration in place, the ErrorTracker will call `MyApp.Filter.sanitize/1` to get a context before saving error occurrence. > #### A note on performance {: .warning} > > Keep in mind that the `sanitize/1` will be called in the context of the ErrorTracker itself. > Slow code will have a significant impact in the ErrorTracker performance. Buggy code can bring > the ErrorTracker process down. """ @doc """ This function will be given an error context to inspect/modify before it's saved. """ @callback sanitize(context :: map()) :: map() end ================================================ FILE: lib/error_tracker/ignorer.ex ================================================ defmodule ErrorTracker.Ignorer do @moduledoc """ Behaviour for ignoring errors. > #### Ignoring vs muting errors {: .info} > > Ignoring an error keeps it from being tracked by the ErrorTracker. While this may be useful in > certain cases, in other cases you may prefer to track the error but don't send telemetry events. > Take a look at the `ErrorTracker.mute/1` function to see how to mute errors. The ErrorTracker tracks every error that happens in your application. In certain cases you may want to ignore some errors and don't track them. To do so you can implement this behaviour. defmodule MyApp.ErrorIgnorer do @behaviour ErrorTracker.Ignorer @impl true def ignore?(error = %ErrorTracker.Error{}, context) do # return true if the error should be ignored end end Once implemented, include it in the ErrorTracker configuration: config :error_tracker, ignorer: MyApp.ErrorIgnorer With this configuration in place, the ErrorTracker will call `MyApp.ErrorIgnorer.ignore?/2` before tracking errors. If the function returns `true` the error will be ignored and won't be tracked. > #### A note on performance {: .warning} > > Keep in mind that the `ignore?/2` will be called in the context of the ErrorTracker itself. > Slow code will have a significant impact in the ErrorTracker performance. Buggy code can bring > the ErrorTracker process down. """ @doc """ Decide wether the given error should be ignored or not. This function receives both the current Error and context and should return a boolean indicating if it should be ignored or not. If the function returns true the error will be ignored, otherwise it will be tracked. """ @callback ignore?(error :: ErrorTracker.Error.t(), context :: map()) :: boolean end ================================================ FILE: lib/error_tracker/integrations/oban.ex ================================================ defmodule ErrorTracker.Integrations.Oban do @moduledoc """ Integration with Oban. ## How to use it It is a plug and play integration: as long as you have Oban installed the ErrorTracker will receive and store the errors as they are reported. ### How it works It works using Oban's Telemetry events, so you don't need to modify anything on your application. > #### A note on errors grouping {: .warning} > > All errors reported using `:error` or `{:error, any()}` as the output of > your `perform/2` worker function are going to be grouped together (one group > of those of errors per worker). > > The reason of that behaviour is that those errors do not generate an exception, > so no stack trace is detected and they are stored as happening in the same > place. > > If you want errors of your workers to be grouped as you may expect on other > integrations, you should raise exceptions to report errors instead of gracefully > returning an error value. ### Default context By default we store some context for you on errors generated in an Oban process: * `job.id`: the unique ID of the job. * `job.worker`: the name of the worker module. * `job.queue`: the name of the queue in which the job was inserted. * `job.args`: the arguments of the job being executed. * `job.priority`: the priority of the job. * `job.attempt`: the number of attempts performed for the job. """ # https://hexdocs.pm/oban/Oban.Telemetry.html @events [ [:oban, :job, :start], [:oban, :job, :exception] ] @doc false def attach do if Application.spec(:oban) do :telemetry.attach_many(__MODULE__, @events, &__MODULE__.handle_event/4, :no_config) end end @doc false def handle_event([:oban, :job, :start], _measurements, metadata, :no_config) do %{job: job} = metadata ErrorTracker.set_context(%{ "job.args" => job.args, "job.attempt" => job.attempt, "job.id" => job.id, "job.priority" => job.priority, "job.queue" => job.queue, "job.worker" => job.worker }) end def handle_event([:oban, :job, :exception], _measurements, metadata, :no_config) do %{reason: exception, stacktrace: stacktrace, job: job} = metadata state = Map.get(metadata, :state, :failure) stacktrace = if stacktrace == [], do: [{String.to_existing_atom("Elixir." <> job.worker), :perform, 2, []}], else: stacktrace ErrorTracker.report(exception, stacktrace, %{state: state}) end end ================================================ FILE: lib/error_tracker/integrations/phoenix.ex ================================================ defmodule ErrorTracker.Integrations.Phoenix do @moduledoc """ Integration with Phoenix applications. ## How to use it It is a plug and play integration: as long as you have Phoenix installed the ErrorTracker will receive and store the errors as they are reported. It also collects the exceptions that raise on your LiveView modules. ### How it works It works using Phoenix's Telemetry events, so you don't need to modify anything on your application. ### Errors on the Endpoint This integration only catches errors that raise after the requests hits your Router. That means that an exception on a plug defined on your Endpoint will not be reported. If you want to also catch those errors, we recommend you to set up the `ErrorTracker.Integrations.Plug` integration too. ### Default context For errors that are reported when executing regular HTTP requests (the ones that go to Controllers), the context added by default is the same that you can find on the `ErrorTracker.Integrations.Plug` integration. As for exceptions generated in LiveView processes, we collect some special information on the context: * `live_view.view`: the LiveView module itself, * `live_view.uri`: last URI that loaded the LiveView (available when the `handle_params` function is invoked). * `live_view.params`: the params received by the LiveView (available when the `handle_params` function is invoked). * `live_view.event`: last event received by the LiveView (available when the `handle_event` function is invoked). * `live_view.event_params`: last event params received by the LiveView (available when the `handle_event` function is invoked). """ alias ErrorTracker.Integrations.Plug, as: PlugIntegration @events [ # https://hexdocs.pm/phoenix/Phoenix.Logger.html#module-instrumentation [:phoenix, :router_dispatch, :start], [:phoenix, :router_dispatch, :exception], # https://hexdocs.pm/phoenix_live_view/telemetry.html [:phoenix, :live_view, :mount, :start], [:phoenix, :live_view, :mount, :exception], [:phoenix, :live_view, :handle_params, :start], [:phoenix, :live_view, :handle_params, :exception], [:phoenix, :live_view, :handle_event, :exception], [:phoenix, :live_view, :render, :exception], [:phoenix, :live_component, :update, :exception], [:phoenix, :live_component, :handle_event, :exception] ] @doc false def attach do if Application.spec(:phoenix) do :telemetry.attach_many(__MODULE__, @events, &__MODULE__.handle_event/4, :no_config) end end @doc false def handle_event([:phoenix, :router_dispatch, :start], _measurements, metadata, :no_config) do PlugIntegration.set_context(metadata.conn) end def handle_event([:phoenix, :router_dispatch, :exception], _measurements, metadata, :no_config) do {reason, kind, stack} = case metadata do %{reason: %Plug.Conn.WrapperError{reason: reason, kind: kind, stack: stack}} -> {reason, kind, stack} %{kind: kind, reason: reason, stacktrace: stack} -> {reason, kind, stack} end PlugIntegration.report_error(metadata.conn, {kind, reason}, stack) end def handle_event([:phoenix, :live_view, :mount, :start], _, metadata, :no_config) do ErrorTracker.set_context(%{ "live_view.view" => metadata.socket.view }) end def handle_event([:phoenix, :live_view, :handle_params, :start], _, metadata, :no_config) do ErrorTracker.set_context(%{ "live_view.uri" => metadata.uri, "live_view.params" => metadata.params }) end def handle_event([:phoenix, :live_view, :handle_event, :exception], _, metadata, :no_config) do ErrorTracker.report({metadata.kind, metadata.reason}, metadata.stacktrace, %{ "live_view.event" => metadata.event, "live_view.event_params" => metadata.params }) end def handle_event([:phoenix, :live_view, _action, :exception], _, metadata, :no_config) do ErrorTracker.report({metadata.kind, metadata.reason}, metadata.stacktrace) end def handle_event([:phoenix, :live_component, :update, :exception], _, metadata, :no_config) do ErrorTracker.report({metadata.kind, metadata.reason}, metadata.stacktrace, %{ "live_view.component" => metadata.component }) end def handle_event([:phoenix, :live_component, :handle_event, :exception], _, metadata, :no_config) do ErrorTracker.report({metadata.kind, metadata.reason}, metadata.stacktrace, %{ "live_view.component" => metadata.component, "live_view.event" => metadata.event, "live_view.event_params" => metadata.params }) end end ================================================ FILE: lib/error_tracker/integrations/plug.ex ================================================ defmodule ErrorTracker.Integrations.Plug do @moduledoc """ Integration with Plug applications. ## How to use it ### Plug applications The way to use this integration is by adding it to either your `Plug.Builder` or `Plug.Router`: ```elixir defmodule MyApp.Router do use Plug.Router use ErrorTracker.Integrations.Plug ... end ``` ### Phoenix applications There is a particular use case which can be useful when running a Phoenix web application. If you want to record exceptions that may occur in your application's endpoint before reaching your router (for example, in any plug like the ones decoding cookies of body contents) you may want to add this integration too: ```elixir defmodule MyApp.Endpoint do use Phoenix.Endpoint use ErrorTracker.Integrations.Plug ... end ``` ### Default context By default we store some context for you on errors generated during a Plug request: * `request.host`: the `conn.host` value. * `request.ip`: the IP address that initiated the request. It includes parsing proxy headers * `request.method`: the HTTP method of the request. * `request.path`: the path of the request. * `request.query`: the query string of the request. * `request.params`: parsed params of the request (only available if they have been fetched and parsed as part of the Plug pipeline). * `request.headers`: headers received on the request. All headers are included by default except for the `Cookie` ones, as they may include large and sensitive content like sessions. """ defmacro __using__(_opts) do quote do @before_compile unquote(__MODULE__) end end defmacro __before_compile__(_) do quote do defoverridable call: 2 def call(conn, opts) do unquote(__MODULE__).set_context(conn) super(conn, opts) rescue e in Plug.Conn.WrapperError -> unquote(__MODULE__).report_error(e.conn, e.reason, e.stack) Plug.Conn.WrapperError.reraise(e) e -> stack = __STACKTRACE__ unquote(__MODULE__).report_error(conn, e, stack) :erlang.raise(:error, e, stack) catch kind, reason -> stack = __STACKTRACE__ unquote(__MODULE__).report_error(conn, {kind, reason}, stack) :erlang.raise(kind, reason, stack) end end end @doc false def report_error(conn, reason, stack) do if !Process.get(:error_tracker_router_exception_reported) do try do ErrorTracker.report(reason, stack, build_context(conn)) after Process.put(:error_tracker_router_exception_reported, true) end end end @doc false def set_context(%Plug.Conn{} = conn) do conn |> build_context() |> ErrorTracker.set_context() end @sensitive_headers ~w[authorization cookie set-cookie] defp build_context(%Plug.Conn{} = conn) do %{ "request.host" => conn.host, "request.path" => conn.request_path, "request.query" => conn.query_string, "request.method" => conn.method, "request.ip" => remote_ip(conn), "request.headers" => Map.new(conn.req_headers, fn {header, value} -> if header in @sensitive_headers, do: {header, "[REDACTED]"}, else: {header, value} end), # Depending on the error source, the request params may have not been fetched yet "request.params" => if(!is_struct(conn.params, Plug.Conn.Unfetched), do: conn.params) } end defp remote_ip(%Plug.Conn{} = conn) do remote_ip = case Plug.Conn.get_req_header(conn, "x-forwarded-for") do [x_forwarded_for | _] -> x_forwarded_for |> String.split(",", parts: 2) |> List.first() [] -> case :inet.ntoa(conn.remote_ip) do {:error, _} -> "" address -> to_string(address) end end String.trim(remote_ip) end end ================================================ FILE: lib/error_tracker/migration/mysql/v03.ex ================================================ defmodule ErrorTracker.Migration.MySQL.V03 do @moduledoc false use Ecto.Migration def up(_opts) do create table(:error_tracker_meta, primary_key: [name: :key, type: :string]) do add :value, :string, null: false end create table(:error_tracker_errors, primary_key: [name: :id, type: :bigserial]) do add :kind, :string, null: false add :reason, :text, null: false add :source_line, :text, null: false add :source_function, :text, null: false add :status, :string, null: false add :fingerprint, :string, null: false add :last_occurrence_at, :utc_datetime_usec, null: false timestamps(type: :utc_datetime_usec) end create unique_index(:error_tracker_errors, [:fingerprint]) create table(:error_tracker_occurrences, primary_key: [name: :id, type: :bigserial]) do add :context, :map, null: false add :reason, :text, null: false add :stacktrace, :map, null: false add :error_id, references(:error_tracker_errors, on_delete: :delete_all, column: :id, type: :bigserial ), null: false timestamps(type: :utc_datetime_usec, updated_at: false) end create index(:error_tracker_occurrences, [:error_id]) create index(:error_tracker_errors, [:last_occurrence_at]) end def down(_opts) do drop table(:error_tracker_occurrences) drop table(:error_tracker_errors) drop table(:error_tracker_meta) end end ================================================ FILE: lib/error_tracker/migration/mysql/v04.ex ================================================ defmodule ErrorTracker.Migration.MySQL.V04 do @moduledoc false use Ecto.Migration def up(_opts) do alter table(:error_tracker_occurrences) do add :breadcrumbs, :json, null: true end end def down(_opts) do alter table(:error_tracker_occurrences) do remove :breadcrumbs end end end ================================================ FILE: lib/error_tracker/migration/mysql/v05.ex ================================================ defmodule ErrorTracker.Migration.MySQL.V05 do @moduledoc false use Ecto.Migration def up(_opts) do alter table(:error_tracker_errors) do add :muted, :boolean, default: false, null: false end end def down(_opts) do alter table(:error_tracker_errors) do remove :muted end end end ================================================ FILE: lib/error_tracker/migration/mysql.ex ================================================ defmodule ErrorTracker.Migration.MySQL do @moduledoc false @behaviour ErrorTracker.Migration use Ecto.Migration alias ErrorTracker.Migration.SQLMigrator @initial_version 3 @current_version 5 @impl ErrorTracker.Migration def up(opts) do opts = with_defaults(opts, @current_version) SQLMigrator.migrate_up(__MODULE__, opts, @initial_version) end @impl ErrorTracker.Migration def down(opts) do opts = with_defaults(opts, @initial_version) SQLMigrator.migrate_down(__MODULE__, opts, @initial_version) end @impl ErrorTracker.Migration def current_version(opts) do opts = with_defaults(opts, @initial_version) SQLMigrator.current_version(opts) end defp with_defaults(opts, version) do Enum.into(opts, %{version: version}) end end ================================================ FILE: lib/error_tracker/migration/postgres/v01.ex ================================================ defmodule ErrorTracker.Migration.Postgres.V01 do @moduledoc false use Ecto.Migration import Ecto.Query def up(%{create_schema: create_schema, prefix: prefix} = opts) do # Prior to V02 the migration version was stored in table comments. # As of now the migration version is stored in a new table (created in V02). # # However, systems migrating to V02 may think they need to run V01 too, so # we need to check for the legacy version storage to avoid running this # migration twice. if current_version_legacy(opts) == 0 do if create_schema, do: execute("CREATE SCHEMA IF NOT EXISTS #{prefix}") create table(:error_tracker_meta, primary_key: [name: :key, type: :string], prefix: prefix ) do add :value, :string, null: false end create table(:error_tracker_errors, primary_key: [name: :id, type: :bigserial], prefix: prefix ) do add :kind, :string, null: false add :reason, :text, null: false add :source_line, :text, null: false add :source_function, :text, null: false add :status, :string, null: false add :fingerprint, :string, null: false add :last_occurrence_at, :utc_datetime_usec, null: false timestamps(type: :utc_datetime_usec) end create unique_index(:error_tracker_errors, [:fingerprint], prefix: prefix) create table(:error_tracker_occurrences, primary_key: [name: :id, type: :bigserial], prefix: prefix ) do add :context, :map, null: false add :reason, :text, null: false add :stacktrace, :map, null: false add :error_id, references(:error_tracker_errors, on_delete: :delete_all, column: :id, type: :bigserial ), null: false timestamps(type: :utc_datetime_usec, updated_at: false) end create index(:error_tracker_occurrences, [:error_id], prefix: prefix) else :noop end end def down(%{prefix: prefix}) do drop table(:error_tracker_occurrences, prefix: prefix) drop table(:error_tracker_errors, prefix: prefix) drop_if_exists table(:error_tracker_meta, prefix: prefix) end def current_version_legacy(opts) do query = from pg_class in "pg_class", left_join: pg_description in "pg_description", on: pg_description.objoid == pg_class.oid, left_join: pg_namespace in "pg_namespace", on: pg_namespace.oid == pg_class.relnamespace, where: pg_class.relname == "error_tracker_errors", where: pg_namespace.nspname == ^opts.escaped_prefix, select: pg_description.description case repo().one(query, log: false) do version when is_binary(version) -> String.to_integer(version) _other -> 0 end end end ================================================ FILE: lib/error_tracker/migration/postgres/v02.ex ================================================ defmodule ErrorTracker.Migration.Postgres.V02 do @moduledoc false use Ecto.Migration def up(%{prefix: prefix}) do # For systems which executed versions without this migration they may not # have the error_tracker_meta table, so we need to create it conditionally # to avoid errors. create_if_not_exists table(:error_tracker_meta, primary_key: [name: :key, type: :string], prefix: prefix ) do add :value, :string, null: false end execute "COMMENT ON TABLE #{inspect(prefix)}.error_tracker_errors IS ''" end def down(%{prefix: prefix}) do # We do not delete the `error_tracker_meta` table because it's creation and # deletion are controlled by V01 migration. execute "COMMENT ON TABLE #{inspect(prefix)}.error_tracker_errors IS '1'" end end ================================================ FILE: lib/error_tracker/migration/postgres/v03.ex ================================================ defmodule ErrorTracker.Migration.Postgres.V03 do @moduledoc false use Ecto.Migration def up(%{prefix: prefix}) do create_if_not_exists index(:error_tracker_errors, [:last_occurrence_at], prefix: prefix) end def down(%{prefix: prefix}) do drop_if_exists index(:error_tracker_errors, [:last_occurrence_at], prefix: prefix) end end ================================================ FILE: lib/error_tracker/migration/postgres/v04.ex ================================================ defmodule ErrorTracker.Migration.Postgres.V04 do @moduledoc false use Ecto.Migration def up(%{prefix: prefix}) do alter table(:error_tracker_occurrences, prefix: prefix) do add :breadcrumbs, {:array, :string}, default: [], null: false end end def down(%{prefix: prefix}) do alter table(:error_tracker_occurrences, prefix: prefix) do remove :breadcrumbs end end end ================================================ FILE: lib/error_tracker/migration/postgres/v05.ex ================================================ defmodule ErrorTracker.Migration.Postgres.V05 do @moduledoc false use Ecto.Migration def up(%{prefix: prefix}) do alter table(:error_tracker_errors, prefix: prefix) do add :muted, :boolean, default: false, null: false end end def down(%{prefix: prefix}) do alter table(:error_tracker_errors, prefix: prefix) do remove :muted end end end ================================================ FILE: lib/error_tracker/migration/postgres.ex ================================================ defmodule ErrorTracker.Migration.Postgres do @moduledoc false @behaviour ErrorTracker.Migration use Ecto.Migration alias ErrorTracker.Migration.SQLMigrator @initial_version 1 @current_version 5 @default_prefix "public" @impl ErrorTracker.Migration def up(opts) do opts = with_defaults(opts, @current_version) SQLMigrator.migrate_up(__MODULE__, opts, @initial_version) end @impl ErrorTracker.Migration def down(opts) do opts = with_defaults(opts, @initial_version) SQLMigrator.migrate_down(__MODULE__, opts, @initial_version) end @impl ErrorTracker.Migration def current_version(opts) do opts = with_defaults(opts, @initial_version) SQLMigrator.current_version(opts) end defp with_defaults(opts, version) do configured_prefix = Application.get_env(:error_tracker, :prefix, "public") opts = Enum.into(opts, %{prefix: configured_prefix, version: version}) opts |> Map.put_new(:create_schema, opts.prefix != @default_prefix) |> Map.put_new(:escaped_prefix, String.replace(opts.prefix, "'", "\\'")) end end ================================================ FILE: lib/error_tracker/migration/sql_migrator.ex ================================================ defmodule ErrorTracker.Migration.SQLMigrator do @moduledoc false use Ecto.Migration import Ecto.Query alias Ecto.Adapters.SQL def migrate_up(migrator, opts, initial_version) do initial = current_version(opts) cond do initial == 0 -> change(migrator, initial_version..opts.version, :up, opts) initial < opts.version -> change(migrator, (initial + 1)..opts.version, :up, opts) true -> :ok end end def migrate_down(migrator, opts, initial_version) do initial = max(current_version(opts), initial_version) if initial >= opts.version do change(migrator, initial..opts.version//-1, :down, opts) end end def current_version(opts) do repo = Map.get_lazy(opts, :repo, fn -> repo() end) query = from meta in "error_tracker_meta", where: meta.key == "migration_version", select: meta.value with true <- meta_table_exists?(repo, opts), version when is_binary(version) <- repo.one(query, log: false, prefix: opts[:prefix]) do String.to_integer(version) else _other -> 0 end end defp change(migrator, versions_range, direction, opts) do for version <- versions_range do padded_version = String.pad_leading(to_string(version), 2, "0") migration_module = Module.concat(migrator, "V#{padded_version}") apply(migration_module, direction, [opts]) end case direction do :up -> record_version(opts, Enum.max(versions_range)) :down -> record_version(opts, Enum.min(versions_range) - 1) end end defp record_version(_opts, 0), do: :ok defp record_version(opts, version) do timestamp = DateTime.to_unix(DateTime.utc_now()) ErrorTracker.Repo.with_adapter(fn :postgres -> prefix = opts[:prefix] execute """ INSERT INTO #{prefix}.error_tracker_meta (key, value) VALUES ('migration_version', '#{version}'), ('migration_timestamp', '#{timestamp}') ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value """ :mysql -> execute """ INSERT INTO error_tracker_meta (`key`, value) VALUES ('migration_version', '#{version}'), ('migration_timestamp', '#{timestamp}') ON DUPLICATE KEY UPDATE value = VALUES(value) """ _other -> execute """ INSERT INTO error_tracker_meta (key, value) VALUES ('migration_version', '#{version}'), ('migration_timestamp', '#{timestamp}') ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value """ end) end defp meta_table_exists?(repo, opts) do ErrorTracker.Repo.with_adapter(fn :postgres -> repo |> SQL.query!( "SELECT TRUE FROM information_schema.tables WHERE table_name = 'error_tracker_meta' AND table_schema = $1", [opts.prefix], log: false ) |> Map.get(:rows) |> Enum.any?() _other -> SQL.table_exists?(repo, "error_tracker_meta", log: false) end) end end ================================================ FILE: lib/error_tracker/migration/sqlite/v02.ex ================================================ defmodule ErrorTracker.Migration.SQLite.V02 do @moduledoc false use Ecto.Migration def up(_opts) do create table(:error_tracker_meta, primary_key: [name: :key, type: :string]) do add :value, :string, null: false end create table(:error_tracker_errors, primary_key: [name: :id, type: :bigserial]) do add :kind, :string, null: false add :reason, :text, null: false add :source_line, :text, null: false add :source_function, :text, null: false add :status, :string, null: false add :fingerprint, :string, null: false add :last_occurrence_at, :utc_datetime_usec, null: false timestamps(type: :utc_datetime_usec) end create unique_index(:error_tracker_errors, [:fingerprint]) create table(:error_tracker_occurrences, primary_key: [name: :id, type: :bigserial]) do add :context, :map, null: false add :reason, :text, null: false add :stacktrace, :map, null: false add :error_id, references(:error_tracker_errors, on_delete: :delete_all, column: :id, type: :bigserial ), null: false timestamps(type: :utc_datetime_usec, updated_at: false) end create index(:error_tracker_occurrences, [:error_id]) end def down(_opts) do drop table(:error_tracker_occurrences) drop table(:error_tracker_errors) drop table(:error_tracker_meta) end end ================================================ FILE: lib/error_tracker/migration/sqlite/v03.ex ================================================ defmodule ErrorTracker.Migration.SQLite.V03 do @moduledoc false use Ecto.Migration def up(_opts) do create_if_not_exists index(:error_tracker_errors, [:last_occurrence_at]) end def down(_opts) do drop_if_exists index(:error_tracker_errors, [:last_occurrence_at]) end end ================================================ FILE: lib/error_tracker/migration/sqlite/v04.ex ================================================ defmodule ErrorTracker.Migration.SQLite.V04 do @moduledoc false use Ecto.Migration def up(_opts) do alter table(:error_tracker_occurrences) do add :breadcrumbs, {:array, :string}, default: [], null: false end end def down(_opts) do alter table(:error_tracker_occurrences) do remove :breadcrumbs end end end ================================================ FILE: lib/error_tracker/migration/sqlite/v05.ex ================================================ defmodule ErrorTracker.Migration.SQLite.V05 do @moduledoc false use Ecto.Migration def up(_opts) do alter table(:error_tracker_errors) do add :muted, :boolean, default: false, null: false end end def down(_opts) do alter table(:error_tracker_errors) do remove :muted end end end ================================================ FILE: lib/error_tracker/migration/sqlite.ex ================================================ defmodule ErrorTracker.Migration.SQLite do @moduledoc false @behaviour ErrorTracker.Migration use Ecto.Migration alias ErrorTracker.Migration.SQLMigrator @initial_version 2 @current_version 5 @impl ErrorTracker.Migration def up(opts) do opts = with_defaults(opts, @current_version) SQLMigrator.migrate_up(__MODULE__, opts, @initial_version) end @impl ErrorTracker.Migration def down(opts) do opts = with_defaults(opts, @initial_version) SQLMigrator.migrate_down(__MODULE__, opts, @initial_version) end @impl ErrorTracker.Migration def current_version(opts) do opts = with_defaults(opts, @initial_version) SQLMigrator.current_version(opts) end defp with_defaults(opts, version) do Enum.into(opts, %{version: version}) end end ================================================ FILE: lib/error_tracker/migration.ex ================================================ defmodule ErrorTracker.Migration do @moduledoc """ Create and modify the database tables for ErrorTracker. ## Usage To use ErrorTracker migrations in your application you will need to generate a regular `Ecto.Migration` that performs the relevant calls to `ErrorTracker.Migration`. ```bash mix ecto.gen.migration add_error_tracker ``` Open the generated migration file and call the `up` and `down` functions on `ErrorTracker.Migration`. ```elixir defmodule MyApp.Repo.Migrations.AddErrorTracker do use Ecto.Migration def up, do: ErrorTracker.Migration.up() def down, do: ErrorTracker.Migration.down() end ``` This will run every ErrorTracker migration for your database. You can now run the migration and perform the database changes: ```bash mix ecto.migrate ``` As new versions of ErrorTracker are released you may need to run additional migrations. To do this you can follow the previous process and create a new migration: ```bash mix ecto.gen.migration update_error_tracker_to_vN ``` Open the generated migration file and call the `up` and `down` functions on the `ErrorTracker.Migration` passing the desired `version`. ```elixir defmodule MyApp.Repo.Migrations.UpdateErrorTrackerToVN do use Ecto.Migration def up, do: ErrorTracker.Migration.up(version: N) def down, do: ErrorTracker.Migration.down(version: N) end ``` Then run the migrations to perform the database changes: ```bash mix ecto.migrate ``` ## Custom prefix - PostgreSQL only ErrorTracker supports namespacing its own tables using PostgreSQL schemas, also known as "prefixes" in Ecto. With prefixes your error tables can reside outside of your primary schema (which is usually named "public"). To use a prefix you need to specify it in your configuration: ```elixir config :error_tracker, :prefix, "custom_prefix" ``` Migrations will automatically create the database schema for you. If the schema does already exist the migration may fail when trying to recreate it. In such cases you can instruct ErrorTracker not to create the schema again: ```elixir defmodule MyApp.Repo.Migrations.AddErrorTracker do use Ecto.Migration def up, do: ErrorTracker.Migration.up(create_schema: false) def down, do: ErrorTracker.Migration.down() end ``` You can also override the configured prefix in the migration: ```elixir defmodule MyApp.Repo.Migrations.AddErrorTracker do use Ecto.Migration def up, do: ErrorTracker.Migration.up(prefix: "custom_prefix") def down, do: ErrorTracker.Migration.down(prefix: "custom_prefix") end ``` """ @callback up(Keyword.t()) :: :ok @callback down(Keyword.t()) :: :ok @callback current_version(Keyword.t()) :: non_neg_integer() @spec up(Keyword.t()) :: :ok def up(opts \\ []) when is_list(opts) do migrator().up(opts) end @spec down(Keyword.t()) :: :ok def down(opts \\ []) when is_list(opts) do migrator().down(opts) end @spec migrated_version(Keyword.t()) :: non_neg_integer() def migrated_version(opts \\ []) when is_list(opts) do migrator().migrated_version(opts) end defp migrator do ErrorTracker.Repo.with_adapter(fn :postgres -> ErrorTracker.Migration.Postgres :mysql -> ErrorTracker.Migration.MySQL :sqlite -> ErrorTracker.Migration.SQLite adapter -> raise "ErrorTracker does not support #{adapter}" end) end end ================================================ FILE: lib/error_tracker/plugins/pruner.ex ================================================ defmodule ErrorTracker.Plugins.Pruner do @moduledoc """ Periodically delete resolved errors based on their age. Pruning allows you to keep your database size under control by removing old errors that are not needed anymore. ## Using the pruner To enable the pruner you must register the plugin in the ErrorTracker configuration. This will use the default options, which is to prune errors resolved after 24 hours. config :error_tracker, plugins: [ErrorTracker.Plugins.Pruner] You can override the default options by passing them as an argument when registering the plugin. config :error_tracker, plugins: [{ErrorTracker.Plugins.Pruner, max_age: :timer.minutes(30)}] ## Options - `:limit` - the maximum number of errors to prune on each execution. Occurrences are removed along the errors. The default is 200 to prevent timeouts and unnecesary database load. - `:max_age` - the number of milliseconds after a resolved error may be pruned. The default is 24 hours. - `:interval` - the interval in milliseconds between pruning runs. The default is 30 minutes. You may find the `:timer` module functions useful to pass readable values to the `:max_age` and `:interval` options. ## Manual pruning In certain cases you may prefer to run the pruner manually. This can be done by calling the `prune_errors/2` function from your application code. This function supports the `:limit` and `:max_age` options as described above. For example, you may call this function from an Oban worker so you can leverage Oban's cron capabilities and have a more granular control over when pruning is run. defmodule MyApp.ErrorPruner do use Oban.Worker def perform(%Job{}) do ErrorTracker.Plugins.Pruner.prune_errors(limit: 10_000, max_age: :timer.minutes(60)) end end """ use GenServer import Ecto.Query alias ErrorTracker.Error alias ErrorTracker.Occurrence alias ErrorTracker.Repo @doc """ Prunes resolved errors. You do not need to use this function if you activate the Pruner plugin. This function is exposed only for advanced use cases and Oban integration. ## Options - `:limit` - the maximum number of errors to prune on each execution. Occurrences are removed along the errors. The default is 200 to prevent timeouts and unnecesary database load. - `:max_age` - the number of milliseconds after a resolved error may be pruned. The default is 24 hours. You may find the `:timer` module functions useful to pass readable values to this option. """ @spec prune_errors(keyword()) :: {:ok, list(Error.t())} def prune_errors(opts \\ []) do limit = opts[:limit] || raise ":limit option is required" max_age = opts[:max_age] || raise ":max_age option is required" time = DateTime.add(DateTime.utc_now(), -max_age, :millisecond) errors = Repo.all( from error in Error, select: [:id, :kind, :source_line, :source_function], where: error.status == :resolved, where: error.last_occurrence_at < ^time, limit: ^limit ) if Enum.any?(errors) do _pruned_occurrences_count = errors |> Ecto.assoc(:occurrences) |> prune_occurrences() |> Enum.sum() Repo.delete_all(from error in Error, where: error.id in ^Enum.map(errors, & &1.id)) end {:ok, errors} end defp prune_occurrences(occurrences_query) do Stream.unfold(occurrences_query, fn occurrences_query -> occurrences_ids = Repo.all(from occurrence in occurrences_query, select: occurrence.id, limit: 1000) case Repo.delete_all(from o in Occurrence, where: o.id in ^occurrences_ids) do {0, _} -> nil {deleted, _} -> {deleted, occurrences_query} end end) end def start_link(state \\ []) do GenServer.start_link(__MODULE__, state, name: __MODULE__) end @impl GenServer @doc false def init(state \\ []) do state = %{ limit: state[:limit] || 200, max_age: state[:max_age] || :timer.hours(24), interval: state[:interval] || :timer.minutes(30) } {:ok, schedule_prune(state)} end @impl GenServer @doc false def handle_info(:prune, state) do {:ok, _pruned} = prune_errors(state) {:noreply, schedule_prune(state)} end defp schedule_prune(%{interval: interval} = state) do Process.send_after(self(), :prune, interval) state end end ================================================ FILE: lib/error_tracker/repo.ex ================================================ defmodule ErrorTracker.Repo do @moduledoc false def insert!(struct_or_changeset, opts \\ []) do dispatch(:insert!, [struct_or_changeset], opts) end def update(changeset, opts \\ []) do dispatch(:update, [changeset], opts) end def get(queryable, id, opts \\ []) do dispatch(:get, [queryable, id], opts) end def get!(queryable, id, opts \\ []) do dispatch(:get!, [queryable, id], opts) end def one(queryable, opts \\ []) do dispatch(:one, [queryable], opts) end def all(queryable, opts \\ []) do dispatch(:all, [queryable], opts) end def delete_all(queryable, opts \\ []) do dispatch(:delete_all, [queryable], opts) end def aggregate(queryable, aggregate, opts \\ []) do dispatch(:aggregate, [queryable, aggregate], opts) end def transaction(fun_or_multi, opts \\ []) do dispatch(:transaction, [fun_or_multi], opts) end def with_adapter(fun) do adapter = case repo().__adapter__() do Ecto.Adapters.Postgres -> :postgres Ecto.Adapters.MyXQL -> :mysql Ecto.Adapters.SQLite3 -> :sqlite end fun.(adapter) end defp dispatch(action, args, opts) do repo = repo() defaults = with_adapter(fn :postgres -> [prefix: Application.get_env(:error_tracker, :prefix, "public")] _ -> [] end) opts_w_defaults = Keyword.merge(defaults, opts) apply(repo, action, args ++ [opts_w_defaults]) end defp repo do Application.fetch_env!(:error_tracker, :repo) end end ================================================ FILE: lib/error_tracker/schemas/error.ex ================================================ defmodule ErrorTracker.Error do @moduledoc """ Schema to store an error or exception recorded by ErrorTracker. It stores a kind, reason and source code location to generate a unique fingerprint that can be used to avoid duplicates. The fingerprint currently does not include the reason itself because it can contain specific details that can change on the same error depending on runtime conditions. """ use Ecto.Schema @type t :: %__MODULE__{ kind: String.t(), reason: String.t(), source_line: String.t(), source_function: String.t(), status: :resolved | :unresolved, fingerprint: String.t(), last_occurrence_at: DateTime.t(), muted: boolean() } schema "error_tracker_errors" do field :kind, :string field :reason, :string field :source_line, :string field :source_function, :string field :status, Ecto.Enum, values: [:resolved, :unresolved], default: :unresolved field :fingerprint, :binary field :last_occurrence_at, :utc_datetime_usec field :muted, :boolean has_many :occurrences, ErrorTracker.Occurrence timestamps(type: :utc_datetime_usec) end @doc false def new(kind, reason, %ErrorTracker.Stacktrace{} = stacktrace) do source = ErrorTracker.Stacktrace.source(stacktrace) {source_line, source_function} = if source do source_line = if source.line, do: "#{source.file}:#{source.line}", else: "(nofile)" source_function = "#{source.module}.#{source.function}/#{source.arity}" {source_line, source_function} else {"-", "-"} end params = [ kind: to_string(kind), source_line: source_line, source_function: source_function ] fingerprint = :crypto.hash(:sha256, params |> Keyword.values() |> Enum.join()) %__MODULE__{} |> Ecto.Changeset.change(params) |> Ecto.Changeset.put_change(:reason, reason) |> Ecto.Changeset.put_change(:fingerprint, Base.encode16(fingerprint)) |> Ecto.Changeset.put_change(:last_occurrence_at, DateTime.utc_now()) |> Ecto.Changeset.apply_action(:new) end @doc """ Returns if the Error has information of the source or not. Errors usually have information about in which line and function occurred, but in some cases (like an Oban job ending with `{:error, any()}`) we cannot get that information and no source is stored. """ def has_source_info?(%__MODULE__{source_function: "-", source_line: "-"}), do: false def has_source_info?(%__MODULE__{}), do: true end ================================================ FILE: lib/error_tracker/schemas/occurrence.ex ================================================ defmodule ErrorTracker.Occurrence do @moduledoc """ Schema to store a particular instance of an error in a given time. It contains all the metadata available about the moment and the environment in which the exception raised. """ use Ecto.Schema import Ecto.Changeset require Logger @type t :: %__MODULE__{} schema "error_tracker_occurrences" do field :reason, :string field :context, :map field :breadcrumbs, {:array, :string} embeds_one :stacktrace, ErrorTracker.Stacktrace belongs_to :error, ErrorTracker.Error timestamps(type: :utc_datetime_usec, updated_at: false) end @doc false def changeset(occurrence, attrs) do occurrence |> cast(attrs, [:context, :reason, :breadcrumbs]) |> maybe_put_stacktrace() |> validate_required([:reason, :stacktrace]) |> validate_context() |> foreign_key_constraint(:error) end # This function validates if the context can be serialized to JSON before # storing it to the DB. # # If it cannot be serialized a warning log message is emitted and an error # is stored in the context. # defp validate_context(changeset) do if changeset.valid? do context = get_field(changeset, :context, %{}) db_json_encoder = ErrorTracker.Repo.with_adapter(fn :postgres -> Application.get_env(:postgrex, :json_library) :mysql -> Application.get_env(:myxql, :json_library) :sqlite -> Application.get_env(:ecto_sqlite3, :json_library) end) validated_context = try do json_encoder = db_json_encoder || ErrorTracker.__default_json_encoder__() _iodata = json_encoder.encode_to_iodata!(context) context rescue _e -> Logger.warning("[ErrorTracker] Context has been ignored: it is not serializable to JSON.") %{ error: "Context not stored because it contains information not serializable to JSON." } end put_change(changeset, :context, validated_context) else changeset end end defp maybe_put_stacktrace(changeset) do if stacktrace = Map.get(changeset.params, "stacktrace"), do: put_embed(changeset, :stacktrace, stacktrace), else: changeset end end ================================================ FILE: lib/error_tracker/schemas/stacktrace.ex ================================================ defmodule ErrorTracker.Stacktrace do @moduledoc """ An Stacktrace contains the information about the execution stack for a given occurrence of an exception. """ use Ecto.Schema @type t :: %__MODULE__{} @primary_key false embedded_schema do embeds_many :lines, Line, primary_key: false do field :application, :string field :module, :string field :function, :string field :arity, :integer field :file, :string field :line, :integer end end def new(stack) do lines_params = for {module, function, arity, opts} <- stack do application = Application.get_application(module) %{ application: to_string(application), module: module |> to_string() |> String.replace_prefix("Elixir.", ""), function: to_string(function), arity: normalize_arity(arity), file: to_string(opts[:file]), line: opts[:line] } end %__MODULE__{} |> Ecto.Changeset.cast(%{lines: lines_params}, []) |> Ecto.Changeset.cast_embed(:lines, with: &line_changeset/2) |> Ecto.Changeset.apply_action(:new) end defp normalize_arity(a) when is_integer(a), do: a defp normalize_arity(a) when is_list(a), do: length(a) defp line_changeset(%__MODULE__.Line{} = line, params) do Ecto.Changeset.cast(line, params, ~w[application module function arity file line]a) end @doc """ Source of the error stack trace. The first line matching the client application. If no line belongs to the current application, just the first line. """ def source(%__MODULE__{} = stack) do client_app = :error_tracker |> Application.fetch_env!(:otp_app) |> to_string() Enum.find(stack.lines, &(&1.application == client_app)) || List.first(stack.lines) end end defimpl String.Chars, for: ErrorTracker.Stacktrace do def to_string(%ErrorTracker.Stacktrace{} = stack) do Enum.join(stack.lines, "\n") end end defimpl String.Chars, for: ErrorTracker.Stacktrace.Line do def to_string(%ErrorTracker.Stacktrace.Line{} = stack_line) do "#{stack_line.module}.#{stack_line.function}/#{stack_line.arity} in #{stack_line.file}:#{stack_line.line}" end end ================================================ FILE: lib/error_tracker/telemetry.ex ================================================ defmodule ErrorTracker.Telemetry do @moduledoc """ Telemetry events of ErrorTracker. ErrorTracker emits some events to allow third parties to receive information of errors and occurrences stored. ### Error events Those occur during the life cycle of an error: * `[:error_tracker, :error, :new]`: is emitted when a new error is stored and no previous occurrences were known. * `[:error_tracker, :error, :resolved]`: is emitted when a new error is marked as resolved on the UI. * `[:error_tracker, :error, :unresolved]`: is emitted when a new error is marked as unresolved on the UI or a new occurrence is registered, moving the error to the unresolved state. ### Occurrence events There is only one event emitted for occurrences: * `[:error_tracker, :occurrence, :new]`: is emitted when a new occurrence is stored. ### Measures and metadata Each event is emitted with some measures and metadata, which can be used to receive information without having to query the database again: | event | measures | metadata | | --------------------------------------- | -------------- | ----------------------------------| | `[:error_tracker, :error, :new]` | `:system_time` | `:error` | | `[:error_tracker, :error, :unresolved]` | `:system_time` | `:error` | | `[:error_tracker, :error, :resolved]` | `:system_time` | `:error` | | `[:error_tracker, :occurrence, :new]` | `:system_time` | `:occurrence`, `:error`, `:muted` | The metadata keys contain the following data: * `:error` - An `%ErrorTracker.Error{}` struct representing the error. * `:occurrence` - An `%ErrorTracker.Occurrence{}` struct representing the occurrence. * `:muted` - A boolean indicating whether the error is muted or not. """ @doc false def new_error(%ErrorTracker.Error{} = error) do measurements = %{system_time: System.system_time()} metadata = %{error: error} :telemetry.execute([:error_tracker, :error, :new], measurements, metadata) end @doc false def unresolved_error(%ErrorTracker.Error{} = error) do measurements = %{system_time: System.system_time()} metadata = %{error: error} :telemetry.execute([:error_tracker, :error, :unresolved], measurements, metadata) end @doc false def resolved_error(%ErrorTracker.Error{} = error) do measurements = %{system_time: System.system_time()} metadata = %{error: error} :telemetry.execute([:error_tracker, :error, :resolved], measurements, metadata) end @doc false def new_occurrence(%ErrorTracker.Occurrence{} = occurrence, muted) when is_boolean(muted) do measurements = %{system_time: System.system_time()} metadata = %{error: occurrence.error, occurrence: occurrence, muted: muted} :telemetry.execute([:error_tracker, :occurrence, :new], measurements, metadata) end end ================================================ FILE: lib/error_tracker/web/components/core_components.ex ================================================ defmodule ErrorTracker.Web.CoreComponents do @moduledoc false use Phoenix.Component @doc """ Renders a button. ## Examples <.button>Send! <.button phx-click="go" class="ml-2">Send! """ attr :type, :string, default: nil attr :class, :string, default: nil attr :rest, :global, include: ~w(disabled form name value href patch navigate) slot :inner_block, required: true def button(%{type: "link"} = assigns) do ~H""" <.link class={[ "phx-submit-loading:opacity-75 py-[11.5px]", "text-sm font-semibold text-sky-500 hover:text-white/80", @class ]} {@rest} > {render_slot(@inner_block)} """ end def button(assigns) do ~H""" """ end @doc """ Renders a badge. ## Examples <.badge>Info <.badge color={:red}>Error """ attr :color, :atom, default: :blue attr :rest, :global slot :inner_block, required: true def badge(assigns) do color_class = case assigns.color do :blue -> "bg-blue-900 text-blue-300" :gray -> "bg-gray-700 text-gray-300" :red -> "bg-red-400/10 text-red-300 ring-red-400/20" :green -> "bg-emerald-400/10 text-emerald-300 ring-emerald-400/20" :yellow -> "bg-yellow-900 text-yellow-300" :indigo -> "bg-indigo-900 text-indigo-300" :purple -> "bg-purple-900 text-purple-300" :pink -> "bg-pink-900 text-pink-300" end assigns = Map.put(assigns, :color_class, color_class) ~H""" {render_slot(@inner_block)} """ end attr :page, :integer, required: true attr :total_pages, :integer, required: true attr :event_previous, :string, default: "prev-page" attr :event_next, :string, default: "next-page" def pagination(assigns) do ~H"""
""" end attr :title, :string attr :title_class, :string, default: nil attr :rest, :global slot :inner_block, required: true def section(assigns) do ~H"""

{@title}

{render_slot(@inner_block)}
""" end attr :name, :string, values: ~w[bell bell-slash arrow-left arrow-right] def icon(%{name: "bell"} = assigns) do ~H""" """ end def icon(%{name: "bell-slash"} = assigns) do ~H""" """ end def icon(%{name: "arrow-left"} = assigns) do ~H""" """ end def icon(%{name: "arrow-right"} = assigns) do ~H""" """ end end ================================================ FILE: lib/error_tracker/web/components/layouts/live.html.heex ================================================ <.navbar id="navbar" {assigns} />
{@inner_content}
================================================ FILE: lib/error_tracker/web/components/layouts/root.html.heex ================================================ {assigns[:page_title] || "🐛 ErrorTracker"} {@inner_content} ================================================ FILE: lib/error_tracker/web/components/layouts.ex ================================================ defmodule ErrorTracker.Web.Layouts do @moduledoc false use ErrorTracker.Web, :html phoenix_js_paths = for app <- ~w[phoenix phoenix_html phoenix_live_view]a do path = Application.app_dir(app, ["priv", "static", "#{app}.js"]) Module.put_attribute(__MODULE__, :external_resource, path) path end @static_path Application.app_dir(:error_tracker, ["priv", "static"]) @external_resource css_path = Path.join(@static_path, "app.css") @external_resource js_path = Path.join(@static_path, "app.js") @css File.read!(css_path) @js """ #{for path <- phoenix_js_paths, do: path |> File.read!() |> String.replace("//# sourceMappingURL=", "// ")} #{File.read!(js_path)} """ @default_socket_config %{path: "/live", transport: :websocket} embed_templates "layouts/*" def get_content(:css), do: @css def get_content(:js), do: @js def get_socket_config(key) do default = Map.get(@default_socket_config, key) config = Application.get_env(:error_tracker, :live_view_socket, []) Keyword.get(config, key, default) end def navbar(assigns) do ~H""" """ end attr :to, :string, required: true attr :rest, :global slot :inner_block, required: true def navbar_item(assigns) do ~H"""
  • {render_slot(@inner_block)}
  • """ end end ================================================ FILE: lib/error_tracker/web/helpers.ex ================================================ defmodule ErrorTracker.Web.Helpers do @moduledoc false @doc false def sanitize_module(<<"Elixir.", str::binary>>), do: str def sanitize_module(str), do: str @doc false def format_datetime(%DateTime{} = dt), do: Calendar.strftime(dt, "%c %Z") end ================================================ FILE: lib/error_tracker/web/hooks/set_assigns.ex ================================================ defmodule ErrorTracker.Web.Hooks.SetAssigns do @moduledoc false import Phoenix.Component, only: [assign: 2] def on_mount({:set_dashboard_path, path}, _params, session, socket) do socket = %{socket | private: Map.put(socket.private, :dashboard_path, path)} {:cont, assign(socket, csp_nonces: session["csp_nonces"])} end end ================================================ FILE: lib/error_tracker/web/live/dashboard.ex ================================================ defmodule ErrorTracker.Web.Live.Dashboard do @moduledoc false use ErrorTracker.Web, :live_view import Ecto.Query alias ErrorTracker.Error alias ErrorTracker.Repo alias ErrorTracker.Web.Search @per_page 10 @impl Phoenix.LiveView def handle_params(params, uri, socket) do path = struct(URI, uri |> URI.parse() |> Map.take([:path, :query])) {:noreply, socket |> assign( path: path, search: Search.from_params(params), page: 1, search_form: Search.to_form(params) ) |> paginate_errors()} end @impl Phoenix.LiveView def handle_event("search", params, socket) do search = Search.from_params(params["search"] || %{}) %URI{} = path = socket.assigns.path path_w_filters = %{path | query: URI.encode_query(search)} {:noreply, push_patch(socket, to: URI.to_string(path_w_filters))} end @impl Phoenix.LiveView def handle_event("next-page", _params, socket) do {:noreply, socket |> assign(page: socket.assigns.page + 1) |> paginate_errors()} end @impl Phoenix.LiveView def handle_event("prev-page", _params, socket) do {:noreply, socket |> assign(page: socket.assigns.page - 1) |> paginate_errors()} end @impl Phoenix.LiveView def handle_event("resolve", %{"error_id" => id}, socket) do error = Repo.get(Error, id) {:ok, _resolved} = ErrorTracker.resolve(error) {:noreply, paginate_errors(socket)} end @impl Phoenix.LiveView def handle_event("unresolve", %{"error_id" => id}, socket) do error = Repo.get(Error, id) {:ok, _unresolved} = ErrorTracker.unresolve(error) {:noreply, paginate_errors(socket)} end @impl Phoenix.LiveView def handle_event("mute", %{"error_id" => id}, socket) do error = Repo.get(Error, id) {:ok, _muted} = ErrorTracker.mute(error) {:noreply, paginate_errors(socket)} end @impl Phoenix.LiveView def handle_event("unmute", %{"error_id" => id}, socket) do error = Repo.get(Error, id) {:ok, _unmuted} = ErrorTracker.unmute(error) {:noreply, paginate_errors(socket)} end defp paginate_errors(socket) do %{page: page, search: search} = socket.assigns offset = (page - 1) * @per_page query = filter(Error, search) total_errors = Repo.aggregate(query, :count) errors = Repo.all( from query, order_by: [desc: :last_occurrence_at], offset: ^offset, limit: @per_page ) error_ids = Enum.map(errors, & &1.id) occurrences = if errors == [] do [] else errors |> Ecto.assoc(:occurrences) |> where([o], o.error_id in ^error_ids) |> group_by([o], o.error_id) |> select([o], {o.error_id, count(o.id)}) |> Repo.all() end assign(socket, errors: errors, occurrences: Map.new(occurrences), total_pages: (total_errors / @per_page) |> Float.ceil() |> trunc() ) end defp filter(query, search) do Enum.reduce(search, query, &do_filter/2) end defp do_filter({:status, status}, query) do where(query, [error], error.status == ^status) end defp do_filter({field, value}, query) do # Postgres provides the ILIKE operator which produces a case-insensitive match between two # strings. SQLite3 only supports LIKE, which is case-insensitive for ASCII characters. Repo.with_adapter(fn :postgres -> where(query, [error], ilike(field(error, ^field), ^"%#{value}%")) :mysql -> where(query, [error], like(field(error, ^field), ^"%#{value}%")) :sqlite -> where(query, [error], like(field(error, ^field), ^"%#{value}%")) end) end end ================================================ FILE: lib/error_tracker/web/live/dashboard.html.heex ================================================ <.form for={@search_form} id="search" class="mb-4 text-black grid md:grid-cols-4 grid-cols-2 gap-2" phx-change="search" >
    Error Occurrences Status
    No errors to show 🎉
    <.link navigate={error_path(@socket, error, @search)} class="absolute inset-1"> ({sanitize_module(error.kind)}) {error.reason}

    ({sanitize_module(error.kind)}) {error.reason}

    {sanitize_module(error.source_function)}
    {error.source_line}

    Last: {format_datetime(error.last_occurrence_at)}

    Total: {@occurrences[error.id]}

    <.badge :if={error.status == :resolved} color={:green}>Resolved <.badge :if={error.status == :unresolved} color={:red}>Unresolved
    <.button :if={error.status == :unresolved} phx-click="resolve" phx-value-error_id={error.id} > Resolve <.button :if={error.status == :resolved} phx-click="unresolve" phx-value-error_id={error.id} > Unresolve <.button :if={!error.muted} phx-click="mute" type="link" phx-value-error_id={error.id}> <.icon name="bell-slash" /> Mute <.button :if={error.muted} phx-click="unmute" type="link" phx-value-error_id={error.id} > <.icon name="bell" /> Unmute
    <.pagination page={@page} total_pages={@total_pages} /> ================================================ FILE: lib/error_tracker/web/live/show.ex ================================================ defmodule ErrorTracker.Web.Live.Show do @moduledoc false use ErrorTracker.Web, :live_view import Ecto.Query alias ErrorTracker.Error alias ErrorTracker.Occurrence alias ErrorTracker.Repo alias ErrorTracker.Web.Search @occurrences_to_navigate 50 @impl Phoenix.LiveView def mount(%{"id" => id} = params, _session, socket) do error = Repo.get!(Error, id) {:ok, assign(socket, error: error, app: Application.fetch_env!(:error_tracker, :otp_app), search: Search.from_params(params) )} end @impl Phoenix.LiveView def handle_params(params, _uri, socket) do occurrence = if occurrence_id = params["occurrence_id"] do socket.assigns.error |> Ecto.assoc(:occurrences) |> Repo.get!(occurrence_id) else socket.assigns.error |> Ecto.assoc(:occurrences) |> order_by([o], desc: o.id) |> limit(1) |> Repo.one() end socket = socket |> assign(occurrence: occurrence) |> load_related_occurrences() {:noreply, socket} end @impl Phoenix.LiveView def handle_event("occurrence_navigation", %{"occurrence_id" => id}, socket) do occurrence_path = occurrence_path( socket, %Occurrence{error_id: socket.assigns.error.id, id: id}, socket.assigns.search ) {:noreply, push_patch(socket, to: occurrence_path)} end @impl Phoenix.LiveView def handle_event("resolve", _params, socket) do {:ok, updated_error} = ErrorTracker.resolve(socket.assigns.error) {:noreply, assign(socket, :error, updated_error)} end @impl Phoenix.LiveView def handle_event("unresolve", _params, socket) do {:ok, updated_error} = ErrorTracker.unresolve(socket.assigns.error) {:noreply, assign(socket, :error, updated_error)} end @impl Phoenix.LiveView def handle_event("mute", _params, socket) do {:ok, updated_error} = ErrorTracker.mute(socket.assigns.error) {:noreply, assign(socket, :error, updated_error)} end @impl Phoenix.LiveView def handle_event("unmute", _params, socket) do {:ok, updated_error} = ErrorTracker.unmute(socket.assigns.error) {:noreply, assign(socket, :error, updated_error)} end defp load_related_occurrences(socket) do current_occurrence = socket.assigns.occurrence base_query = Ecto.assoc(socket.assigns.error, :occurrences) half_limit = floor(@occurrences_to_navigate / 2) previous_occurrences_query = where(base_query, [o], o.id < ^current_occurrence.id) next_occurrences_query = where(base_query, [o], o.id > ^current_occurrence.id) previous_count = Repo.aggregate(previous_occurrences_query, :count) next_count = Repo.aggregate(next_occurrences_query, :count) {previous_limit, next_limit} = cond do previous_count < half_limit and next_count < half_limit -> {previous_count, next_count} previous_count < half_limit -> {previous_count, @occurrences_to_navigate - previous_count - 1} next_count < half_limit -> {@occurrences_to_navigate - next_count - 1, next_count} true -> {half_limit, half_limit} end occurrences = [ related_occurrences(next_occurrences_query, next_limit), current_occurrence, related_occurrences(previous_occurrences_query, previous_limit) ] |> List.flatten() |> Enum.reverse() total_occurrences = socket.assigns.error |> Ecto.assoc(:occurrences) |> Repo.aggregate(:count) next_occurrence = base_query |> where([o], o.id > ^current_occurrence.id) |> order_by([o], asc: o.id) |> limit(1) |> select([:id, :error_id, :inserted_at]) |> Repo.one() prev_occurrence = base_query |> where([o], o.id < ^current_occurrence.id) |> order_by([o], desc: o.id) |> limit(1) |> select([:id, :error_id, :inserted_at]) |> Repo.one() socket |> assign(:occurrences, occurrences) |> assign(:total_occurrences, total_occurrences) |> assign(:next, next_occurrence) |> assign(:prev, prev_occurrence) end defp related_occurrences(query, num_results) do query |> order_by([o], desc: o.id) |> select([:id, :error_id, :inserted_at]) |> limit(^num_results) |> Repo.all() end end ================================================ FILE: lib/error_tracker/web/live/show.html.heex ================================================
    <.link navigate={dashboard_path(@socket, @search)}> <.icon name="arrow-left" /> Back to the dashboard
    <.section title="Full message">
    <%= @occurrence.reason %>
    <.section :if={ErrorTracker.Error.has_source_info?(@error)} title="Source">
            <%= sanitize_module(@error.source_function) %>
            <%= @error.source_line %>
    <.section :if={@occurrence.breadcrumbs != []} title="Bread crumbs">
    Enum.reverse() |> Enum.with_index() } class="border-b bg-gray-400/10 border-gray-900 last:border-b-0" >
    {length(@occurrence.breadcrumbs) - index}. {breadcrumb}
    <.section :if={@occurrence.stacktrace.lines != []} title="Stacktrace">
    (<%= line.application || @app %>)
    <%= "#{sanitize_module(line.module)}.#{line.function}/#{line.arity}" %>
                    <%= if line.line, do: "#{line.file}:#{line.line}", else: "(nofile)" %>
    <.section title="Context">
            <%= ErrorTracker.__default_json_encoder__().encode_to_iodata!(@occurrence.context) %>
          
    <.section title={"Occurrence (#{@total_occurrences} total)"}>
    <.section title="Error kind">
    <%= sanitize_module(@error.kind) %>
    <.section title="Last seen">
    <%= format_datetime(@error.last_occurrence_at) %>
    <.section title="First seen">
    <%= format_datetime(@error.inserted_at) %>
    <.section title="Status" title_class="mb-3"> <.badge :if={@error.status == :resolved} color={:green}>Resolved <.badge :if={@error.status == :unresolved} color={:red}>Unresolved <.section>
    <.button :if={@error.status == :unresolved} phx-click="resolve"> Mark as resolved <.button :if={@error.status == :resolved} phx-click="unresolve"> Mark as unresolved <.button :if={!@error.muted} phx-click="mute" type="link"> <.icon name="bell-slash" /> Mute <.button :if={@error.muted} phx-click="unmute" type="link"> <.icon name="bell" /> Unmute
    ================================================ FILE: lib/error_tracker/web/router/routes.ex ================================================ defmodule ErrorTracker.Web.Router.Routes do @moduledoc false alias ErrorTracker.Error alias ErrorTracker.Occurrence alias Phoenix.LiveView.Socket @doc """ Returns the dashboard path """ def dashboard_path(%Socket{} = socket, params \\ %{}) do socket |> dashboard_uri(params) |> URI.to_string() end @doc """ Returns the path to see the details of an error """ def error_path(%Socket{} = socket, %Error{id: id}, params \\ %{}) do socket |> dashboard_uri(params) |> URI.append_path("/#{id}") |> URI.to_string() end @doc """ Returns the path to see the details of an occurrence """ def occurrence_path(%Socket{} = socket, %Occurrence{id: id, error_id: error_id}, params \\ %{}) do socket |> dashboard_uri(params) |> URI.append_path("/#{error_id}/#{id}") |> URI.to_string() end defp dashboard_uri(%Socket{} = socket, params) do %URI{ path: socket.private[:dashboard_path], query: if(Enum.any?(params), do: URI.encode_query(params)) } end end ================================================ FILE: lib/error_tracker/web/router.ex ================================================ defmodule ErrorTracker.Web.Router do @moduledoc """ ErrorTracker UI integration into your application's router. """ alias ErrorTracker.Web.Hooks.SetAssigns @doc """ Creates the routes needed to use the `ErrorTracker` web interface. It requires a path in which you are going to serve the web interface. In order to work properly, the route should be in a scope with CSRF protection (usually the `:browser` pipeline). ## Security considerations The dashboard inlines both the JS and CSS assets. This means that, if your application has a Content Security Policy, you need to specify the `csp_nonce_assign_key` option, which is explained below. ## Options * `on_mount`: a list of mount hooks to use before invoking the dashboard LiveView views. * `as`: a session name to use for the dashboard LiveView session. By default it uses `:error_tracker_dashboard`. * `csp_nonce_assign_key`: an assign key to find the CSP nonce value used for assets. Supports either `atom()` or a map of type `%{optional(:img) => atom(), optional(:script) => atom(), optional(:style) => atom()}` """ defmacro error_tracker_dashboard(path, opts \\ []) do quote bind_quoted: [path: path, opts: opts] do # Ensure that the given path includes previous scopes so we can generate proper # paths for navigating through the dashboard. scoped_path = Phoenix.Router.scoped_path(__MODULE__, path) # Generate the session name and session hooks. {session_name, session_opts} = ErrorTracker.Web.Router.__parse_options__(opts, scoped_path) scope path, alias: false, as: false do import Phoenix.LiveView.Router, only: [live: 4, live_session: 3] alias ErrorTracker.Web.Live.Show live_session session_name, session_opts do live "/", ErrorTracker.Web.Live.Dashboard, :index, as: session_name live "/:id", Show, :show, as: session_name live "/:id/:occurrence_id", Show, :show, as: session_name end end end end @doc false def __parse_options__(opts, path) do custom_on_mount = Keyword.get(opts, :on_mount, []) session_name = Keyword.get(opts, :as, :error_tracker_dashboard) csp_nonce_assign_key = case opts[:csp_nonce_assign_key] do nil -> nil key when is_atom(key) -> %{img: key, style: key, script: key} keys when is_map(keys) -> Map.take(keys, [:img, :style, :script]) end session_opts = [ session: {__MODULE__, :__session__, [csp_nonce_assign_key]}, on_mount: [{SetAssigns, {:set_dashboard_path, path}}] ++ custom_on_mount, root_layout: {ErrorTracker.Web.Layouts, :root} ] {session_name, session_opts} end @doc false def __session__(conn, csp_nonce_assign_key) do %{ "csp_nonces" => %{ img: conn.assigns[csp_nonce_assign_key[:img]], style: conn.assigns[csp_nonce_assign_key[:style]], script: conn.assigns[csp_nonce_assign_key[:script]] } } end end ================================================ FILE: lib/error_tracker/web/search.ex ================================================ defmodule ErrorTracker.Web.Search do @moduledoc false @types %{ reason: :string, source_line: :string, source_function: :string, status: :string } defp changeset(params) do Ecto.Changeset.cast({%{}, @types}, params, Map.keys(@types)) end @spec from_params(map()) :: %{atom() => String.t()} def from_params(params) do params |> changeset() |> Ecto.Changeset.apply_changes() end @spec to_form(map()) :: Phoenix.HTML.Form.t() def to_form(params) do params |> changeset() |> Phoenix.Component.to_form(as: :search) end end ================================================ FILE: lib/error_tracker/web.ex ================================================ defmodule ErrorTracker.Web do @moduledoc """ ErrorTracker includes a dashboard to view and inspect errors that occurred on your application and are already stored in the database. In order to use it, you need to add the following to your Phoenix's `router.ex` file: ```elixir defmodule YourAppWeb.Router do use Phoenix.Router use ErrorTracker.Web, :router ... scope "/" do ... error_tracker_dashboard "/errors" end end ``` This will add the routes needed for ErrorTracker's dashboard to work. **Note:** when adding the dashboard routes, make sure you do it in an scope that has CSRF protection (usually the `:browser` pipeline in most projects), as otherwise you may experience LiveView issues like crashes and redirections. ## Security considerations Errors may contain sensitive information, like IP addresses, users information or even passwords sent on forms! Securing your dashboard is an important part of integrating ErrorTracker on your project. In order to do so, we recommend implementing your own security mechanisms in the form of a mount hook and pass it to the `error_tracker_dashboard` macro using the `on_mount` option. You can find more details on `ErrorTracker.Web.Router.error_tracker_dashboard/2`. ### Static assets Static assets (CSS and JS) are inlined during the compilation. If you have a [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) be sure to allow inline styles and scripts. To do this, ensure that your `style-src` and `script-src` policies include the `unsafe-inline` value. ## LiveView socket options By default the library expects you to have your LiveView socket at `/live` and using `websocket` transport. If that's not the case, you can configure it adding the following configuration to your app's config files: ```elixir config :error_tracker, live_view_socket: [ path: "/my-custom-live-path" transport: :longpoll # (accepted values are :longpoll or :websocket) ] ``` """ @doc false def html do quote do import Phoenix.Controller, only: [get_csrf_token: 0] unquote(html_helpers()) end end @doc false def live_view do quote do use Phoenix.LiveView, layout: {ErrorTracker.Web.Layouts, :live} unquote(html_helpers()) end end @doc false def live_component do quote do use Phoenix.LiveComponent unquote(html_helpers()) end end @doc false def router do quote do import ErrorTracker.Web.Router end end defp html_helpers do quote do use Phoenix.Component import ErrorTracker.Web.CoreComponents import ErrorTracker.Web.Helpers import ErrorTracker.Web.Router.Routes import Phoenix.HTML import Phoenix.LiveView.Helpers alias Phoenix.LiveView.JS end end defmacro __using__(which) when is_atom(which) do apply(__MODULE__, which, []) end end ================================================ FILE: lib/error_tracker.ex ================================================ defmodule ErrorTracker do @moduledoc """ En Elixir-based built-in error tracking solution. The main objectives behind this project are: * Provide a basic free error tracking solution: because tracking errors in your application should be a requirement for almost any project, and helps to provide quality and maintenance to your project. * Be easy to use: by providing plug-and-play integrations, documentation and a simple UI to manage your errors. * Be as minimalistic as possible: you just need a database to store errors and a Phoenix application if you want to inspect them via web. That's all. ## Requirements ErrorTracker requires Elixir 1.15+, Ecto 3.11+, Phoenix LiveView 0.19+, and PostgreSQL, MySQL/MariaDB or SQLite3 as database. ## Integrations We currently include integrations for what we consider the basic stack of an application: Phoenix, Plug, and Oban. However, we may continue working in adding support for more systems and libraries in the future if there is enough interest from the community. If you want to manually report an error, you can use the `ErrorTracker.report/3` function. ## Context Aside from the information about each exception (kind, message, stack trace...) we also store contexts. Contexts are arbitrary maps that allow you to store extra information about an exception to be able to reproduce it later. Each integration includes a default context with useful information they can gather, but aside from that, you can also add your own information. You can do this in a per-process basis or in a per-call basis (or both). There are some requirements on the type of data that can be included in the context, so we recommend taking a look at `set_context/1` documentation. **Per process** This allows you to set a general context for the current process such as a Phoenix request or an Oban job. For example, you could include the following code in your authentication Plug to automatically include the user ID in any error that is tracked during the Phoenix request handling. ```elixir ErrorTracker.set_context(%{user_id: conn.assigns.current_user.id}) ``` **Per call** As we had seen before, you can use `ErrorTracker.report/3` to manually report an error. The third parameter of this function is optional and allows you to include extra context that will be tracked along with the error. ## Breadcrumbs Aside from contextual information, it is sometimes useful to know in which points of your code the code was executed in a given request / process. Using breadcrumbs allows you to add that information to any error generated and stored on a given process / request. And if you are using `Ash` or `Splode` their exceptions' breadcrumbs will be automatically populated. If you want to add a breadcrumb in a point of your code you can do so: ```elixir ErrorTracker.add_breadcrumb("Executed my super secret code") ``` Breadcrumbs can be viewed in the dashboard on the details page of an occurrence. """ import Ecto.Query alias ErrorTracker.Error alias ErrorTracker.Occurrence alias ErrorTracker.Repo alias ErrorTracker.Telemetry @typedoc """ A map containing the relevant context for a particular error. """ @type context :: %{(String.t() | atom()) => any()} @typedoc """ An `Exception` or a `{kind, payload}` tuple compatible with `Exception.normalize/3`. """ @type exception :: Exception.t() | {:error, any()} | {Exception.non_error_kind(), any()} @doc """ Report an exception to be stored. Returns the occurrence stored or `:noop` if the ErrorTracker is disabled by configuration the exception has not been stored. Aside from the exception, it is expected to receive the stack trace and, optionally, a context map which will be merged with the current process context. Keep in mind that errors that occur in Phoenix controllers, Phoenix LiveViews and Oban jobs are automatically reported. You will need this function only if you want to report custom errors. ```elixir try do # your code catch e -> ErrorTracker.report(e, __STACKTRACE__) end ``` ## Exceptions Exceptions can be passed in three different forms: * An exception struct: the module of the exception is stored along with the exception message. * A `{kind, exception}` tuple in which case the information is converted to an Elixir exception (if possible) and stored. """ @spec report(exception(), Exception.stacktrace(), context()) :: Occurrence.t() | :noop def report(exception, stacktrace, given_context \\ %{}) do {kind, reason} = normalize_exception(exception, stacktrace) {:ok, stacktrace} = ErrorTracker.Stacktrace.new(stacktrace) {:ok, error} = Error.new(kind, reason, stacktrace) context = Map.merge(get_context(), given_context) breadcrumbs = get_breadcrumbs() ++ exception_breadcrumbs(exception) if enabled?() && !ignored?(error, context) do sanitized_context = sanitize_context(context) upsert_error!(error, stacktrace, sanitized_context, breadcrumbs, reason) else :noop end end @doc """ Marks an error as resolved. If an error is marked as resolved and it happens again, it will automatically appear as unresolved again. """ @spec resolve(Error.t()) :: {:ok, Error.t()} | {:error, Ecto.Changeset.t()} def resolve(%Error{status: :unresolved} = error) do changeset = Ecto.Changeset.change(error, status: :resolved) with {:ok, updated_error} <- Repo.update(changeset) do Telemetry.resolved_error(updated_error) {:ok, updated_error} end end @doc """ Marks an error as unresolved. """ @spec unresolve(Error.t()) :: {:ok, Error.t()} | {:error, Ecto.Changeset.t()} def unresolve(%Error{status: :resolved} = error) do changeset = Ecto.Changeset.change(error, status: :unresolved) with {:ok, updated_error} <- Repo.update(changeset) do Telemetry.unresolved_error(updated_error) {:ok, updated_error} end end @doc """ Mutes the error so new occurrences won't send telemetry events. When an error is muted: - New occurrences are still tracked and stored in the database - No telemetry events are emitted for new occurrences - You can still see the error and its occurrences in the web UI This is useful for noisy errors that you want to keep tracking but don't want to receive notifications about. """ @spec mute(Error.t()) :: {:ok, Error.t()} | {:error, Ecto.Changeset.t()} def mute(%Error{} = error) do changeset = Ecto.Changeset.change(error, muted: true) Repo.update(changeset) end @doc """ Unmutes the error so new occurrences will send telemetry events again. This reverses the effect of `mute/1`, allowing telemetry events to be emitted for new occurrences of this error again. """ @spec unmute(Error.t()) :: {:ok, Error.t()} | {:error, Ecto.Changeset.t()} def unmute(%Error{} = error) do changeset = Ecto.Changeset.change(error, muted: false) Repo.update(changeset) end @doc """ Sets the current process context. The given context will be merged into the current process context. The given context may override existing keys from the current process context. ## Context depth You can store context on more than one level of depth, but take into account that the merge operation is performed on the first level. That means that any existing data on deep levels for he current context will be replaced if the first level key is received on the new contents. ## Content serialization The content stored on the context should be serializable using the JSON library used by the application (usually `JSON` for Elixir 1.18+ and `Jason` for older versions), so it is recommended to use primitive types (strings, numbers, booleans...). If you still need to pass more complex data types to your context, please test that they can be encoded to JSON or storing the errors will fail. You may need to define a custom encoder for that data type if not included by default. """ @spec set_context(context()) :: context() def set_context(params) when is_map(params) do current_context = Process.get(:error_tracker_context, %{}) Process.put(:error_tracker_context, Map.merge(current_context, params)) params end @doc """ Obtain the context of the current process. """ @spec get_context() :: context() def get_context do Process.get(:error_tracker_context, %{}) end @doc """ Adds a breadcrumb to the current process. The new breadcrumb will be added as the most recent entry of the breadcrumbs list. ## Breadcrumbs limit Breadcrumbs are a powerful tool that allows to add an infinite number of entries. However, it is not recommended to store errors with an excessive amount of breadcrumbs. As they are stored as an array of strings under the hood, storing many entries per error can lead to some delays and using extra disk space on the database. """ @spec add_breadcrumb(String.t()) :: list(String.t()) def add_breadcrumb(breadcrumb) when is_binary(breadcrumb) do current_breadcrumbs = Process.get(:error_tracker_breadcrumbs, []) new_breadcrumbs = current_breadcrumbs ++ [breadcrumb] Process.put(:error_tracker_breadcrumbs, new_breadcrumbs) new_breadcrumbs end @doc """ Obtain the breadcrumbs of the current process. """ @spec get_breadcrumbs() :: list(String.t()) def get_breadcrumbs do Process.get(:error_tracker_breadcrumbs, []) end defp enabled? do !!Application.get_env(:error_tracker, :enabled, true) end defp ignored?(error, context) do ignorer = Application.get_env(:error_tracker, :ignorer) ignorer && ignorer.ignore?(error, context) end defp sanitize_context(context) do filter_mod = Application.get_env(:error_tracker, :filter) if filter_mod, do: filter_mod.sanitize(context), else: context end defp normalize_exception(%struct{} = ex, _stacktrace) when is_exception(ex) do {to_string(struct), Exception.message(ex)} end defp normalize_exception({kind, ex}, stacktrace) do case Exception.normalize(kind, ex, stacktrace) do %struct{} = ex -> {to_string(struct), Exception.message(ex)} payload -> {to_string(kind), safe_to_string(payload)} end end defp safe_to_string(term) do to_string(term) rescue Protocol.UndefinedError -> inspect(term) end defp exception_breadcrumbs(exception) do case exception do {_kind, exception} -> exception_breadcrumbs(exception) %{bread_crumbs: breadcrumbs} -> breadcrumbs _other -> [] end end defp upsert_error!(error, stacktrace, context, breadcrumbs, reason) do status_and_muted_query = from e in Error, where: [fingerprint: ^error.fingerprint], select: {e.status, e.muted} {existing_status, muted} = case Repo.one(status_and_muted_query) do {existing_status, muted} -> {existing_status, muted} nil -> {nil, false} end {:ok, {error, occurrence}} = Repo.transaction(fn -> error = Repo.with_adapter(fn :mysql -> Repo.insert!(error, on_conflict: [set: [status: :unresolved, last_occurrence_at: DateTime.utc_now()]] ) _other -> Repo.insert!(error, on_conflict: [set: [status: :unresolved, last_occurrence_at: DateTime.utc_now()]], conflict_target: :fingerprint ) end) occurrence = error |> Ecto.build_assoc(:occurrences) |> Occurrence.changeset(%{ stacktrace: stacktrace, context: context, breadcrumbs: breadcrumbs, reason: reason }) |> Repo.insert!() {error, occurrence} end) %Occurrence{} = occurrence occurrence = %{occurrence | error: error} # If the error existed and was marked as resolved before this exception, # sent a Telemetry event # If it is a new error, sent a Telemetry event case existing_status do :resolved -> Telemetry.unresolved_error(error) :unresolved -> :noop nil -> Telemetry.new_error(error) end Telemetry.new_occurrence(occurrence, muted) occurrence end @default_json_encoder (cond do Code.ensure_loaded?(JSON) -> JSON Code.ensure_loaded?(Jason) -> Jason true -> raise """ No JSON encoder found. Please add Jason to your dependencies: {:jason, "~> 1.1"} Or upgrade to Elixir 1.18+. """ end) @doc false def __default_json_encoder__, do: @default_json_encoder end ================================================ FILE: lib/mix/tasks/error_tracker.install.ex ================================================ defmodule Mix.Tasks.ErrorTracker.Install.Docs do @moduledoc false def short_doc do "Install and configure ErrorTracker for use in this application." end def example do "mix error_tracker.install" end def long_doc do """ #{short_doc()} ## Example ```bash #{example()} ``` """ end end if Code.ensure_loaded?(Igniter) do defmodule Mix.Tasks.ErrorTracker.Install do @shortdoc "#{__MODULE__.Docs.short_doc()}" @moduledoc __MODULE__.Docs.long_doc() use Igniter.Mix.Task alias Igniter.Project.Config @impl Igniter.Mix.Task def info(_argv, _composing_task) do %Igniter.Mix.Task.Info{ # Groups allow for overlapping arguments for tasks by the same author # See the generators guide for more. group: :error_tracker, # *other* dependencies to add # i.e `{:foo, "~> 2.0"}` adds_deps: [], # *other* dependencies to add and call their associated installers, if they exist # i.e `{:foo, "~> 2.0"}` installs: [], # An example invocation example: __MODULE__.Docs.example(), # A list of environments that this should be installed in. only: nil, # a list of positional arguments, i.e `[:file]` positional: [], # Other tasks your task composes using `Igniter.compose_task`, passing in the CLI argv # This ensures your option schema includes options from nested tasks composes: [], # `OptionParser` schema schema: [], # Default values for the options in the `schema` defaults: [], # CLI aliases aliases: [], # A list of options in the schema that are required required: [] } end @impl Igniter.Mix.Task def igniter(igniter) do app_name = Igniter.Project.Application.app_name(igniter) {igniter, repo} = Igniter.Libs.Ecto.select_repo(igniter) {igniter, router} = Igniter.Libs.Phoenix.select_router(igniter) igniter |> set_up_configuration(app_name, repo) |> set_up_formatter() |> set_up_database(repo) |> set_up_web_ui(app_name, router) end defp set_up_configuration(igniter, app_name, repo) do igniter |> Config.configure_new("config.exs", :error_tracker, [:repo], repo) |> Config.configure_new("config.exs", :error_tracker, [:otp_app], app_name) |> Config.configure_new("config.exs", :error_tracker, [:enabled], true) end defp set_up_formatter(igniter) do Igniter.Project.Formatter.import_dep(igniter, :error_tracker) end defp set_up_database(igniter, repo) do migration_body = """ def up, do: ErrorTracker.Migration.up() def down, do: ErrorTracker.Migration.down(version: 1) """ Igniter.Libs.Ecto.gen_migration(igniter, repo, "add_error_tracker", body: migration_body, on_exists: :skip ) end defp set_up_web_ui(igniter, app_name, router) do if router do Igniter.Project.Module.find_and_update_module!(igniter, router, fn zipper -> zipper = Igniter.Code.Common.add_code( zipper, """ if Application.compile_env(#{inspect(app_name)}, :dev_routes) do use ErrorTracker.Web, :router scope "/dev" do pipe_through :browser error_tracker_dashboard "/errors" end end """, placement: :after ) {:ok, zipper} end) else Igniter.add_warning(igniter, """ No Phoenix router found or selected. Please ensure that Phoenix is set up and then run this installer again with mix igniter.install error_tracker """) end end end else defmodule Mix.Tasks.ErrorTracker.Install do @shortdoc "#{__MODULE__.Docs.short_doc()} | Install `igniter` to use" @moduledoc __MODULE__.Docs.long_doc() use Mix.Task def run(_argv) do Mix.shell().error(""" The task 'error_tracker.install' requires igniter. Please install igniter and try again. For more information, see: https://hexdocs.pm/igniter/readme.html#installation """) exit({:shutdown, 1}) end end end ================================================ FILE: mix.exs ================================================ defmodule ErrorTracker.MixProject do use Mix.Project def project do [ app: :error_tracker, version: "0.8.0", elixir: "~> 1.15", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, deps: deps(), package: package(), description: description(), source_url: "https://github.com/elixir-error-tracker/error-tracker", aliases: aliases(), name: "ErrorTracker", docs: [ main: "ErrorTracker", formatters: ["html"], groups_for_modules: groups_for_modules(), extra_section: "GUIDES", extras: [ "guides/Getting Started.md" ], api_reference: false, main: "getting-started" ] ] end # Run "mix help compile.app" to learn about applications. def application do [ mod: {ErrorTracker.Application, []}, extra_applications: [:logger] ] end defp elixirc_paths(:test), do: ["lib", "test/support"] defp elixirc_paths(_env), do: ["lib"] def package do [ licenses: ["Apache-2.0"], links: %{ "GitHub" => "https://github.com/elixir-error-tracker/error-tracker" }, maintainers: [ "Óscar de Arriba González", "Cristian Álvarez Belaustegui", "Víctor Ortiz Heredia" ], files: ~w(lib priv/static LICENSE mix.exs README.md .formatter.exs) ] end def description do "An Elixir-based built-in error tracking solution" end defp groups_for_modules do [ Integrations: [ ErrorTracker.Integrations.Oban, ErrorTracker.Integrations.Phoenix, ErrorTracker.Integrations.Plug ], Plugins: [ ErrorTracker.Plugins.Pruner ], Schemas: [ ErrorTracker.Error, ErrorTracker.Occurrence, ErrorTracker.Stacktrace, ErrorTracker.Stacktrace.Line ], "Web UI": [ ErrorTracker.Web, ErrorTracker.Web.Router ] ] end # Run "mix help deps" to learn about dependencies. defp deps do [ {:ecto_sql, "~> 3.13"}, {:ecto, "~> 3.13"}, {:phoenix_ecto, "~> 4.6"}, {:phoenix_live_view, "~> 1.0"}, {:plug, "~> 1.10"}, # Dev dependencies {:bun, "~> 1.3", only: :dev}, {:ex_doc, "~> 0.33", only: :dev}, {:phoenix_live_reload, ">= 0.0.0", only: :dev}, {:plug_cowboy, ">= 0.0.0", only: :dev}, {:styler, "~> 1.11", only: [:dev, :test], runtime: false}, {:tailwind, "~> 0.2", only: :dev}, # Optional dependencies {:ecto_sqlite3, ">= 0.0.0", optional: true}, {:igniter, "~> 0.5", optional: true}, {:jason, "~> 1.1", optional: true}, {:myxql, ">= 0.0.0", optional: true}, {:postgrex, ">= 0.0.0", optional: true} ] end defp aliases do [ dev: "run --no-halt dev.exs", "assets.install": ["bun.install", "cmd _build/bun install --cwd assets/"], "assets.watch": ["tailwind default --watch"], "assets.build": ["bun default", "tailwind default"] ] end end ================================================ FILE: priv/repo/migrations/20240527155639_create_error_tracker_tables.exs ================================================ defmodule ErrorTracker.Repo.Migrations.CreateErrorTrackerTables do use Ecto.Migration defdelegate up, to: ErrorTracker.Migration defdelegate down, to: ErrorTracker.Migration end ================================================ FILE: priv/repo/seeds.exs ================================================ adapter = case Application.get_env(:error_tracker, :ecto_adapter) do :postgres -> Ecto.Adapters.Postgres :sqlite3 -> Ecto.Adapters.SQLite3 end defmodule ErrorTrackerDev.Repo do use Ecto.Repo, otp_app: :error_tracker, adapter: adapter end ErrorTrackerDev.Repo.start_link() ErrorTrackerDev.Repo.delete_all(ErrorTracker.Error) errors = for i <- 1..100 do %{ kind: "Error #{i}", reason: "Reason #{i}", source_line: "line", source_function: "function", status: :unresolved, fingerprint: "#{i}", last_occurrence_at: DateTime.utc_now(), inserted_at: DateTime.utc_now(), updated_at: DateTime.utc_now() } end {_, errors} = dbg(ErrorTrackerDev.Repo.insert_all(ErrorTracker.Error, errors, returning: [:id])) for error <- errors do occurrences = for _i <- 1..200 do %{ context: %{}, reason: "REASON", stacktrace: %ErrorTracker.Stacktrace{}, error_id: error.id, inserted_at: DateTime.utc_now() } end ErrorTrackerDev.Repo.insert_all(ErrorTracker.Occurrence, occurrences) end ================================================ FILE: priv/static/app.css ================================================ /* ! tailwindcss v3.4.3 | MIT License | https://tailwindcss.com */ /* 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) 2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) */ *, ::before, ::after { box-sizing: border-box; /* 1 */ border-width: 0; /* 2 */ border-style: solid; /* 2 */ border-color: #e5e7eb; /* 2 */ } ::before, ::after { --tw-content: ''; } /* 1. Use a consistent sensible line-height in all browsers. 2. Prevent adjustments of font size after orientation changes in iOS. 3. Use a more readable tab size. 4. Use the user's configured `sans` font-family by default. 5. Use the user's configured `sans` font-feature-settings by default. 6. Use the user's configured `sans` font-variation-settings by default. 7. Disable tap highlights on iOS */ html, :host { line-height: 1.5; /* 1 */ -webkit-text-size-adjust: 100%; /* 2 */ -moz-tab-size: 4; /* 3 */ -o-tab-size: 4; tab-size: 4; /* 3 */ font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; /* 4 */ font-feature-settings: normal; /* 5 */ font-variation-settings: normal; /* 6 */ -webkit-tap-highlight-color: transparent; /* 7 */ } /* 1. Remove the margin in all browsers. 2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. */ body { margin: 0; /* 1 */ line-height: inherit; /* 2 */ } /* 1. Add the correct height in Firefox. 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) 3. Ensure horizontal rules are visible by default. */ hr { height: 0; /* 1 */ color: inherit; /* 2 */ border-top-width: 1px; /* 3 */ } /* Add the correct text decoration in Chrome, Edge, and Safari. */ abbr:where([title]) { -webkit-text-decoration: underline dotted; text-decoration: underline dotted; } /* Remove the default font size and weight for headings. */ h1, h2, h3, h4, h5, h6 { font-size: inherit; font-weight: inherit; } /* Reset links to optimize for opt-in styling instead of opt-out. */ a { color: inherit; text-decoration: inherit; } /* Add the correct font weight in Edge and Safari. */ b, strong { font-weight: bolder; } /* 1. Use the user's configured `mono` font-family by default. 2. Use the user's configured `mono` font-feature-settings by default. 3. Use the user's configured `mono` font-variation-settings by default. 4. Correct the odd `em` font sizing in all browsers. */ code, kbd, samp, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; /* 1 */ font-feature-settings: normal; /* 2 */ font-variation-settings: normal; /* 3 */ font-size: 1em; /* 4 */ } /* Add the correct font size in all browsers. */ small { font-size: 80%; } /* Prevent `sub` and `sup` elements from affecting the line height in all browsers. */ sub, sup { font-size: 75%; line-height: 0; position: relative; vertical-align: baseline; } sub { bottom: -0.25em; } sup { top: -0.5em; } /* 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) 3. Remove gaps between table borders by default. */ table { text-indent: 0; /* 1 */ border-color: inherit; /* 2 */ border-collapse: collapse; /* 3 */ } /* 1. Change the font styles in all browsers. 2. Remove the margin in Firefox and Safari. 3. Remove default padding in all browsers. */ button, input, optgroup, select, textarea { font-family: inherit; /* 1 */ font-feature-settings: inherit; /* 1 */ font-variation-settings: inherit; /* 1 */ font-size: 100%; /* 1 */ font-weight: inherit; /* 1 */ line-height: inherit; /* 1 */ letter-spacing: inherit; /* 1 */ color: inherit; /* 1 */ margin: 0; /* 2 */ padding: 0; /* 3 */ } /* Remove the inheritance of text transform in Edge and Firefox. */ button, select { text-transform: none; } /* 1. Correct the inability to style clickable types in iOS and Safari. 2. Remove default button styles. */ button, input:where([type='button']), input:where([type='reset']), input:where([type='submit']) { -webkit-appearance: button; /* 1 */ background-color: transparent; /* 2 */ background-image: none; /* 2 */ } /* Use the modern Firefox focus style for all focusable elements. */ :-moz-focusring { outline: auto; } /* Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) */ :-moz-ui-invalid { box-shadow: none; } /* Add the correct vertical alignment in Chrome and Firefox. */ progress { vertical-align: baseline; } /* Correct the cursor style of increment and decrement buttons in Safari. */ ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { height: auto; } /* 1. Correct the odd appearance in Chrome and Safari. 2. Correct the outline style in Safari. */ [type='search'] { -webkit-appearance: textfield; /* 1 */ outline-offset: -2px; /* 2 */ } /* Remove the inner padding in Chrome and Safari on macOS. */ ::-webkit-search-decoration { -webkit-appearance: none; } /* 1. Correct the inability to style clickable types in iOS and Safari. 2. Change font properties to `inherit` in Safari. */ ::-webkit-file-upload-button { -webkit-appearance: button; /* 1 */ font: inherit; /* 2 */ } /* Add the correct display in Chrome and Safari. */ summary { display: list-item; } /* Removes the default spacing and border for appropriate elements. */ blockquote, dl, dd, h1, h2, h3, h4, h5, h6, hr, figure, p, pre { margin: 0; } fieldset { margin: 0; padding: 0; } legend { padding: 0; } ol, ul, menu { list-style: none; margin: 0; padding: 0; } /* Reset default styling for dialogs. */ dialog { padding: 0; } /* Prevent resizing textareas horizontally by default. */ textarea { resize: vertical; } /* 1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) 2. Set the default placeholder color to the user's configured gray 400 color. */ input::-moz-placeholder, textarea::-moz-placeholder { opacity: 1; /* 1 */ color: #9ca3af; /* 2 */ } input::placeholder, textarea::placeholder { opacity: 1; /* 1 */ color: #9ca3af; /* 2 */ } /* Set the default cursor for buttons. */ button, [role="button"] { cursor: pointer; } /* Make sure disabled buttons don't get the pointer cursor. */ :disabled { cursor: default; } /* 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) This can trigger a poorly considered lint error in some tools but is included by design. */ img, svg, video, canvas, audio, iframe, embed, object { display: block; /* 1 */ vertical-align: middle; /* 2 */ } /* Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) */ img, video { max-width: 100%; height: auto; } /* Make elements with the HTML hidden attribute stay hidden by default */ [hidden] { display: none; } [type='text'],[type='email'],[type='url'],[type='password'],[type='number'],[type='date'],[type='datetime-local'],[type='month'],[type='search'],[type='tel'],[type='time'],[type='week'],[multiple],textarea,select { -webkit-appearance: none; -moz-appearance: none; appearance: none; background-color: #fff; border-color: #6b7280; border-width: 1px; border-radius: 0px; padding-top: 0.5rem; padding-right: 0.75rem; padding-bottom: 0.5rem; padding-left: 0.75rem; font-size: 1rem; line-height: 1.5rem; --tw-shadow: 0 0 #0000; } [type='text']:focus, [type='email']:focus, [type='url']:focus, [type='password']:focus, [type='number']:focus, [type='date']:focus, [type='datetime-local']:focus, [type='month']:focus, [type='search']:focus, [type='tel']:focus, [type='time']:focus, [type='week']:focus, [multiple]:focus, textarea:focus, select:focus { outline: 2px solid transparent; outline-offset: 2px; --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/); --tw-ring-offset-width: 0px; --tw-ring-offset-color: #fff; --tw-ring-color: #2563eb; --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); border-color: #2563eb; } input::-moz-placeholder, textarea::-moz-placeholder { color: #6b7280; opacity: 1; } input::placeholder,textarea::placeholder { color: #6b7280; opacity: 1; } ::-webkit-datetime-edit-fields-wrapper { padding: 0; } ::-webkit-date-and-time-value { min-height: 1.5em; } ::-webkit-datetime-edit,::-webkit-datetime-edit-year-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-meridiem-field { padding-top: 0; padding-bottom: 0; } select { background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); background-position: right 0.5rem center; background-repeat: no-repeat; background-size: 1.5em 1.5em; padding-right: 2.5rem; -webkit-print-color-adjust: exact; print-color-adjust: exact; } [multiple] { background-image: initial; background-position: initial; background-repeat: unset; background-size: initial; padding-right: 0.75rem; -webkit-print-color-adjust: unset; print-color-adjust: unset; } [type='checkbox'],[type='radio'] { -webkit-appearance: none; -moz-appearance: none; appearance: none; padding: 0; -webkit-print-color-adjust: exact; print-color-adjust: exact; display: inline-block; vertical-align: middle; background-origin: border-box; -webkit-user-select: none; -moz-user-select: none; user-select: none; flex-shrink: 0; height: 1rem; width: 1rem; color: #2563eb; background-color: #fff; border-color: #6b7280; border-width: 1px; --tw-shadow: 0 0 #0000; } [type='checkbox'] { border-radius: 0px; } [type='radio'] { border-radius: 100%; } [type='checkbox']:focus,[type='radio']:focus { outline: 2px solid transparent; outline-offset: 2px; --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/); --tw-ring-offset-width: 2px; --tw-ring-offset-color: #fff; --tw-ring-color: #2563eb; --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } [type='checkbox']:checked,[type='radio']:checked { border-color: transparent; background-color: currentColor; background-size: 100% 100%; background-position: center; background-repeat: no-repeat; } [type='checkbox']:checked { background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e"); } [type='radio']:checked { background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e"); } [type='checkbox']:checked:hover,[type='checkbox']:checked:focus,[type='radio']:checked:hover,[type='radio']:checked:focus { border-color: transparent; background-color: currentColor; } [type='checkbox']:indeterminate { background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3e%3cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3e%3c/svg%3e"); border-color: transparent; background-color: currentColor; background-size: 100% 100%; background-position: center; background-repeat: no-repeat; } [type='checkbox']:indeterminate:hover,[type='checkbox']:indeterminate:focus { border-color: transparent; background-color: currentColor; } [type='file'] { background: unset; border-color: inherit; border-width: 0; border-radius: 0; padding: 0; font-size: unset; line-height: inherit; } [type='file']:focus { outline: 1px solid ButtonText; outline: 1px auto -webkit-focus-ring-color; } *, ::before, ::after { --tw-border-spacing-x: 0; --tw-border-spacing-y: 0; --tw-translate-x: 0; --tw-translate-y: 0; --tw-rotate: 0; --tw-skew-x: 0; --tw-skew-y: 0; --tw-scale-x: 1; --tw-scale-y: 1; --tw-pan-x: ; --tw-pan-y: ; --tw-pinch-zoom: ; --tw-scroll-snap-strictness: proximity; --tw-gradient-from-position: ; --tw-gradient-via-position: ; --tw-gradient-to-position: ; --tw-ordinal: ; --tw-slashed-zero: ; --tw-numeric-figure: ; --tw-numeric-spacing: ; --tw-numeric-fraction: ; --tw-ring-inset: ; --tw-ring-offset-width: 0px; --tw-ring-offset-color: #fff; --tw-ring-color: rgb(59 130 246 / 0.5); --tw-ring-offset-shadow: 0 0 #0000; --tw-ring-shadow: 0 0 #0000; --tw-shadow: 0 0 #0000; --tw-shadow-colored: 0 0 #0000; --tw-blur: ; --tw-brightness: ; --tw-contrast: ; --tw-grayscale: ; --tw-hue-rotate: ; --tw-invert: ; --tw-saturate: ; --tw-sepia: ; --tw-drop-shadow: ; --tw-backdrop-blur: ; --tw-backdrop-brightness: ; --tw-backdrop-contrast: ; --tw-backdrop-grayscale: ; --tw-backdrop-hue-rotate: ; --tw-backdrop-invert: ; --tw-backdrop-opacity: ; --tw-backdrop-saturate: ; --tw-backdrop-sepia: ; --tw-contain-size: ; --tw-contain-layout: ; --tw-contain-paint: ; --tw-contain-style: ; } ::backdrop { --tw-border-spacing-x: 0; --tw-border-spacing-y: 0; --tw-translate-x: 0; --tw-translate-y: 0; --tw-rotate: 0; --tw-skew-x: 0; --tw-skew-y: 0; --tw-scale-x: 1; --tw-scale-y: 1; --tw-pan-x: ; --tw-pan-y: ; --tw-pinch-zoom: ; --tw-scroll-snap-strictness: proximity; --tw-gradient-from-position: ; --tw-gradient-via-position: ; --tw-gradient-to-position: ; --tw-ordinal: ; --tw-slashed-zero: ; --tw-numeric-figure: ; --tw-numeric-spacing: ; --tw-numeric-fraction: ; --tw-ring-inset: ; --tw-ring-offset-width: 0px; --tw-ring-offset-color: #fff; --tw-ring-color: rgb(59 130 246 / 0.5); --tw-ring-offset-shadow: 0 0 #0000; --tw-ring-shadow: 0 0 #0000; --tw-shadow: 0 0 #0000; --tw-shadow-colored: 0 0 #0000; --tw-blur: ; --tw-brightness: ; --tw-contrast: ; --tw-grayscale: ; --tw-hue-rotate: ; --tw-invert: ; --tw-saturate: ; --tw-sepia: ; --tw-drop-shadow: ; --tw-backdrop-blur: ; --tw-backdrop-brightness: ; --tw-backdrop-contrast: ; --tw-backdrop-grayscale: ; --tw-backdrop-hue-rotate: ; --tw-backdrop-invert: ; --tw-backdrop-opacity: ; --tw-backdrop-saturate: ; --tw-backdrop-sepia: ; --tw-contain-size: ; --tw-contain-layout: ; --tw-contain-paint: ; --tw-contain-style: ; } .container { width: 100%; } @media (min-width: 640px) { .container { max-width: 640px; } } @media (min-width: 768px) { .container { max-width: 768px; } } @media (min-width: 1024px) { .container { max-width: 1024px; } } @media (min-width: 1280px) { .container { max-width: 1280px; } } @media (min-width: 1536px) { .container { max-width: 1536px; } } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border-width: 0; } .static { position: static; } .absolute { position: absolute; } .relative { position: relative; } .inset-1 { inset: 0.25rem; } .mx-auto { margin-left: auto; margin-right: auto; } .my-1 { margin-top: 0.25rem; margin-bottom: 0.25rem; } .my-6 { margin-top: 1.5rem; margin-bottom: 1.5rem; } .mb-1 { margin-bottom: 0.25rem; } .mb-2 { margin-bottom: 0.5rem; } .mb-3 { margin-bottom: 0.75rem; } .mb-4 { margin-bottom: 1rem; } .me-2 { margin-inline-end: 0.5rem; } .ml-2 { margin-left: 0.5rem; } .mr-1 { margin-right: 0.25rem; } .mr-2 { margin-right: 0.5rem; } .mt-1 { margin-top: 0.25rem; } .mt-10 { margin-top: 2.5rem; } .mt-2 { margin-top: 0.5rem; } .mt-4 { margin-top: 1rem; } .mt-6 { margin-top: 1.5rem; } .block { display: block; } .inline-block { display: inline-block; } .inline { display: inline; } .flex { display: flex; } .inline-flex { display: inline-flex; } .table { display: table; } .grid { display: grid; } .hidden { display: none; } .\!h-4 { height: 1rem !important; } .h-10 { height: 2.5rem; } .h-5 { height: 1.25rem; } .\!w-4 { width: 1rem !important; } .w-10 { width: 2.5rem; } .w-11 { width: 2.75rem; } .w-28 { width: 7rem; } .w-5 { width: 1.25rem; } .w-72 { width: 18rem; } .w-full { width: 100%; } .table-fixed { table-layout: fixed; } .grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); } .grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } .flex-col { flex-direction: column; } .flex-wrap { flex-wrap: wrap; } .items-center { align-items: center; } .justify-end { justify-content: flex-end; } .justify-center { justify-content: center; } .justify-between { justify-content: space-between; } .gap-2 { gap: 0.5rem; } .gap-y-4 { row-gap: 1rem; } .space-y-8 > :not([hidden]) ~ :not([hidden]) { --tw-space-y-reverse: 0; margin-top: calc(2rem * calc(1 - var(--tw-space-y-reverse))); margin-bottom: calc(2rem * var(--tw-space-y-reverse)); } .self-center { align-self: center; } .overflow-auto { overflow: auto; } .overflow-hidden { overflow: hidden; } .overflow-x-auto { overflow-x: auto; } .text-ellipsis { text-overflow: ellipsis; } .whitespace-nowrap { white-space: nowrap; } .rounded { border-radius: 0.25rem; } .rounded-lg { border-radius: 0.5rem; } .border { border-width: 1px; } .border-y { border-top-width: 1px; border-bottom-width: 1px; } .border-b { border-bottom-width: 1px; } .border-gray-400 { --tw-border-opacity: 1; border-color: rgb(156 163 175 / var(--tw-border-opacity)); } .border-gray-600 { --tw-border-opacity: 1; border-color: rgb(75 85 99 / var(--tw-border-opacity)); } .border-gray-900 { --tw-border-opacity: 1; border-color: rgb(17 24 39 / var(--tw-border-opacity)); } .bg-blue-900 { --tw-bg-opacity: 1; background-color: rgb(30 58 138 / var(--tw-bg-opacity)); } .bg-emerald-400\/10 { background-color: rgb(52 211 153 / 0.1); } .bg-gray-300\/10 { background-color: rgb(209 213 219 / 0.1); } .bg-gray-400\/10 { background-color: rgb(156 163 175 / 0.1); } .bg-gray-700 { --tw-bg-opacity: 1; background-color: rgb(55 65 81 / var(--tw-bg-opacity)); } .bg-gray-800 { --tw-bg-opacity: 1; background-color: rgb(31 41 55 / var(--tw-bg-opacity)); } .bg-gray-900 { --tw-bg-opacity: 1; background-color: rgb(17 24 39 / var(--tw-bg-opacity)); } .bg-indigo-900 { --tw-bg-opacity: 1; background-color: rgb(49 46 129 / var(--tw-bg-opacity)); } .bg-pink-900 { --tw-bg-opacity: 1; background-color: rgb(131 24 67 / var(--tw-bg-opacity)); } .bg-purple-900 { --tw-bg-opacity: 1; background-color: rgb(88 28 135 / var(--tw-bg-opacity)); } .bg-red-400\/10 { background-color: rgb(248 113 113 / 0.1); } .bg-sky-500 { --tw-bg-opacity: 1; background-color: rgb(14 165 233 / var(--tw-bg-opacity)); } .bg-yellow-900 { --tw-bg-opacity: 1; background-color: rgb(113 63 18 / var(--tw-bg-opacity)); } .p-2 { padding: 0.5rem; } .p-2\.5 { padding: 0.625rem; } .p-4 { padding: 1rem; } .px-2 { padding-left: 0.5rem; padding-right: 0.5rem; } .px-3 { padding-left: 0.75rem; padding-right: 0.75rem; } .px-4 { padding-left: 1rem; padding-right: 1rem; } .py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; } .py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; } .py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; } .py-4 { padding-top: 1rem; padding-bottom: 1rem; } .py-8 { padding-top: 2rem; padding-bottom: 2rem; } .py-\[11\.5px\] { padding-top: 11.5px; padding-bottom: 11.5px; } .pl-2 { padding-left: 0.5rem; } .pr-2 { padding-right: 0.5rem; } .pr-5 { padding-right: 1.25rem; } .text-left { text-align: left; } .text-center { text-align: center; } .text-right { text-align: right; } .align-top { vertical-align: top; } .align-text-top { vertical-align: text-top; } .text-2xl { font-size: 1.5rem; line-height: 2rem; } .text-base { font-size: 1rem; line-height: 1.5rem; } .text-sm { font-size: 0.875rem; line-height: 1.25rem; } .text-xs { font-size: 0.75rem; line-height: 1rem; } .font-extralight { font-weight: 200; } .font-medium { font-weight: 500; } .font-normal { font-weight: 400; } .font-semibold { font-weight: 600; } .uppercase { text-transform: uppercase; } .text-black { --tw-text-opacity: 1; color: rgb(0 0 0 / var(--tw-text-opacity)); } .text-blue-300 { --tw-text-opacity: 1; color: rgb(147 197 253 / var(--tw-text-opacity)); } .text-emerald-300 { --tw-text-opacity: 1; color: rgb(110 231 183 / var(--tw-text-opacity)); } .text-gray-300 { --tw-text-opacity: 1; color: rgb(209 213 219 / var(--tw-text-opacity)); } .text-gray-400 { --tw-text-opacity: 1; color: rgb(156 163 175 / var(--tw-text-opacity)); } .text-indigo-300 { --tw-text-opacity: 1; color: rgb(165 180 252 / var(--tw-text-opacity)); } .text-pink-300 { --tw-text-opacity: 1; color: rgb(249 168 212 / var(--tw-text-opacity)); } .text-purple-300 { --tw-text-opacity: 1; color: rgb(216 180 254 / var(--tw-text-opacity)); } .text-red-300 { --tw-text-opacity: 1; color: rgb(252 165 165 / var(--tw-text-opacity)); } .text-sky-500 { --tw-text-opacity: 1; color: rgb(14 165 233 / var(--tw-text-opacity)); } .text-sky-600 { --tw-text-opacity: 1; color: rgb(2 132 199 / var(--tw-text-opacity)); } .text-white { --tw-text-opacity: 1; color: rgb(255 255 255 / var(--tw-text-opacity)); } .text-yellow-300 { --tw-text-opacity: 1; color: rgb(253 224 71 / var(--tw-text-opacity)); } .placeholder-gray-400::-moz-placeholder { --tw-placeholder-opacity: 1; color: rgb(156 163 175 / var(--tw-placeholder-opacity)); } .placeholder-gray-400::placeholder { --tw-placeholder-opacity: 1; color: rgb(156 163 175 / var(--tw-placeholder-opacity)); } .shadow-md { --tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); --tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color); box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); } .ring-1 { --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); } .ring-inset { --tw-ring-inset: inset; } .ring-emerald-400\/20 { --tw-ring-color: rgb(52 211 153 / 0.2); } .ring-gray-900 { --tw-ring-opacity: 1; --tw-ring-color: rgb(17 24 39 / var(--tw-ring-opacity)); } .ring-red-400\/20 { --tw-ring-color: rgb(248 113 113 / 0.2); } .ring-offset-gray-800 { --tw-ring-offset-color: #1f2937; } .filter { filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); } ::-webkit-scrollbar { height: 6px; width: 6px; --tw-bg-opacity: 1; background-color: rgb(209 213 219 / var(--tw-bg-opacity)); } ::-webkit-scrollbar-thumb { --tw-bg-opacity: 1; background-color: rgb(107 114 128 / var(--tw-bg-opacity)); border-radius: 4px; } .last\:border-b-0:last-child { border-bottom-width: 0px; } .last-of-type\:border-b-0:last-of-type { border-bottom-width: 0px; } .hover\:bg-gray-700:hover { --tw-bg-opacity: 1; background-color: rgb(55 65 81 / var(--tw-bg-opacity)); } .hover\:bg-gray-800:hover { --tw-bg-opacity: 1; background-color: rgb(31 41 55 / var(--tw-bg-opacity)); } .hover\:bg-gray-800\/60:hover { background-color: rgb(31 41 55 / 0.6); } .hover\:bg-sky-700:hover { --tw-bg-opacity: 1; background-color: rgb(3 105 161 / var(--tw-bg-opacity)); } .hover\:text-white:hover { --tw-text-opacity: 1; color: rgb(255 255 255 / var(--tw-text-opacity)); } .hover\:text-white\/80:hover { color: rgb(255 255 255 / 0.8); } .focus\:border-blue-500:focus { --tw-border-opacity: 1; border-color: rgb(59 130 246 / var(--tw-border-opacity)); } .focus\:outline-none:focus { outline: 2px solid transparent; outline-offset: 2px; } .focus\:ring-2:focus { --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); } .focus\:ring-blue-500:focus { --tw-ring-opacity: 1; --tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity)); } .focus\:ring-gray-500:focus { --tw-ring-opacity: 1; --tw-ring-color: rgb(107 114 128 / var(--tw-ring-opacity)); } .focus\:ring-sky-600:focus { --tw-ring-opacity: 1; --tw-ring-color: rgb(2 132 199 / var(--tw-ring-opacity)); } .active\:text-white\/80:active { color: rgb(255 255 255 / 0.8); } .phx-submit-loading\:opacity-75.phx-submit-loading { opacity: 0.75; } .phx-submit-loading .phx-submit-loading\:opacity-75 { opacity: 0.75; } @media (min-width: 640px) { .sm\:rounded-lg { border-radius: 0.5rem; } } @media (min-width: 768px) { .md\:col-span-3 { grid-column: span 3 / span 3; } .md\:mt-0 { margin-top: 0px; } .md\:block { display: block; } .md\:hidden { display: none; } .md\:w-auto { width: auto; } .md\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } .md\:flex-row { flex-direction: row; } .md\:space-x-3 > :not([hidden]) ~ :not([hidden]) { --tw-space-x-reverse: 0; margin-right: calc(0.75rem * var(--tw-space-x-reverse)); margin-left: calc(0.75rem * calc(1 - var(--tw-space-x-reverse))); } .md\:space-x-8 > :not([hidden]) ~ :not([hidden]) { --tw-space-x-reverse: 0; margin-right: calc(2rem * var(--tw-space-x-reverse)); margin-left: calc(2rem * calc(1 - var(--tw-space-x-reverse))); } .md\:border-0 { border-width: 0px; } .md\:border-r { border-right-width: 1px; } .md\:border-gray-600 { --tw-border-opacity: 1; border-color: rgb(75 85 99 / var(--tw-border-opacity)); } .md\:bg-gray-800 { --tw-bg-opacity: 1; background-color: rgb(31 41 55 / var(--tw-bg-opacity)); } .md\:p-0 { padding: 0px; } .md\:pl-0 { padding-left: 0px; } .md\:hover\:bg-transparent:hover { background-color: transparent; } .md\:hover\:text-sky-500:hover { --tw-text-opacity: 1; color: rgb(14 165 233 / var(--tw-text-opacity)); } } .rtl\:space-x-reverse:where([dir="rtl"], [dir="rtl"] *) > :not([hidden]) ~ :not([hidden]) { --tw-space-x-reverse: 1; } .rtl\:text-right:where([dir="rtl"], [dir="rtl"] *) { text-align: right; } ================================================ FILE: priv/static/app.js ================================================ var C=Object.create;var{defineProperty:b,getPrototypeOf:x,getOwnPropertyNames:E}=Object;var F=Object.prototype.hasOwnProperty;var I=(n,s,u)=>{u=n!=null?C(x(n)):{};const t=s||!n||!n.__esModule?b(u,"default",{value:n,enumerable:!0}):u;for(let o of E(n))if(!F.call(t,o))b(t,o,{get:()=>n[o],enumerable:!0});return t};var w=(n,s)=>()=>(s||n((s={exports:{}}).exports,s),s.exports);var y=w((m,g)=>{(function(n,s){function u(){t.width=n.innerWidth,t.height=5*i.barThickness;var e=t.getContext("2d");e.shadowBlur=i.shadowBlur,e.shadowColor=i.shadowColor;var r,a=e.createLinearGradient(0,0,t.width,0);for(r in i.barColors)a.addColorStop(r,i.barColors[r]);e.lineWidth=i.barThickness,e.beginPath(),e.moveTo(0,i.barThickness/2),e.lineTo(Math.ceil(o*t.width),i.barThickness/2),e.strokeStyle=a,e.stroke()}var t,o,c,d=null,p=null,h=null,i={autoRun:!0,barThickness:3,barColors:{0:"rgba(26, 188, 156, .9)",".25":"rgba(52, 152, 219, .9)",".50":"rgba(241, 196, 15, .9)",".75":"rgba(230, 126, 34, .9)","1.0":"rgba(211, 84, 0, .9)"},shadowBlur:10,shadowColor:"rgba(0, 0, 0, .6)",className:null},l={config:function(e){for(var r in e)i.hasOwnProperty(r)&&(i[r]=e[r])},show:function(e){var r,a;c||(e?h=h||setTimeout(()=>l.show(),e):(c=!0,p!==null&&n.cancelAnimationFrame(p),t||((a=(t=s.createElement("canvas")).style).position="fixed",a.top=a.left=a.right=a.margin=a.padding=0,a.zIndex=100001,a.display="none",i.className&&t.classList.add(i.className),r="resize",e=u,(a=n).addEventListener?a.addEventListener(r,e,!1):a.attachEvent?a.attachEvent("on"+r,e):a["on"+r]=e),t.parentElement||s.body.appendChild(t),t.style.opacity=1,t.style.display="block",l.progress(0),i.autoRun&&function v(){d=n.requestAnimationFrame(v),l.progress("+"+0.05*Math.pow(1-Math.sqrt(o),2))}()))},progress:function(e){return e===void 0||(typeof e=="string"&&(e=(0<=e.indexOf("+")||0<=e.indexOf("-")?o:0)+parseFloat(e)),o=1f.default.show(300));window.addEventListener("phx:page-loading-stop",(n)=>f.default.hide());T.connect();window.liveSocket=T; ================================================ FILE: test/error_tracker/filter_test.exs ================================================ defmodule ErrorTracker.FilterTest do use ErrorTracker.Test.Case setup context do if filter = context[:filter] do previous_setting = Application.get_env(:error_tracker, :filter) Application.put_env(:error_tracker, :filter, filter) # Ensure that the application env is restored after each test on_exit(fn -> Application.put_env(:error_tracker, :filter, previous_setting) end) end [] end @sensitive_ctx %{ "request" => %{ "headers" => %{ "accept" => "application/json, text/plain, */*", "authorization" => "Bearer 12341234" } } } test "without an filter, context objects are saved as they are." do assert %ErrorTracker.Occurrence{context: ctx} = report_error(fn -> raise "BOOM" end, @sensitive_ctx) assert ctx == @sensitive_ctx end @tag filter: ErrorTracker.FilterTest.AuthHeaderHider test "user defined filter should be used to sanitize the context before it's saved." do assert %ErrorTracker.Occurrence{context: ctx} = report_error(fn -> raise "BOOM" end, @sensitive_ctx) assert ctx != @sensitive_ctx cleaned_header_value = ctx |> Map.get("request") |> Map.get("headers") |> Map.get("authorization") assert cleaned_header_value == "REMOVED" end end defmodule ErrorTracker.FilterTest.AuthHeaderHider do @moduledoc false @behaviour ErrorTracker.Filter def sanitize(context) do context |> Enum.map(fn {"authorization", _} -> {"authorization", "REMOVED"} o -> o end) |> Map.new(fn {key, val} when is_map(val) -> {key, sanitize(val)} o -> o end) end end ================================================ FILE: test/error_tracker/ignorer_test.exs ================================================ defmodule ErrorTracker.IgnorerTest do use ErrorTracker.Test.Case setup context do if ignorer = context[:ignorer] do previous_setting = Application.get_env(:error_tracker, :ignorer) Application.put_env(:error_tracker, :ignorer, ignorer) # Ensure that the application env is restored after each test on_exit(fn -> Application.put_env(:error_tracker, :ignorer, previous_setting) end) end [] end @tag ignorer: ErrorTracker.EveryErrorIgnorer test "with an ignorer ignores errors" do assert :noop = report_error(fn -> raise "[IGNORE] Sample error" end) assert %ErrorTracker.Occurrence{} = report_error(fn -> raise "Sample error" end) end @tag ignorer: false test "without an ignorer does not ignore errors" do assert %ErrorTracker.Occurrence{} = report_error(fn -> raise "[IGNORE] Sample error" end) assert %ErrorTracker.Occurrence{} = report_error(fn -> raise "Sample error" end) end end defmodule ErrorTracker.EveryErrorIgnorer do @moduledoc false @behaviour ErrorTracker.Ignorer @impl true def ignore?(error, _context) do String.contains?(error.reason, "[IGNORE]") end end ================================================ FILE: test/error_tracker/schemas/occurrence_test.exs ================================================ defmodule ErrorTracker.OccurrenceTest do use ErrorTracker.Test.Case import Ecto.Changeset alias ErrorTracker.Occurrence alias ErrorTracker.Stacktrace describe inspect(&Occurrence.changeset/2) do test "works as expected with valid data" do attrs = %{context: %{foo: :bar}, reason: "Test reason", stacktrace: %Stacktrace{}} changeset = Occurrence.changeset(%Occurrence{}, attrs) assert changeset.valid? end test "validates required fields" do changeset = Occurrence.changeset(%Occurrence{}, %{}) refute changeset.valid? assert {_, [validation: :required]} = changeset.errors[:reason] assert {_, [validation: :required]} = changeset.errors[:stacktrace] end @tag capture_log: true test "if context is not serializable, an error messgae is stored" do attrs = %{ context: %{foo: %ErrorTracker.Error{}}, reason: "Test reason", stacktrace: %Stacktrace{} } changeset = Occurrence.changeset(%Occurrence{}, attrs) assert %{error: err} = get_field(changeset, :context) assert err =~ "not serializable to JSON" end end end ================================================ FILE: test/error_tracker/telemetry_test.exs ================================================ defmodule ErrorTracker.TelemetryTest do use ErrorTracker.Test.Case alias ErrorTracker.Error alias ErrorTracker.Occurrence setup do attach_telemetry() :ok end test "events are emitted for new errors" do {exception, stacktrace} = try do raise "This is a test" rescue e -> {e, __STACKTRACE__} end # Since the error is new, both the new error and new occurrence events will be emitted %Occurrence{error: error = %Error{}} = ErrorTracker.report(exception, stacktrace) assert_receive {:telemetry_event, [:error_tracker, :error, :new], _, %{error: %Error{}}} assert_receive {:telemetry_event, [:error_tracker, :occurrence, :new], _, %{occurrence: %Occurrence{}, muted: false}} # The error is already known so the new error event won't be emitted ErrorTracker.report(exception, stacktrace) refute_receive {:telemetry_event, [:error_tracker, :error, :new], _, %{error: %Error{}}}, 150 assert_receive {:telemetry_event, [:error_tracker, :occurrence, :new], _, %{occurrence: %Occurrence{}, muted: false}} # The error is muted so the new occurrence event will include the muted=true metadata ErrorTracker.mute(error) ErrorTracker.report(exception, stacktrace) assert_receive {:telemetry_event, [:error_tracker, :occurrence, :new], _, %{occurrence: %Occurrence{}, muted: true}} end test "events are emitted for resolved and unresolved errors" do %Occurrence{error: error = %Error{}} = report_error(fn -> raise "This is a test" end) # The resolved event will be emitted {:ok, resolved = %Error{}} = ErrorTracker.resolve(error) assert_receive {:telemetry_event, [:error_tracker, :error, :resolved], _, %{error: %Error{}}} # The unresolved event will be emitted {:ok, _unresolved} = ErrorTracker.unresolve(resolved) assert_receive {:telemetry_event, [:error_tracker, :error, :unresolved], _, %{error: %Error{}}} end end ================================================ FILE: test/error_tracker_test.exs ================================================ defmodule ErrorTrackerTest do use ErrorTracker.Test.Case alias ErrorTracker.Error alias ErrorTracker.Occurrence # We use this file path because for some reason the test scripts are not # handled as part of the application, so the last line of the app executed is # on the case module. @relative_file_path "test/support/case.ex" describe inspect(&ErrorTracker.report/3) do setup context do if Map.has_key?(context, :enabled) do Application.put_env(:error_tracker, :enabled, context[:enabled]) # Ensure that the application env is restored after each test on_exit(fn -> Application.delete_env(:error_tracker, :enabled) end) end [] end test "reports exceptions" do %Occurrence{error: error = %Error{}} = report_error(fn -> raise "This is a test" end) assert error.kind == to_string(RuntimeError) assert error.reason == "This is a test" assert error.source_line =~ @relative_file_path end test "reports badarith errors" do string_var = to_string(1) %Occurrence{error: error = %Error{}, stacktrace: %{lines: [last_line | _]}} = report_error(fn -> 1 + string_var end) assert error.kind == to_string(ArithmeticError) assert error.reason == "bad argument in arithmetic expression" # Elixir 1.17.0 reports these errors differently than previous versions if Version.compare(System.version(), "1.17.0") == :lt do assert last_line.module == "ErrorTrackerTest" assert last_line.function =~ "&ErrorTracker.report/3 reports badarith errors" assert last_line.arity == 1 assert last_line.file assert last_line.line else assert last_line.module == "erlang" assert last_line.function == "+" assert last_line.arity == 2 refute last_line.file refute last_line.line end end test "reports undefined function errors" do # This function does not exist and will raise when called {m, f, a} = {ErrorTracker, :invalid_fun, []} %Occurrence{error: error = %Error{}} = report_error(fn -> apply(m, f, a) end) assert error.kind == to_string(UndefinedFunctionError) assert error.reason =~ "is undefined or private" assert error.source_function == Exception.format_mfa(m, f, Enum.count(a)) assert error.source_line == "(nofile)" end test "reports throws" do %Occurrence{error: error = %Error{}} = report_error(fn -> throw("This is a test") end) assert error.kind == "throw" assert error.reason == "This is a test" assert error.source_line =~ @relative_file_path end test "reports exits" do %Occurrence{error: error = %Error{}} = report_error(fn -> exit("This is a test") end) assert error.kind == "exit" assert error.reason == "This is a test" assert error.source_line =~ @relative_file_path end @tag capture_log: true test "reports errors with invalid context" do # It's invalid because cannot be serialized to JSON invalid_context = %{foo: %Error{}} assert %Occurrence{} = report_error(fn -> raise "test" end, invalid_context) end test "without enabled flag it works as expected" do # Ensure no value is set Application.delete_env(:error_tracker, :enabled) assert %Occurrence{} = report_error(fn -> raise "Sample error" end) end @tag enabled: true test "with enabled flag to true it works as expected" do assert %Occurrence{} = report_error(fn -> raise "Sample error" end) end @tag enabled: false test "with enabled flag to false it does not store the exception" do assert report_error(fn -> raise "Sample error" end) == :noop end test "includes breadcrumbs if present" do breadcrumbs = ["breadcrumb 1", "breadcrumb 2"] occurrence = report_error(fn -> raise ErrorWithBreadcrumbs, message: "test", bread_crumbs: breadcrumbs end) assert occurrence.breadcrumbs == breadcrumbs end test "includes breadcrumbs if stored by the user" do ErrorTracker.add_breadcrumb("breadcrumb 1") ErrorTracker.add_breadcrumb("breadcrumb 2") occurrence = report_error(fn -> raise "Sample error" end) assert occurrence.breadcrumbs == ["breadcrumb 1", "breadcrumb 2"] end test "merges breadcrumbs stored by the user and contained on the exception" do ErrorTracker.add_breadcrumb("breadcrumb 1") ErrorTracker.add_breadcrumb("breadcrumb 2") occurrence = report_error(fn -> raise ErrorWithBreadcrumbs, message: "test", bread_crumbs: ["breadcrumb 3"] end) assert occurrence.breadcrumbs == ["breadcrumb 1", "breadcrumb 2", "breadcrumb 3"] end end describe inspect(&ErrorTracker.resolve/1) do test "marks the error as resolved" do %Occurrence{error: error} = report_error(fn -> raise "This is a test" end) assert {:ok, %Error{status: :resolved}} = ErrorTracker.resolve(error) end end describe inspect(&ErrorTracker.unresolve/1) do test "marks the error as unresolved" do %Occurrence{error: error} = report_error(fn -> raise "This is a test" end) # Manually mark the error as resolved {:ok, resolved} = ErrorTracker.resolve(error) assert {:ok, %Error{status: :unresolved}} = ErrorTracker.unresolve(resolved) end end describe inspect(&ErrorTracker.add_breadcrumb/1) do test "adds an entry to the breadcrumbs list" do ErrorTracker.add_breadcrumb("breadcrumb 1") ErrorTracker.add_breadcrumb("breadcrumb 2") assert ["breadcrumb 1", "breadcrumb 2"] = ErrorTracker.get_breadcrumbs() end end end defmodule ErrorWithBreadcrumbs do @moduledoc false defexception [:message, :bread_crumbs] end ================================================ FILE: test/integrations/plug_test.exs ================================================ defmodule ErrorTracker.Integrations.PlugTest do use ErrorTracker.Test.Case alias ErrorTracker.Integrations.Plug, as: IntegrationPlug @fake_callstack [] setup do [conn: Phoenix.ConnTest.build_conn()] end test "it reports errors, including the request headers", %{conn: conn} do conn = Plug.Conn.put_req_header(conn, "accept", "application/json") IntegrationPlug.report_error( conn, {"an error from Phoenix", "something bad happened"}, @fake_callstack ) [error] = repo().all(ErrorTracker.Error) assert error.kind == "an error from Phoenix" assert error.reason == "something bad happened" [occurrence] = repo().all(ErrorTracker.Occurrence) assert occurrence.error_id == error.id %{"request.headers" => request_headers} = occurrence.context assert request_headers == %{"accept" => "application/json"} end test "it does not save sensitive request headers, to avoid storing them in cleartext", %{ conn: conn } do conn = conn |> Plug.Conn.put_req_header("cookie", "who stole the cookie from the cookie jar ?") |> Plug.Conn.put_req_header("authorization", "Bearer plz-dont-leak-my-secrets") |> Plug.Conn.put_req_header("safe", "this can be safely stored in cleartext") IntegrationPlug.report_error( conn, {"an error from Phoenix", "something bad happened"}, @fake_callstack ) [occurrence] = repo().all(ErrorTracker.Occurrence) assert occurrence.context["request.headers"]["cookie"] == "[REDACTED]" assert occurrence.context["request.headers"]["authorization"] == "[REDACTED]" assert occurrence.context["request.headers"]["safe"] != "[REDACTED]" end end ================================================ FILE: test/support/case.ex ================================================ defmodule ErrorTracker.Test.Case do @moduledoc false use ExUnit.CaseTemplate using do quote do import Ecto.Query import ErrorTracker.Test.Case end end setup do Ecto.Adapters.SQL.Sandbox.checkout(repo()) end @doc """ Reports the error produced by the given function. """ def report_error(fun, context \\ %{}) do occurrence = try do fun.() rescue exception -> ErrorTracker.report(exception, __STACKTRACE__, context) catch kind, reason -> ErrorTracker.report({kind, reason}, __STACKTRACE__, context) end case occurrence do %ErrorTracker.Occurrence{} -> repo().preload(occurrence, :error) other -> other end end @doc """ Sends telemetry events as messages to the current process. This allows test cases to check that telemetry events are fired with: assert_receive {:telemetry_event, event, measurements, metadata} """ def attach_telemetry do :telemetry.attach_many( "telemetry-test", [ [:error_tracker, :error, :new], [:error_tracker, :error, :resolved], [:error_tracker, :error, :unresolved], [:error_tracker, :occurrence, :new] ], &__MODULE__._send_telemetry/4, nil ) end def _send_telemetry(event, measurements, metadata, _opts) do send(self(), {:telemetry_event, event, measurements, metadata}) end def repo do Application.fetch_env!(:error_tracker, :repo) end end ================================================ FILE: test/support/lite_repo.ex ================================================ defmodule ErrorTracker.Test.LiteRepo do @moduledoc false use Ecto.Repo, otp_app: :error_tracker, adapter: Ecto.Adapters.SQLite3 end ================================================ FILE: test/support/mysql_repo.ex ================================================ defmodule ErrorTracker.Test.MySQLRepo do @moduledoc false use Ecto.Repo, otp_app: :error_tracker, adapter: Ecto.Adapters.MyXQL end ================================================ FILE: test/support/repo.ex ================================================ defmodule ErrorTracker.Test.Repo do @moduledoc false use Ecto.Repo, otp_app: :error_tracker, adapter: Ecto.Adapters.Postgres end ================================================ FILE: test/test_helper.exs ================================================ # Use the appropriate repo for the desired database repo = case System.get_env("DB") do "sqlite" -> ErrorTracker.Test.LiteRepo "mysql" -> ErrorTracker.Test.MySQLRepo "postgres" -> ErrorTracker.Test.Repo _other -> raise "Please run either `DB=sqlite mix test`, `DB=postgres mix test` or `DB=mysql mix test`" end Application.put_env(:error_tracker, :repo, repo) # Create the database and start the repo repo.__adapter__().storage_up(repo.config()) repo.start_link() # Run migrations Ecto.Migrator.run(repo, :up, all: true, log_migrations_sql: false, log: false) ExUnit.start() Ecto.Adapters.SQL.Sandbox.mode(repo, :manual)