[
  {
    "path": ".formatter.exs",
    "content": "# Used by \"mix format\"\nlocals_without_parens = [error_tracker_dashboard: 1, error_tracker_dashboard: 2]\n\n# Parse SemVer minor elixir version from project configuration\n# eg `\"~> 1.15\"` version requirement will yield `\"1.15\"`\n[elixir_minor_version | _] = Regex.run(~r/([\\d\\.]+)/, Mix.Project.config()[:elixir])\n\n[\n  import_deps: [:ecto, :ecto_sql, :plug, :phoenix],\n  inputs: [\"{mix,.formatter,dev,dev.*}.exs\", \"{config,lib,test}/**/*.{heex,ex,exs}\"],\n  plugins: [Phoenix.LiveView.HTMLFormatter, Styler],\n  locals_without_parens: locals_without_parens,\n  export: [locals_without_parens: locals_without_parens],\n  styler: [\n    minimum_supported_elixir_version: \"#{elixir_minor_version}.0\"\n  ]\n]\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: bug\nassignees: ''\n\n---\n\n**Describe the bug**\nA clear and concise description of what the bug is. Remember that we don't provide support to third-party libraries such as Tower.\n\n**To Reproduce**\nSteps to reproduce the behavior:\n1. Go to '...'\n2. Click on '....'\n3. Scroll down to '....'\n4. See error\n\n**Expected behavior**\nA clear and concise description of what you expected to happen.\n\n**Screenshots**\nIf applicable, add screenshots to help explain your problem.\n\n**Additional context**\nAdd any other context about the problem here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: enhancement\nassignees: ''\n\n---\n\n**Is your feature request related to a problem? Please describe.**\nA clear and concise description of what the problem is. Ex. I'm always frustrated when [...]\n\n**Describe the solution you'd like**\nA clear and concise description of what you want to happen.\n\n**Describe alternatives you've considered**\nA clear and concise description of any alternative solutions or features you've considered.\n\n**Additional context**\nAdd any other context or screenshots about the feature request here.\n"
  },
  {
    "path": ".github/workflows/elixir.yml",
    "content": "name: CI\non:\n  push:\n    branches:\n      - main\n    paths-ignore:\n      - 'guides/**'\n  pull_request:\n    paths-ignore:\n      - 'guides/**'\nenv:\n  MIX_ENV: test\n\njobs:\n  code_quality_and_tests:\n    runs-on: ubuntu-latest\n    strategy:\n      fail-fast: false\n      matrix:\n        include:\n          - elixir: \"1.15.x\"\n            erlang: \"24.x\"\n          - elixir: \"1.16.x\"\n            erlang: \"24.x\"\n          - elixir: \"1.17.x\"\n            erlang: \"27.x\"\n          - elixir: \"1.18.x\"\n            erlang: \"27.x\"\n          - elixir: \"1.19.x\"\n            erlang: \"28.x\"\n          - elixir: \"latest\"\n            erlang: \"28.x\"\n    services:\n      db:\n        image: postgres:15\n        ports: [\"5432:5432\"]\n        env:\n          POSTGRES_PASSWORD: postgres\n        options: >-\n          --health-cmd pg_isready\n          --health-interval 10s\n          --health-timeout 5s\n          --health-retries 5\n      mariadb:\n        image: mariadb:11\n        ports: [\"3306:3306\"]\n        env:\n          MARIADB_ROOT_PASSWORD: root\n        options: >-\n          --health-cmd \"healthcheck.sh --connect --innodb_initialized\"\n          --health-interval 10s\n          --health-timeout 5s\n          --health-retries 5\n    name: Elixir v${{ matrix.elixir }}, Erlang v${{ matrix.erlang }}\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Generate test configuration\n        run: cp config/test.example.exs config/test.exs\n\n      - uses: erlef/setup-beam@v1\n        with:\n          otp-version: ${{ matrix.erlang }}\n          elixir-version: ${{ matrix.elixir }}\n\n      - name: Retrieve Dependencies Cache\n        uses: actions/cache@v4\n        id: mix-cache\n        with:\n          path: |\n            deps\n            _build\n          key: ${{ runner.os }}-${{ matrix.erlang }}-${{ matrix.elixir }}-mix-${{ hashFiles('**/mix.lock') }}\n\n      - name: Install Mix Dependencies\n        run: mix deps.get\n\n      - name: Check unused dependencies\n        run: mix deps.unlock --check-unused\n\n      - name: Compile dependencies\n        run: mix deps.compile\n\n      - name: Check format\n        run: mix format --check-formatted\n\n      - name: Check application compile warnings\n        run: mix compile --force --warnings-as-errors\n\n      - name: Run Tests - SQLite3\n        run: mix test --warnings-as-errors\n        env:\n          DB: sqlite\n\n      - name: Run Tests - PostgreSQL\n        run: mix test --warnings-as-errors\n        env:\n          DB: postgres\n\n      - name: Run Tests - MySQL/MariaDB\n        run: mix test --warnings-as-errors\n        env:\n          DB: mysql\n"
  },
  {
    "path": ".gitignore",
    "content": "# The directory Mix will write compiled artifacts to.\n/_build/\n\n# If you run \"mix test --cover\", coverage assets end up here.\n/cover/\n\n# The directory Mix downloads your dependencies sources to.\n/deps/\n\n# Where third-party dependencies like ExDoc output generated docs.\n/doc/\n\n# Ignore .fetch files in case you like to edit your project deps locally.\n/.fetch\n\n# If the VM crashes, it generates a dump, let's ignore it too.\nerl_crash.dump\n\n# Also ignore archive artifacts (built via \"mix archive.build\").\n*.ez\n\n# Ignore package tarball (built via \"mix hex.build\").\nerror_tracker-*.tar\n\n# Temporary files, for example, from tests.\n/tmp/\n\n# Configuration files (only the examples are committed)\n/config/dev.exs\n/config/test.exs\n\n# Assets\n/assets/node_modules\n\n# SQLite3 databases\n*.db\n*.db-shm\n*.db-wal\n"
  },
  {
    "path": ".tool-versions",
    "content": "elixir 1.19\nerlang 28.1\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Changelog\n\nPlease see [our GitHub \"Releases\" page](https://github.com/elixir-error-tracker/error-tracker/releases).\n"
  },
  {
    "path": "LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [2024] [elixir-error-tracker]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "README.md",
    "content": "# 🐛 ErrorTracker\n\n<a title=\"GitHub CI\" href=\"https://github.com/elixir-error-tracker/error-tracker/actions\"><img src=\"https://github.com/elixir-error-tracker/error-tracker/workflows/CI/badge.svg\" alt=\"GitHub CI\" /></a>\n<a title=\"Latest release\" href=\"https://hex.pm/packages/error_tracker\"><img src=\"https://img.shields.io/hexpm/v/error_tracker.svg\" alt=\"Latest release\" /></a>\n<a title=\"View documentation\" href=\"https://hexdocs.pm/error_tracker\"><img src=\"https://img.shields.io/badge/hex.pm-docs-blue.svg\" alt=\"View documentation\" /></a>\n\n**An Elixir based built-in error tracking solution.**\n\nErrorTracker 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.\n\n**Does it send notifications or integrate with issue trackers?**\n\nErrorTrackers'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.\n\n**Why another error tracker?**\n\nWhile 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.\\\nYou can see a more detailed explanation [here](https://crbelaus.com/2024/07/31/built-in-elixir-error-reporting-tracking).\n\n<a href=\"guides/screenshots/error-dashboard.png\">\n  <img src=\"guides/screenshots/error-dashboard.png\" alt=\"ErrorTracker web dashboard\" width=\"400\">\n</a>\n<a href=\"guides/screenshots/error-detail.png\">\n  <img src=\"guides/screenshots/error-detail.png\" alt=\"ErrorTracker error detail\" width=\"400\">\n</a>\n\n## Configuration\n\nTake a look at the [Getting Started](/guides/Getting%20Started.md) guide.\n\n## Development\n\n### Initial setup and dependencies\n\nIf 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:\n\n```\ncp config/dev.example.exs config/dev.exs\ncp config/test.example.exs config/test.exs\n```\n\nThen, you will need to download the dependencies:\n\n```\nmix deps.get\n```\n\n### Assets\n\nIn 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.\n\nTo do so, you need to first make a clean build:\n\n```\nmix do assets.install, assets.build\n```\n\nThat task will build the JS and CSS of the project.\n\nThe JS is not expected to change too much because we rely in LiveView, but if\nyou make any change just execute that command again and you are good to go.\n\nIn the case of CSS, as it is automatically generated by Tailwind, you need to\nstart the watcher when your intention is to modify the classes used.\n\nTo do so you can execute this task in a separate terminal:\n\n```\nmix assets.watch\n```\n\n\n\n### Development server\n\nWe have a `dev.exs` script based on [Phoenix Playground](https://github.com/phoenix-playground/phoenix_playground) that starts a development server.\n\n```\niex dev.exs\n```\n"
  },
  {
    "path": "assets/css/app.css",
    "content": "@import \"tailwindcss/base\";\n@import \"tailwindcss/components\";\n@import \"tailwindcss/utilities\";\n\n::-webkit-scrollbar {\n  height: 6px;\n  width: 6px;\n  @apply bg-gray-300;\n}\n\n::-webkit-scrollbar-thumb {\n  @apply bg-gray-500;\n  border-radius: 4px;\n}\n"
  },
  {
    "path": "assets/js/app.js",
    "content": "// Phoenix assets are imported from dependencies.\nimport topbar from \"topbar\";\n\nlet csrfToken = document.querySelector(\"meta[name='csrf-token']\").getAttribute(\"content\");\nlet livePath = document.querySelector(\"meta[name='live-path']\").getAttribute(\"content\");\nlet liveTransport = document .querySelector(\"meta[name='live-transport']\") .getAttribute(\"content\");\n\nconst Hooks = {\n  JsonPrettyPrint: {\n    mounted() {\n      this.formatJson();\n    },\n    updated() {\n      this.formatJson();\n    },\n    formatJson() {\n      try {\n        // Get the raw JSON content\n        const rawJson = this.el.textContent.trim();\n        // Parse and stringify with indentation\n        const formattedJson = JSON.stringify(JSON.parse(rawJson), null, 2);\n        // Update the element content\n        this.el.textContent = formattedJson;\n      } catch (error) {\n        console.error(\"Error formatting JSON:\", error);\n        // Keep the original content if there's an error\n      }\n    }\n  }\n};\n\nlet liveSocket = new LiveView.LiveSocket(livePath, Phoenix.Socket, {\n  transport: liveTransport === \"longpoll\" ? Phoenix.LongPoll : WebSocket,\n  params: { _csrf_token: csrfToken },\n  hooks: Hooks\n\n});\n\n// Show progress bar on live navigation and form submits\ntopbar.config({ barColors: { 0: \"#29d\" }, shadowColor: \"rgba(0, 0, 0, .3)\" });\nwindow.addEventListener(\"phx:page-loading-start\", (_info) => topbar.show(300));\nwindow.addEventListener(\"phx:page-loading-stop\", (_info) => topbar.hide());\n\n// connect if there are any LiveViews on the page\nliveSocket.connect();\nwindow.liveSocket = liveSocket;\n"
  },
  {
    "path": "assets/package.json",
    "content": "{\n  \"workspaces\": [\n    \"../deps/*\"\n  ],\n  \"dependencies\": {\n    \"phoenix\": \"workspace:*\",\n    \"phoenix_live_view\": \"workspace:*\",\n    \"topbar\": \"^3.0.0\"\n  }\n}\n"
  },
  {
    "path": "assets/tailwind.config.js",
    "content": "// See the Tailwind configuration guide for advanced usage\n// https://tailwindcss.com/docs/configuration\n\nlet plugin = require('tailwindcss/plugin')\n\nmodule.exports = {\n  content: [\n    './js/**/*.js',\n    '../lib/error_tracker/web.ex',\n    '../lib/error_tracker/web/**/*.*ex'\n  ],\n  theme: {\n    extend: {},\n  },\n  plugins: [\n    require('@tailwindcss/forms'),\n    plugin(({addVariant}) => addVariant('phx-no-feedback', ['&.phx-no-feedback', '.phx-no-feedback &'])),\n    plugin(({addVariant}) => addVariant('phx-click-loading', ['&.phx-click-loading', '.phx-click-loading &'])),\n    plugin(({addVariant}) => addVariant('phx-submit-loading', ['&.phx-submit-loading', '.phx-submit-loading &'])),\n    plugin(({addVariant}) => addVariant('phx-change-loading', ['&.phx-change-loading', '.phx-change-loading &']))\n  ]\n}\n"
  },
  {
    "path": "config/config.exs",
    "content": "import Config\n\nimport_config \"#{config_env()}.exs\"\n"
  },
  {
    "path": "config/dev.example.exs",
    "content": "import Config\n\nconfig :bun,\n  version: \"1.1.18\",\n  default: [\n    args: ~w(build app.js --outdir=../../priv/static),\n    cd: Path.expand(\"../assets/js\", __DIR__),\n    env: %{}\n  ]\n\n# MySQL/MariaDB adapter\n#\n# To use MySQL/MariaDB on your local development machine uncomment these lines and\n# comment the lines of other adapters.\n#\n# config :error_tracker, :ecto_adapter, :mysql\n#\n# config :error_tracker, ErrorTrackerDev.Repo,\n#   url: \"ecto://root:root@127.0.0.1/error_tracker_dev\"\n#\n# SQLite3 adapter\n#\n# To use SQLite3 on your local development machine uncomment these lines and\n# comment the lines of other adapters.\n#\n# config :error_tracker, :ecto_adapter, :sqlite3\n#\n# config :error_tracker, ErrorTrackerDev.Repo,\n#   database: System.get_env(\"SQLITE_DB\") || \"dev.db\"\n#\n# PostgreSQL adapter\n#\n# To use PostgreSQL on your local development machine uncomment these lines and\n# comment the lines of other adapters.\nconfig :error_tracker, ErrorTrackerDev.Repo, url: \"ecto://postgres:postgres@127.0.0.1/error_tracker_dev\"\nconfig :error_tracker, :ecto_adapter, :postgres\n\nconfig :tailwind,\n  version: \"3.4.3\",\n  default: [\n    args: ~w(\n      --config=tailwind.config.js\n      --input=css/app.css\n      --output=../priv/static/app.css\n      ),\n    cd: Path.expand(\"../assets\", __DIR__)\n  ]\n"
  },
  {
    "path": "config/test.example.exs",
    "content": "import Config\n\nalias Ecto.Adapters.SQL.Sandbox\nalias ErrorTracker.Test.Repo\n\nconfig :error_tracker, ErrorTracker.Test.LiteRepo,\n  database: \"priv/lite_repo/test.db\",\n  pool: Sandbox,\n  log: false,\n  # Use the same migrations as the PostgreSQL repo\n  priv: \"priv/repo\"\n\nconfig :error_tracker, ErrorTracker.Test.MySQLRepo,\n  url: \"ecto://root:root@127.0.0.1/error_tracker_test\",\n  pool: Sandbox,\n  log: false,\n  # Use the same migrations as the PostgreSQL repo\n  priv: \"priv/repo\"\n\nconfig :error_tracker, Repo,\n  url: \"ecto://postgres:postgres@127.0.0.1/error_tracker_test\",\n  pool: Sandbox,\n  log: false\n\nconfig :error_tracker, ecto_repos: [Repo]\n\n# Repo is selected in the test_helper.exs based on the given ENV vars\nconfig :error_tracker, otp_app: :error_tracker\n"
  },
  {
    "path": "dev.exs",
    "content": "# This is the development server for Errortracker built on the PhoenixLiveDashboard project.\n# To start the development server run:\n#     $ iex dev.exs\n#\nMix.install([\n  {:ecto_sqlite3, \">= 0.0.0\"},\n  {:error_tracker, path: \".\", force: true},\n  {:phoenix_playground, \"~> 0.1.8\"}\n])\n\notp_app = :error_tracker_dev\n\nApplication.put_all_env(\n  error_tracker_dev: [\n    {ErrorTrackerDev.Repo, [database: \"priv/repo/dev.db\"]}\n  ],\n  error_tracker: [\n    {:application, otp_app},\n    {:otp_app, otp_app},\n    {:repo, ErrorTrackerDev.Repo}\n  ]\n)\n\ndefmodule ErrorTrackerDev.Repo do\n  use Ecto.Repo, otp_app: otp_app, adapter: Ecto.Adapters.SQLite3\n\n  require Logger\n\n  defmodule Migration do\n    @moduledoc false\n    use Ecto.Migration\n\n    def up, do: ErrorTracker.Migration.up()\n    def down, do: ErrorTracker.Migration.down()\n  end\n\n  def migrate do\n    Ecto.Migrator.run(__MODULE__, [{0, __MODULE__.Migration}], :up, all: true)\n  end\nend\n\ndefmodule ErrorTrackerDev.Controller do\n  use Phoenix.Controller, formats: [:html]\n  use Phoenix.Component\n\n  plug :put_layout, false\n  plug :put_view, __MODULE__\n\n  def index(conn, _params) do\n    render(conn)\n  end\n\n  def index(assigns) do\n    ~H\"\"\"\n    <h2>ErrorTracker Dev server</h2>\n\n    <ul>\n      <li></li>\n    </ul>\n    \"\"\"\n  end\n\n  def noroute(conn, _params) do\n    ErrorTracker.add_breadcrumb(\"ErrorTrackerDev.Controller.noroute/2\")\n\n    raise Phoenix.Router.NoRouteError, conn: conn, router: ErrorTrackerDev.Router\n  end\n\n  def exception(_conn, _params) do\n    ErrorTracker.add_breadcrumb(\"ErrorTrackerDev.Controller.exception/2\")\n\n    raise ErrorTrackerDev.Exception,\n      message: \"This is a controller exception\",\n      bread_crumbs: [\"First\", \"Second\"]\n  end\n\n  def exit(_conn, _params) do\n    ErrorTracker.add_breadcrumb(\"ErrorTrackerDev.Controller.exit/2\")\n\n    exit(:timeout)\n  end\nend\n\ndefmodule ErrorTrackerDev.Live do\n  @moduledoc false\n  use Phoenix.LiveView\n\n  def mount(params, _session, socket) do\n    if params[\"crash_on_mount\"] do\n      raise(\"Crashed on mount/3\")\n    end\n\n    {:ok, socket}\n  end\n\n  def handle_params(params, _uri, socket) do\n    if params[\"crash_on_handle_params\"] do\n      raise \"Crashed on handle_params/3\"\n    end\n\n    {:noreply, socket}\n  end\n\n  def handle_event(\"crash_on_handle_event\", _params, _socket) do\n    raise \"Crashed on handle_event/3\"\n  end\n\n  def handle_event(\"crash_on_render\", _params, socket) do\n    {:noreply, assign(socket, crash_on_render: true)}\n  end\n\n  def handle_event(\"genserver-timeout\", _params, socket) do\n    GenServer.call(ErrorTrackerDev.GenServer, :timeout, 2000)\n    {:noreply, socket}\n  end\n\n  def render(assigns) do\n    if Map.has_key?(assigns, :crash_on_render) do\n      raise \"Crashed on render/1\"\n    end\n\n    ~H\"\"\"\n    <h1>ErrorTracker Dev server</h1>\n\n    <.link href=\"/dev/errors\" target=\"_blank\">Open the ErrorTracker dashboard</.link>\n\n    <p>\n      Errors are stored in the <code>priv/repo/dev.db</code>\n      database, which is automatically created by this script.<br />\n      If you want to clear the state stop the script, run the following command and start it again. <pre>rm priv/repo/dev.db priv/repo/dev.db-shm priv/repo/dev.db-wal</pre>\n    </p>\n\n    <h2>LiveView examples</h2>\n\n    <ul>\n      <li>\n        <.link href=\"/?crash_on_mount\">Crash on mount/3</.link>\n      </li>\n      <li>\n        <.link patch=\"/?crash_on_handle_params\">Crash on handle_params/3</.link>\n      </li>\n      <li>\n        <.link phx-click=\"crash_on_render\">Crash on render/1</.link>\n      </li>\n      <li>\n        <.link phx-click=\"crash_on_handle_event\">Crash on handle_event/3</.link>\n      </li>\n      <li>\n        <.link phx-click=\"genserver-timeout\">Crash with a GenServer timeout</.link>\n      </li>\n    </ul>\n\n    <h2>Controller examples</h2>\n\n    <ul>\n      <li>\n        <.link href=\"/noroute\">Generate a 404 error from the controller</.link>\n      </li>\n      <li>\n        <.link href=\"/exception\">Generate an exception from the controller</.link>\n      </li>\n      <li>\n        <.link href=\"/plug_exception\">Generate an exception from the router</.link>\n      </li>\n      <li>\n        <.link href=\"/exit\">Generate an exit from the controller</.link>\n      </li>\n    </ul>\n    \"\"\"\n  end\nend\n\ndefmodule ErrorTrackerDev.Router do\n  use Phoenix.Router\n  use ErrorTracker.Web, :router\n\n  import Phoenix.LiveView.Router\n\n  pipeline :browser do\n    plug :accepts, [:html]\n    plug :put_root_layout, html: {PhoenixPlayground.Layout, :root}\n    plug :put_secure_browser_headers\n  end\n\n  scope \"/\" do\n    pipe_through :browser\n\n    live \"/\", ErrorTrackerDev.Live\n    get \"/noroute\", ErrorTrackerDev.Controller, :noroute\n    get \"/exception\", ErrorTrackerDev.Controller, :exception\n    get \"/exit\", ErrorTrackerDev.Controller, :exit\n\n    scope \"/dev\" do\n      error_tracker_dashboard \"/errors\", csp_nonce_assign_key: :custom_csp_nonce\n    end\n  end\nend\n\ndefmodule ErrorTrackerDev.Endpoint do\n  use Phoenix.Endpoint, otp_app: :phoenix_playground\n  use ErrorTracker.Integrations.Plug\n\n  # Default PhoenixPlayground.Endpoint\n  plug Plug.Logger\n  socket \"/live\", Phoenix.LiveView.Socket\n  plug Plug.Static, from: {:phoenix, \"priv/static\"}, at: \"/assets/phoenix\"\n  plug Plug.Static, from: {:phoenix_live_view, \"priv/static\"}, at: \"/assets/phoenix_live_view\"\n  socket \"/phoenix/live_reload/socket\", Phoenix.LiveReloader.Socket\n  plug Phoenix.LiveReloader\n  plug Phoenix.CodeReloader, reloader: &PhoenixPlayground.CodeReloader.reload/2\n\n  # Use a custom Content Security Policy\n  plug :set_csp\n  # Raise an exception in the /plug_exception path\n  plug :plug_exception\n  # Our custom router which allows us to have regular controllers and live views\n  plug ErrorTrackerDev.Router\n\n  defp set_csp(conn, _opts) do\n    nonce = 10 |> :crypto.strong_rand_bytes() |> Base.encode64()\n\n    policies = [\n      \"script-src 'self' 'nonce-#{nonce}';\",\n      \"style-src 'self' 'nonce-#{nonce}';\"\n    ]\n\n    conn\n    |> Plug.Conn.assign(:custom_csp_nonce, \"#{nonce}\")\n    |> Plug.Conn.put_resp_header(\"content-security-policy\", Enum.join(policies, \" \"))\n  end\n\n  defp plug_exception(%Plug.Conn{path_info: path_info} = conn, _opts) when is_list(path_info) do\n    if \"plug_exception\" in path_info,\n      do: raise(\"Crashed in Endpoint\"),\n      else: conn\n  end\nend\n\ndefmodule ErrorTrackerDev.ErrorView do\n  def render(\"404.html\", _assigns) do\n    \"This is a 404\"\n  end\n\n  def render(\"500.html\", _assigns) do\n    \"This is a 500\"\n  end\nend\n\ndefmodule ErrorTrackerDev.GenServer do\n  @moduledoc false\n  use GenServer\n\n  # Client\n\n  def start_link(_) do\n    GenServer.start_link(__MODULE__, %{})\n  end\n\n  # Server (callbacks)\n\n  @impl true\n  def init(initial_state) do\n    {:ok, initial_state}\n  end\n\n  @impl true\n  def handle_call(:timeout, _from, state) do\n    :timer.sleep(5000)\n    {:reply, state, state}\n  end\nend\n\ndefmodule ErrorTrackerDev.Exception do\n  @moduledoc false\n  defexception [:message, :bread_crumbs]\nend\n\ndefmodule ErrorTrackerDev.Telemetry do\n  @moduledoc false\n  def handle_event(event, measure, metadata, _opts) do\n    dbg([event, measure, metadata])\n  end\nend\n\nPhoenixPlayground.start(\n  endpoint: ErrorTrackerDev.Endpoint,\n  child_specs: [\n    {ErrorTrackerDev.Repo, []},\n    {ErrorTrackerDev.GenServer, [name: ErrorTrackerDev.GenServer]}\n  ],\n  open_browser: false,\n  debug_errors: false\n)\n\nErrorTrackerDev.Repo.migrate()\n\n:telemetry.attach_many(\n  \"error-tracker-events\",\n  [\n    [:error_tracker, :error, :new],\n    [:error_tracker, :error, :resolved],\n    [:error_tracker, :error, :unresolved],\n    [:error_tracker, :occurrence, :new]\n  ],\n  &ErrorTrackerDev.Telemetry.handle_event/4,\n  []\n)\n"
  },
  {
    "path": "guides/Getting Started.md",
    "content": "# Getting Started\n\nThis 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.\n\nIn 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.\n\n**This guide requires you to have set up Ecto with PostgreSQL, MySQL/MariaDB or SQLite3 beforehand.**\n\n## Automatic installation using Igniter\n\nThe 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.\n\n### If Igniter is already available\n\nErrorTracker may be installed and configured with a single command:\n\n```bash\nmix igniter.install error_tracker\n```\n\n### If Igniter is not yet available\n\nIf the `igniter.install` escript is not available. First, add `error_tracker` and `igniter` to your deps in `mix.exs`:\n\n```elixir\n{:error_tracker, \"~> 0.8\"},\n{:igniter, \"~> 0.5\", only: [:dev]},\n```\n\nRun `mix deps.get` to fetch the dependencies, then run the install task:\n\n```bash\nmix error_tracker.install\n```\n\n\n## Manual Installation\n\nThe first step to add ErrorTracker to your application is to declare the package as a dependency in your `mix.exs` file:\n\n```elixir\n# mix.exs\ndefp deps do\n  [\n    {:error_tracker, \"~> 0.8\"}\n  ]\nend\n```\n\nOnce ErrorTracker is declared as a dependency of your application, you can install it with the following command:\n\n```bash\nmix deps.get\n```\n\n### Configuring ErrorTracker\n\nErrorTracker needs a few configuration options to work. This configuration should be added to your `config/config.exs` file:\n\n```elixir\nconfig :error_tracker,\n  repo: MyApp.Repo,\n  otp_app: :my_app,\n  enabled: true\n```\n\nThe `: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.\n\nThe `: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.\n\nThe `: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.\n\n### Setting up the database\n\nSince ErrorTracker stores errors in the database you must create a database migration to add the required tables:\n\n```\nmix ecto.gen.migration add_error_tracker\n```\n\nOpen the generated migration and call the `up` and `down` functions on `ErrorTracker.Migration`:\n\n```elixir\ndefmodule MyApp.Repo.Migrations.AddErrorTracker do\n  use Ecto.Migration\n\n  def up, do: ErrorTracker.Migration.up(version: 5)\n\n  # We specify `version: 1` in `down`, to ensure we remove all migrations.\n  def down, do: ErrorTracker.Migration.down(version: 1)\nend\n```\n\nYou can run the migration and apply the database changes with the following command:\n\n```bash\nmix ecto.migrate\n```\n\nFor more information about how to handle migrations, take a look at the `ErrorTracker.Migration` module docs.\n\n## Automatic error tracking\n\nAt 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.\n\nIf your application uses Plug but not Phoenix, you will need to add the relevant integration in your `Plug.Builder` or `Plug.Router` module.\n\n```elixir\ndefmodule MyApp.Router do\n  use Plug.Router\n  use ErrorTracker.Integrations.Plug\n\n  # Your code here\nend\n```\n\nThis 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.\n\n```elixir\ndefmodule MyApp.Endpoint do\n  use Phoenix.Endpoint\n  use ErrorTracker.Integrations.Plug\n\n  # Your code here\nend\n```\n\nYou can learn more about this in the `ErrorTracker.Integrations.Plug` module documentation.\n\n## Error context\n\nThe 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.\n\nIn 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.\n\nThe `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.\n\nThere 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\n\n```elixir\nErrorTracker.set_context(%{user_id: conn.assigns.current_user.id})\n```\n\nYou 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.\n\n## Manual error tracking\n\nIf 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:\n\n```elixir\ntry do\n  # your code\ncatch\n  e ->\n    ErrorTracker.report(e, __STACKTRACE__)\nend\n```\n\nYou can also use `ErrorTracker.report/3` and set some custom context that will be included along with the reported error.\n\n## Web UI\n\nErrorTracker also provides a dashboard built with Phoenix LiveView that can be used to see and manage the recorded errors.\n\nThis is completely optional, and you can find more information about it in the `ErrorTracker.Web` module documentation.\n\n## Notifications\n\nCurrently ErrorTracker does not support notifications out of the box.\n\nHowever, it provides some detailed Telemetry events that you may use to implement your own notifications following your custom rules and notification channels.\n\nIf you want to take a look at the events you can attach to, take a look at `ErrorTracker.Telemetry` module documentation.\n\n## Pruning resolved errors\n\nBy default errors are kept in the database indefinitely. This is not ideal for production\nenvironments where you may want to prune old errors that have been resolved.\n\nThe `ErrorTracker.Plugins.Pruner` module provides automatic pruning functionality with a configurable\ninterval and error age.\n\n## Ignoring and Muting Errors\n\nErrorTracker provides two different ways to silence errors:\n\n### Ignoring Errors\n\nErrorTracker tracks every error by default. In certain cases some errors may be expected or just not interesting to track.\nThe `ErrorTracker.Ignorer` behaviour allows you to ignore errors based on their attributes and context.\n\nWhen 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.\n\nFor example, if you had an integration with an unreliable third-party system that was frequently timing out, you could ignore those errors like so:\n\n```elixir\ndefmodule MyApp.ErrorIgnores do\n  @behaviour ErrorTracker.Ignorer\n\n  @impl ErrorTracker.Ignorer\n  def ignore?(%{kind: \"Elixir.UnreliableThirdParty.Error\", reason: \":timeout\"} = _error, _context) do\n    true\n  end\nend\n```\n\n### Muting Errors\n\nSometimes you may want to keep tracking error occurrences but avoid receiving notifications about them. For these cases,\nErrorTracker allows you to mute specific errors.\n\nWhen an error is muted:\n- New occurrences are still tracked and stored in the database\n- You can still see the error and its occurrences in the web UI\n- [Telemetry events](ErrorTracker.Telemetry.html) for new occurrences include the `muted: true` flag so you can ignore them as needed.\n\nThis is particularly useful for noisy errors that you want to keep tracking but don't want to receive notifications about.\n\nYou can mute and unmute errors manually through the web UI or programmatically using the `ErrorTracker.mute/1` and `ErrorTracker.unmute/1` functions.\n"
  },
  {
    "path": "lib/error_tracker/application.ex",
    "content": "defmodule ErrorTracker.Application do\n  @moduledoc false\n\n  use Application\n\n  def start(_type, _args) do\n    children = Application.get_env(:error_tracker, :plugins, [])\n\n    attach_handlers()\n\n    Supervisor.start_link(children, strategy: :one_for_one, name: __MODULE__)\n  end\n\n  defp attach_handlers do\n    ErrorTracker.Integrations.Oban.attach()\n    ErrorTracker.Integrations.Phoenix.attach()\n  end\nend\n"
  },
  {
    "path": "lib/error_tracker/filter.ex",
    "content": "defmodule ErrorTracker.Filter do\n  @moduledoc \"\"\"\n  Behaviour for sanitizing & modifying the error context before it's saved.\n\n      defmodule MyApp.ErrorFilter do\n        @behaviour ErrorTracker.Filter\n\n        @impl true\n        def sanitize(context) do\n          context # Modify the context object (add or remove fields as much as you need.)\n        end\n      end\n\n  Once implemented, include it in the ErrorTracker configuration:\n\n    config :error_tracker, filter: MyApp.Filter\n\n  With this configuration in place, the ErrorTracker will call `MyApp.Filter.sanitize/1` to get a context before\n  saving error occurrence.\n\n  > #### A note on performance {: .warning}\n  >\n  > Keep in mind that the `sanitize/1` will be called in the context of the ErrorTracker itself.\n  > Slow code will have a significant impact in the ErrorTracker performance. Buggy code can bring\n  > the ErrorTracker process down.\n  \"\"\"\n\n  @doc \"\"\"\n  This function will be given an error context to inspect/modify before it's saved.\n  \"\"\"\n  @callback sanitize(context :: map()) :: map()\nend\n"
  },
  {
    "path": "lib/error_tracker/ignorer.ex",
    "content": "defmodule ErrorTracker.Ignorer do\n  @moduledoc \"\"\"\n  Behaviour for ignoring errors.\n\n  > #### Ignoring vs muting errors {: .info}\n  >\n  > Ignoring an error keeps it from being tracked by the ErrorTracker. While this may be useful in\n  > certain cases, in other cases you may prefer to track the error but don't send telemetry events.\n  > Take a look at the `ErrorTracker.mute/1` function to see how to mute errors.\n\n  The ErrorTracker tracks every error that happens in your application. In certain cases you may\n  want to ignore some errors and don't track them. To do so you can implement this behaviour.\n\n      defmodule MyApp.ErrorIgnorer do\n        @behaviour ErrorTracker.Ignorer\n\n        @impl true\n        def ignore?(error = %ErrorTracker.Error{}, context) do\n          # return true if the error should be ignored\n        end\n      end\n\n  Once implemented, include it in the ErrorTracker configuration:\n\n      config :error_tracker, ignorer: MyApp.ErrorIgnorer\n\n  With this configuration in place, the ErrorTracker will call `MyApp.ErrorIgnorer.ignore?/2` before\n  tracking errors. If the function returns `true` the error will be ignored and won't be tracked.\n\n  > #### A note on performance {: .warning}\n  >\n  > Keep in mind that the `ignore?/2` will be called in the context of the ErrorTracker itself.\n  > Slow code will have a significant impact in the ErrorTracker performance. Buggy code can bring\n  > the ErrorTracker process down.\n  \"\"\"\n\n  @doc \"\"\"\n  Decide wether the given error should be ignored or not.\n\n  This function receives both the current Error and context and should return a boolean indicating\n  if it should be ignored or not. If the function returns true the error will be ignored, otherwise\n  it will be tracked.\n  \"\"\"\n  @callback ignore?(error :: ErrorTracker.Error.t(), context :: map()) :: boolean\nend\n"
  },
  {
    "path": "lib/error_tracker/integrations/oban.ex",
    "content": "defmodule ErrorTracker.Integrations.Oban do\n  @moduledoc \"\"\"\n  Integration with Oban.\n\n  ## How to use it\n\n  It is a plug and play integration: as long as you have Oban installed the\n  ErrorTracker will receive and store the errors as they are reported.\n\n  ### How it works\n\n  It works using Oban's Telemetry events, so you don't need to modify anything\n  on your application.\n\n  > #### A note on errors grouping {: .warning}\n  >\n  > All errors reported using `:error` or `{:error, any()}` as the output of\n  > your `perform/2` worker function are going to be grouped together (one group\n  > of those of errors per worker).\n  >\n  > The reason of that behaviour is that those errors do not generate an exception,\n  > so no stack trace is detected and they are stored as happening in the same\n  > place.\n  >\n  > If you want errors of your workers to be grouped as you may expect on other\n  > integrations, you should raise exceptions to report errors instead of gracefully\n  > returning an error value.\n\n  ### Default context\n\n  By default we store some context for you on errors generated in an Oban\n  process:\n\n  * `job.id`: the unique ID of the job.\n\n  * `job.worker`: the name of the worker module.\n\n  * `job.queue`: the name of the queue in which the job was inserted.\n\n  * `job.args`: the arguments of the job being executed.\n\n  * `job.priority`: the priority of the job.\n\n  * `job.attempt`: the number of attempts performed for the job.\n  \"\"\"\n\n  # https://hexdocs.pm/oban/Oban.Telemetry.html\n  @events [\n    [:oban, :job, :start],\n    [:oban, :job, :exception]\n  ]\n\n  @doc false\n  def attach do\n    if Application.spec(:oban) do\n      :telemetry.attach_many(__MODULE__, @events, &__MODULE__.handle_event/4, :no_config)\n    end\n  end\n\n  @doc false\n  def handle_event([:oban, :job, :start], _measurements, metadata, :no_config) do\n    %{job: job} = metadata\n\n    ErrorTracker.set_context(%{\n      \"job.args\" => job.args,\n      \"job.attempt\" => job.attempt,\n      \"job.id\" => job.id,\n      \"job.priority\" => job.priority,\n      \"job.queue\" => job.queue,\n      \"job.worker\" => job.worker\n    })\n  end\n\n  def handle_event([:oban, :job, :exception], _measurements, metadata, :no_config) do\n    %{reason: exception, stacktrace: stacktrace, job: job} = metadata\n    state = Map.get(metadata, :state, :failure)\n\n    stacktrace =\n      if stacktrace == [],\n        do: [{String.to_existing_atom(\"Elixir.\" <> job.worker), :perform, 2, []}],\n        else: stacktrace\n\n    ErrorTracker.report(exception, stacktrace, %{state: state})\n  end\nend\n"
  },
  {
    "path": "lib/error_tracker/integrations/phoenix.ex",
    "content": "defmodule ErrorTracker.Integrations.Phoenix do\n  @moduledoc \"\"\"\n  Integration with Phoenix applications.\n\n  ## How to use it\n\n  It is a plug and play integration: as long as you have Phoenix installed the\n  ErrorTracker will receive and store the errors as they are reported.\n\n  It also collects the exceptions that raise on your LiveView modules.\n\n  ### How it works\n\n  It works using Phoenix's Telemetry events, so you don't need to modify\n  anything on your application.\n\n  ### Errors on the Endpoint\n\n  This integration only catches errors that raise after the requests hits your\n  Router. That means that an exception on a plug defined on your Endpoint will\n  not be reported.\n\n  If you want to also catch those errors, we recommend you to set up the\n  `ErrorTracker.Integrations.Plug` integration too.\n\n  ### Default context\n\n  For errors that are reported when executing regular HTTP requests (the ones\n  that go to Controllers), the context added by default is the same that you\n  can find on the `ErrorTracker.Integrations.Plug` integration.\n\n  As for exceptions generated in LiveView processes, we collect some special\n  information on the context:\n\n  * `live_view.view`: the LiveView module itself,\n\n  * `live_view.uri`: last URI that loaded the LiveView (available when the\n  `handle_params` function is invoked).\n\n  * `live_view.params`: the params received by the LiveView (available when the\n  `handle_params` function is invoked).\n\n  * `live_view.event`: last event received by the LiveView (available when the\n  `handle_event` function is invoked).\n\n  * `live_view.event_params`: last event params received by the LiveView\n  (available when the `handle_event` function is invoked).\n  \"\"\"\n\n  alias ErrorTracker.Integrations.Plug, as: PlugIntegration\n\n  @events [\n    # https://hexdocs.pm/phoenix/Phoenix.Logger.html#module-instrumentation\n    [:phoenix, :router_dispatch, :start],\n    [:phoenix, :router_dispatch, :exception],\n    # https://hexdocs.pm/phoenix_live_view/telemetry.html\n    [:phoenix, :live_view, :mount, :start],\n    [:phoenix, :live_view, :mount, :exception],\n    [:phoenix, :live_view, :handle_params, :start],\n    [:phoenix, :live_view, :handle_params, :exception],\n    [:phoenix, :live_view, :handle_event, :exception],\n    [:phoenix, :live_view, :render, :exception],\n    [:phoenix, :live_component, :update, :exception],\n    [:phoenix, :live_component, :handle_event, :exception]\n  ]\n\n  @doc false\n  def attach do\n    if Application.spec(:phoenix) do\n      :telemetry.attach_many(__MODULE__, @events, &__MODULE__.handle_event/4, :no_config)\n    end\n  end\n\n  @doc false\n  def handle_event([:phoenix, :router_dispatch, :start], _measurements, metadata, :no_config) do\n    PlugIntegration.set_context(metadata.conn)\n  end\n\n  def handle_event([:phoenix, :router_dispatch, :exception], _measurements, metadata, :no_config) do\n    {reason, kind, stack} =\n      case metadata do\n        %{reason: %Plug.Conn.WrapperError{reason: reason, kind: kind, stack: stack}} ->\n          {reason, kind, stack}\n\n        %{kind: kind, reason: reason, stacktrace: stack} ->\n          {reason, kind, stack}\n      end\n\n    PlugIntegration.report_error(metadata.conn, {kind, reason}, stack)\n  end\n\n  def handle_event([:phoenix, :live_view, :mount, :start], _, metadata, :no_config) do\n    ErrorTracker.set_context(%{\n      \"live_view.view\" => metadata.socket.view\n    })\n  end\n\n  def handle_event([:phoenix, :live_view, :handle_params, :start], _, metadata, :no_config) do\n    ErrorTracker.set_context(%{\n      \"live_view.uri\" => metadata.uri,\n      \"live_view.params\" => metadata.params\n    })\n  end\n\n  def handle_event([:phoenix, :live_view, :handle_event, :exception], _, metadata, :no_config) do\n    ErrorTracker.report({metadata.kind, metadata.reason}, metadata.stacktrace, %{\n      \"live_view.event\" => metadata.event,\n      \"live_view.event_params\" => metadata.params\n    })\n  end\n\n  def handle_event([:phoenix, :live_view, _action, :exception], _, metadata, :no_config) do\n    ErrorTracker.report({metadata.kind, metadata.reason}, metadata.stacktrace)\n  end\n\n  def handle_event([:phoenix, :live_component, :update, :exception], _, metadata, :no_config) do\n    ErrorTracker.report({metadata.kind, metadata.reason}, metadata.stacktrace, %{\n      \"live_view.component\" => metadata.component\n    })\n  end\n\n  def handle_event([:phoenix, :live_component, :handle_event, :exception], _, metadata, :no_config) do\n    ErrorTracker.report({metadata.kind, metadata.reason}, metadata.stacktrace, %{\n      \"live_view.component\" => metadata.component,\n      \"live_view.event\" => metadata.event,\n      \"live_view.event_params\" => metadata.params\n    })\n  end\nend\n"
  },
  {
    "path": "lib/error_tracker/integrations/plug.ex",
    "content": "defmodule ErrorTracker.Integrations.Plug do\n  @moduledoc \"\"\"\n  Integration with Plug applications.\n\n  ## How to use it\n\n  ### Plug applications\n\n  The way to use this integration is by adding it to either your `Plug.Builder`\n  or `Plug.Router`:\n\n  ```elixir\n  defmodule MyApp.Router do\n    use Plug.Router\n    use ErrorTracker.Integrations.Plug\n\n    ...\n  end\n  ```\n\n  ### Phoenix applications\n\n  There is a particular use case which can be useful when running a Phoenix\n  web application.\n\n  If you want to record exceptions that may occur in your application's endpoint\n  before reaching your router (for example, in any plug like the ones decoding\n  cookies of body contents) you may want to add this integration too:\n\n  ```elixir\n  defmodule MyApp.Endpoint do\n    use Phoenix.Endpoint\n    use ErrorTracker.Integrations.Plug\n\n    ...\n  end\n  ```\n\n  ### Default context\n\n  By default we store some context for you on errors generated during a Plug\n  request:\n\n  * `request.host`: the `conn.host` value.\n\n  * `request.ip`: the IP address that initiated the request. It includes parsing\n  proxy headers\n\n  * `request.method`: the HTTP method of the request.\n\n  * `request.path`: the path of the request.\n\n  * `request.query`: the query string of the request.\n\n  * `request.params`: parsed params of the request (only available if they have\n  been fetched and parsed as part of the Plug pipeline).\n\n  * `request.headers`: headers received on the request. All headers are included\n  by default except for the `Cookie` ones, as they may include large and\n  sensitive content like sessions.\n\n  \"\"\"\n\n  defmacro __using__(_opts) do\n    quote do\n      @before_compile unquote(__MODULE__)\n    end\n  end\n\n  defmacro __before_compile__(_) do\n    quote do\n      defoverridable call: 2\n\n      def call(conn, opts) do\n        unquote(__MODULE__).set_context(conn)\n        super(conn, opts)\n      rescue\n        e in Plug.Conn.WrapperError ->\n          unquote(__MODULE__).report_error(e.conn, e.reason, e.stack)\n\n          Plug.Conn.WrapperError.reraise(e)\n\n        e ->\n          stack = __STACKTRACE__\n          unquote(__MODULE__).report_error(conn, e, stack)\n\n          :erlang.raise(:error, e, stack)\n      catch\n        kind, reason ->\n          stack = __STACKTRACE__\n          unquote(__MODULE__).report_error(conn, {kind, reason}, stack)\n\n          :erlang.raise(kind, reason, stack)\n      end\n    end\n  end\n\n  @doc false\n  def report_error(conn, reason, stack) do\n    if !Process.get(:error_tracker_router_exception_reported) do\n      try do\n        ErrorTracker.report(reason, stack, build_context(conn))\n      after\n        Process.put(:error_tracker_router_exception_reported, true)\n      end\n    end\n  end\n\n  @doc false\n  def set_context(%Plug.Conn{} = conn) do\n    conn |> build_context() |> ErrorTracker.set_context()\n  end\n\n  @sensitive_headers ~w[authorization cookie set-cookie]\n\n  defp build_context(%Plug.Conn{} = conn) do\n    %{\n      \"request.host\" => conn.host,\n      \"request.path\" => conn.request_path,\n      \"request.query\" => conn.query_string,\n      \"request.method\" => conn.method,\n      \"request.ip\" => remote_ip(conn),\n      \"request.headers\" =>\n        Map.new(conn.req_headers, fn {header, value} ->\n          if header in @sensitive_headers, do: {header, \"[REDACTED]\"}, else: {header, value}\n        end),\n      # Depending on the error source, the request params may have not been fetched yet\n      \"request.params\" => if(!is_struct(conn.params, Plug.Conn.Unfetched), do: conn.params)\n    }\n  end\n\n  defp remote_ip(%Plug.Conn{} = conn) do\n    remote_ip =\n      case Plug.Conn.get_req_header(conn, \"x-forwarded-for\") do\n        [x_forwarded_for | _] ->\n          x_forwarded_for |> String.split(\",\", parts: 2) |> List.first()\n\n        [] ->\n          case :inet.ntoa(conn.remote_ip) do\n            {:error, _} -> \"\"\n            address -> to_string(address)\n          end\n      end\n\n    String.trim(remote_ip)\n  end\nend\n"
  },
  {
    "path": "lib/error_tracker/migration/mysql/v03.ex",
    "content": "defmodule ErrorTracker.Migration.MySQL.V03 do\n  @moduledoc false\n\n  use Ecto.Migration\n\n  def up(_opts) do\n    create table(:error_tracker_meta, primary_key: [name: :key, type: :string]) do\n      add :value, :string, null: false\n    end\n\n    create table(:error_tracker_errors, primary_key: [name: :id, type: :bigserial]) do\n      add :kind, :string, null: false\n      add :reason, :text, null: false\n      add :source_line, :text, null: false\n      add :source_function, :text, null: false\n      add :status, :string, null: false\n      add :fingerprint, :string, null: false\n      add :last_occurrence_at, :utc_datetime_usec, null: false\n\n      timestamps(type: :utc_datetime_usec)\n    end\n\n    create unique_index(:error_tracker_errors, [:fingerprint])\n\n    create table(:error_tracker_occurrences, primary_key: [name: :id, type: :bigserial]) do\n      add :context, :map, null: false\n      add :reason, :text, null: false\n      add :stacktrace, :map, null: false\n\n      add :error_id,\n          references(:error_tracker_errors,\n            on_delete: :delete_all,\n            column: :id,\n            type: :bigserial\n          ),\n          null: false\n\n      timestamps(type: :utc_datetime_usec, updated_at: false)\n    end\n\n    create index(:error_tracker_occurrences, [:error_id])\n\n    create index(:error_tracker_errors, [:last_occurrence_at])\n  end\n\n  def down(_opts) do\n    drop table(:error_tracker_occurrences)\n    drop table(:error_tracker_errors)\n    drop table(:error_tracker_meta)\n  end\nend\n"
  },
  {
    "path": "lib/error_tracker/migration/mysql/v04.ex",
    "content": "defmodule ErrorTracker.Migration.MySQL.V04 do\n  @moduledoc false\n\n  use Ecto.Migration\n\n  def up(_opts) do\n    alter table(:error_tracker_occurrences) do\n      add :breadcrumbs, :json, null: true\n    end\n  end\n\n  def down(_opts) do\n    alter table(:error_tracker_occurrences) do\n      remove :breadcrumbs\n    end\n  end\nend\n"
  },
  {
    "path": "lib/error_tracker/migration/mysql/v05.ex",
    "content": "defmodule ErrorTracker.Migration.MySQL.V05 do\n  @moduledoc false\n\n  use Ecto.Migration\n\n  def up(_opts) do\n    alter table(:error_tracker_errors) do\n      add :muted, :boolean, default: false, null: false\n    end\n  end\n\n  def down(_opts) do\n    alter table(:error_tracker_errors) do\n      remove :muted\n    end\n  end\nend\n"
  },
  {
    "path": "lib/error_tracker/migration/mysql.ex",
    "content": "defmodule ErrorTracker.Migration.MySQL do\n  @moduledoc false\n\n  @behaviour ErrorTracker.Migration\n\n  use Ecto.Migration\n\n  alias ErrorTracker.Migration.SQLMigrator\n\n  @initial_version 3\n  @current_version 5\n\n  @impl ErrorTracker.Migration\n  def up(opts) do\n    opts = with_defaults(opts, @current_version)\n    SQLMigrator.migrate_up(__MODULE__, opts, @initial_version)\n  end\n\n  @impl ErrorTracker.Migration\n  def down(opts) do\n    opts = with_defaults(opts, @initial_version)\n    SQLMigrator.migrate_down(__MODULE__, opts, @initial_version)\n  end\n\n  @impl ErrorTracker.Migration\n  def current_version(opts) do\n    opts = with_defaults(opts, @initial_version)\n    SQLMigrator.current_version(opts)\n  end\n\n  defp with_defaults(opts, version) do\n    Enum.into(opts, %{version: version})\n  end\nend\n"
  },
  {
    "path": "lib/error_tracker/migration/postgres/v01.ex",
    "content": "defmodule ErrorTracker.Migration.Postgres.V01 do\n  @moduledoc false\n\n  use Ecto.Migration\n\n  import Ecto.Query\n\n  def up(%{create_schema: create_schema, prefix: prefix} = opts) do\n    # Prior to V02 the migration version was stored in table comments.\n    # As of now the migration version is stored in a new table (created in V02).\n    #\n    # However, systems migrating to V02 may think they need to run V01 too, so\n    # we need to check for the legacy version storage to avoid running this\n    # migration twice.\n    if current_version_legacy(opts) == 0 do\n      if create_schema, do: execute(\"CREATE SCHEMA IF NOT EXISTS #{prefix}\")\n\n      create table(:error_tracker_meta,\n               primary_key: [name: :key, type: :string],\n               prefix: prefix\n             ) do\n        add :value, :string, null: false\n      end\n\n      create table(:error_tracker_errors,\n               primary_key: [name: :id, type: :bigserial],\n               prefix: prefix\n             ) do\n        add :kind, :string, null: false\n        add :reason, :text, null: false\n        add :source_line, :text, null: false\n        add :source_function, :text, null: false\n        add :status, :string, null: false\n        add :fingerprint, :string, null: false\n        add :last_occurrence_at, :utc_datetime_usec, null: false\n\n        timestamps(type: :utc_datetime_usec)\n      end\n\n      create unique_index(:error_tracker_errors, [:fingerprint], prefix: prefix)\n\n      create table(:error_tracker_occurrences,\n               primary_key: [name: :id, type: :bigserial],\n               prefix: prefix\n             ) do\n        add :context, :map, null: false\n        add :reason, :text, null: false\n        add :stacktrace, :map, null: false\n\n        add :error_id,\n            references(:error_tracker_errors,\n              on_delete: :delete_all,\n              column: :id,\n              type: :bigserial\n            ),\n            null: false\n\n        timestamps(type: :utc_datetime_usec, updated_at: false)\n      end\n\n      create index(:error_tracker_occurrences, [:error_id], prefix: prefix)\n    else\n      :noop\n    end\n  end\n\n  def down(%{prefix: prefix}) do\n    drop table(:error_tracker_occurrences, prefix: prefix)\n    drop table(:error_tracker_errors, prefix: prefix)\n    drop_if_exists table(:error_tracker_meta, prefix: prefix)\n  end\n\n  def current_version_legacy(opts) do\n    query =\n      from pg_class in \"pg_class\",\n        left_join: pg_description in \"pg_description\",\n        on: pg_description.objoid == pg_class.oid,\n        left_join: pg_namespace in \"pg_namespace\",\n        on: pg_namespace.oid == pg_class.relnamespace,\n        where: pg_class.relname == \"error_tracker_errors\",\n        where: pg_namespace.nspname == ^opts.escaped_prefix,\n        select: pg_description.description\n\n    case repo().one(query, log: false) do\n      version when is_binary(version) -> String.to_integer(version)\n      _other -> 0\n    end\n  end\nend\n"
  },
  {
    "path": "lib/error_tracker/migration/postgres/v02.ex",
    "content": "defmodule ErrorTracker.Migration.Postgres.V02 do\n  @moduledoc false\n\n  use Ecto.Migration\n\n  def up(%{prefix: prefix}) do\n    # For systems which executed versions without this migration they may not\n    # have the error_tracker_meta table, so we need to create it conditionally\n    # to avoid errors.\n    create_if_not_exists table(:error_tracker_meta,\n                           primary_key: [name: :key, type: :string],\n                           prefix: prefix\n                         ) do\n      add :value, :string, null: false\n    end\n\n    execute \"COMMENT ON TABLE #{inspect(prefix)}.error_tracker_errors IS ''\"\n  end\n\n  def down(%{prefix: prefix}) do\n    # We do not delete the `error_tracker_meta` table because it's creation and\n    # deletion are controlled by V01 migration.\n    execute \"COMMENT ON TABLE #{inspect(prefix)}.error_tracker_errors IS '1'\"\n  end\nend\n"
  },
  {
    "path": "lib/error_tracker/migration/postgres/v03.ex",
    "content": "defmodule ErrorTracker.Migration.Postgres.V03 do\n  @moduledoc false\n\n  use Ecto.Migration\n\n  def up(%{prefix: prefix}) do\n    create_if_not_exists index(:error_tracker_errors, [:last_occurrence_at], prefix: prefix)\n  end\n\n  def down(%{prefix: prefix}) do\n    drop_if_exists index(:error_tracker_errors, [:last_occurrence_at], prefix: prefix)\n  end\nend\n"
  },
  {
    "path": "lib/error_tracker/migration/postgres/v04.ex",
    "content": "defmodule ErrorTracker.Migration.Postgres.V04 do\n  @moduledoc false\n\n  use Ecto.Migration\n\n  def up(%{prefix: prefix}) do\n    alter table(:error_tracker_occurrences, prefix: prefix) do\n      add :breadcrumbs, {:array, :string}, default: [], null: false\n    end\n  end\n\n  def down(%{prefix: prefix}) do\n    alter table(:error_tracker_occurrences, prefix: prefix) do\n      remove :breadcrumbs\n    end\n  end\nend\n"
  },
  {
    "path": "lib/error_tracker/migration/postgres/v05.ex",
    "content": "defmodule ErrorTracker.Migration.Postgres.V05 do\n  @moduledoc false\n\n  use Ecto.Migration\n\n  def up(%{prefix: prefix}) do\n    alter table(:error_tracker_errors, prefix: prefix) do\n      add :muted, :boolean, default: false, null: false\n    end\n  end\n\n  def down(%{prefix: prefix}) do\n    alter table(:error_tracker_errors, prefix: prefix) do\n      remove :muted\n    end\n  end\nend\n"
  },
  {
    "path": "lib/error_tracker/migration/postgres.ex",
    "content": "defmodule ErrorTracker.Migration.Postgres do\n  @moduledoc false\n\n  @behaviour ErrorTracker.Migration\n\n  use Ecto.Migration\n\n  alias ErrorTracker.Migration.SQLMigrator\n\n  @initial_version 1\n  @current_version 5\n  @default_prefix \"public\"\n\n  @impl ErrorTracker.Migration\n  def up(opts) do\n    opts = with_defaults(opts, @current_version)\n    SQLMigrator.migrate_up(__MODULE__, opts, @initial_version)\n  end\n\n  @impl ErrorTracker.Migration\n  def down(opts) do\n    opts = with_defaults(opts, @initial_version)\n    SQLMigrator.migrate_down(__MODULE__, opts, @initial_version)\n  end\n\n  @impl ErrorTracker.Migration\n  def current_version(opts) do\n    opts = with_defaults(opts, @initial_version)\n    SQLMigrator.current_version(opts)\n  end\n\n  defp with_defaults(opts, version) do\n    configured_prefix = Application.get_env(:error_tracker, :prefix, \"public\")\n    opts = Enum.into(opts, %{prefix: configured_prefix, version: version})\n\n    opts\n    |> Map.put_new(:create_schema, opts.prefix != @default_prefix)\n    |> Map.put_new(:escaped_prefix, String.replace(opts.prefix, \"'\", \"\\\\'\"))\n  end\nend\n"
  },
  {
    "path": "lib/error_tracker/migration/sql_migrator.ex",
    "content": "defmodule ErrorTracker.Migration.SQLMigrator do\n  @moduledoc false\n\n  use Ecto.Migration\n\n  import Ecto.Query\n\n  alias Ecto.Adapters.SQL\n\n  def migrate_up(migrator, opts, initial_version) do\n    initial = current_version(opts)\n\n    cond do\n      initial == 0 ->\n        change(migrator, initial_version..opts.version, :up, opts)\n\n      initial < opts.version ->\n        change(migrator, (initial + 1)..opts.version, :up, opts)\n\n      true ->\n        :ok\n    end\n  end\n\n  def migrate_down(migrator, opts, initial_version) do\n    initial = max(current_version(opts), initial_version)\n\n    if initial >= opts.version do\n      change(migrator, initial..opts.version//-1, :down, opts)\n    end\n  end\n\n  def current_version(opts) do\n    repo = Map.get_lazy(opts, :repo, fn -> repo() end)\n\n    query =\n      from meta in \"error_tracker_meta\",\n        where: meta.key == \"migration_version\",\n        select: meta.value\n\n    with true <- meta_table_exists?(repo, opts),\n         version when is_binary(version) <- repo.one(query, log: false, prefix: opts[:prefix]) do\n      String.to_integer(version)\n    else\n      _other -> 0\n    end\n  end\n\n  defp change(migrator, versions_range, direction, opts) do\n    for version <- versions_range do\n      padded_version = String.pad_leading(to_string(version), 2, \"0\")\n\n      migration_module = Module.concat(migrator, \"V#{padded_version}\")\n      apply(migration_module, direction, [opts])\n    end\n\n    case direction do\n      :up -> record_version(opts, Enum.max(versions_range))\n      :down -> record_version(opts, Enum.min(versions_range) - 1)\n    end\n  end\n\n  defp record_version(_opts, 0), do: :ok\n\n  defp record_version(opts, version) do\n    timestamp = DateTime.to_unix(DateTime.utc_now())\n\n    ErrorTracker.Repo.with_adapter(fn\n      :postgres ->\n        prefix = opts[:prefix]\n\n        execute \"\"\"\n        INSERT INTO #{prefix}.error_tracker_meta (key, value)\n        VALUES ('migration_version', '#{version}'), ('migration_timestamp', '#{timestamp}')\n        ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value\n        \"\"\"\n\n      :mysql ->\n        execute \"\"\"\n        INSERT INTO error_tracker_meta (`key`, value)\n        VALUES ('migration_version', '#{version}'), ('migration_timestamp', '#{timestamp}')\n        ON DUPLICATE KEY UPDATE value = VALUES(value)\n        \"\"\"\n\n      _other ->\n        execute \"\"\"\n        INSERT INTO error_tracker_meta (key, value)\n        VALUES ('migration_version', '#{version}'), ('migration_timestamp', '#{timestamp}')\n        ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value\n        \"\"\"\n    end)\n  end\n\n  defp meta_table_exists?(repo, opts) do\n    ErrorTracker.Repo.with_adapter(fn\n      :postgres ->\n        repo\n        |> SQL.query!(\n          \"SELECT TRUE FROM information_schema.tables WHERE table_name = 'error_tracker_meta' AND table_schema = $1\",\n          [opts.prefix],\n          log: false\n        )\n        |> Map.get(:rows)\n        |> Enum.any?()\n\n      _other ->\n        SQL.table_exists?(repo, \"error_tracker_meta\", log: false)\n    end)\n  end\nend\n"
  },
  {
    "path": "lib/error_tracker/migration/sqlite/v02.ex",
    "content": "defmodule ErrorTracker.Migration.SQLite.V02 do\n  @moduledoc false\n\n  use Ecto.Migration\n\n  def up(_opts) do\n    create table(:error_tracker_meta, primary_key: [name: :key, type: :string]) do\n      add :value, :string, null: false\n    end\n\n    create table(:error_tracker_errors, primary_key: [name: :id, type: :bigserial]) do\n      add :kind, :string, null: false\n      add :reason, :text, null: false\n      add :source_line, :text, null: false\n      add :source_function, :text, null: false\n      add :status, :string, null: false\n      add :fingerprint, :string, null: false\n      add :last_occurrence_at, :utc_datetime_usec, null: false\n\n      timestamps(type: :utc_datetime_usec)\n    end\n\n    create unique_index(:error_tracker_errors, [:fingerprint])\n\n    create table(:error_tracker_occurrences, primary_key: [name: :id, type: :bigserial]) do\n      add :context, :map, null: false\n      add :reason, :text, null: false\n      add :stacktrace, :map, null: false\n\n      add :error_id,\n          references(:error_tracker_errors,\n            on_delete: :delete_all,\n            column: :id,\n            type: :bigserial\n          ),\n          null: false\n\n      timestamps(type: :utc_datetime_usec, updated_at: false)\n    end\n\n    create index(:error_tracker_occurrences, [:error_id])\n  end\n\n  def down(_opts) do\n    drop table(:error_tracker_occurrences)\n    drop table(:error_tracker_errors)\n    drop table(:error_tracker_meta)\n  end\nend\n"
  },
  {
    "path": "lib/error_tracker/migration/sqlite/v03.ex",
    "content": "defmodule ErrorTracker.Migration.SQLite.V03 do\n  @moduledoc false\n\n  use Ecto.Migration\n\n  def up(_opts) do\n    create_if_not_exists index(:error_tracker_errors, [:last_occurrence_at])\n  end\n\n  def down(_opts) do\n    drop_if_exists index(:error_tracker_errors, [:last_occurrence_at])\n  end\nend\n"
  },
  {
    "path": "lib/error_tracker/migration/sqlite/v04.ex",
    "content": "defmodule ErrorTracker.Migration.SQLite.V04 do\n  @moduledoc false\n\n  use Ecto.Migration\n\n  def up(_opts) do\n    alter table(:error_tracker_occurrences) do\n      add :breadcrumbs, {:array, :string}, default: [], null: false\n    end\n  end\n\n  def down(_opts) do\n    alter table(:error_tracker_occurrences) do\n      remove :breadcrumbs\n    end\n  end\nend\n"
  },
  {
    "path": "lib/error_tracker/migration/sqlite/v05.ex",
    "content": "defmodule ErrorTracker.Migration.SQLite.V05 do\n  @moduledoc false\n\n  use Ecto.Migration\n\n  def up(_opts) do\n    alter table(:error_tracker_errors) do\n      add :muted, :boolean, default: false, null: false\n    end\n  end\n\n  def down(_opts) do\n    alter table(:error_tracker_errors) do\n      remove :muted\n    end\n  end\nend\n"
  },
  {
    "path": "lib/error_tracker/migration/sqlite.ex",
    "content": "defmodule ErrorTracker.Migration.SQLite do\n  @moduledoc false\n\n  @behaviour ErrorTracker.Migration\n\n  use Ecto.Migration\n\n  alias ErrorTracker.Migration.SQLMigrator\n\n  @initial_version 2\n  @current_version 5\n\n  @impl ErrorTracker.Migration\n  def up(opts) do\n    opts = with_defaults(opts, @current_version)\n    SQLMigrator.migrate_up(__MODULE__, opts, @initial_version)\n  end\n\n  @impl ErrorTracker.Migration\n  def down(opts) do\n    opts = with_defaults(opts, @initial_version)\n    SQLMigrator.migrate_down(__MODULE__, opts, @initial_version)\n  end\n\n  @impl ErrorTracker.Migration\n  def current_version(opts) do\n    opts = with_defaults(opts, @initial_version)\n    SQLMigrator.current_version(opts)\n  end\n\n  defp with_defaults(opts, version) do\n    Enum.into(opts, %{version: version})\n  end\nend\n"
  },
  {
    "path": "lib/error_tracker/migration.ex",
    "content": "defmodule ErrorTracker.Migration do\n  @moduledoc \"\"\"\n  Create and modify the database tables for ErrorTracker.\n\n  ## Usage\n\n  To use ErrorTracker migrations in your application you will need to generate\n  a regular `Ecto.Migration` that performs the relevant calls to `ErrorTracker.Migration`.\n\n  ```bash\n  mix ecto.gen.migration add_error_tracker\n  ```\n\n  Open the generated migration file and call the `up` and `down` functions on\n  `ErrorTracker.Migration`.\n\n  ```elixir\n  defmodule MyApp.Repo.Migrations.AddErrorTracker do\n    use Ecto.Migration\n\n    def up, do: ErrorTracker.Migration.up()\n    def down, do: ErrorTracker.Migration.down()\n  end\n  ```\n\n  This will run every ErrorTracker migration for your database. You can now run the migration\n  and perform the database changes:\n\n  ```bash\n  mix ecto.migrate\n  ```\n\n  As new versions of ErrorTracker are released you may need to run additional migrations.\n  To do this you can follow the previous process and create a new migration:\n\n  ```bash\n  mix ecto.gen.migration update_error_tracker_to_vN\n  ```\n\n  Open the generated migration file and call the `up` and `down` functions on the\n  `ErrorTracker.Migration` passing the desired `version`.\n\n  ```elixir\n  defmodule MyApp.Repo.Migrations.UpdateErrorTrackerToVN do\n    use Ecto.Migration\n\n    def up, do: ErrorTracker.Migration.up(version: N)\n    def down, do: ErrorTracker.Migration.down(version: N)\n  end\n  ```\n\n  Then run the migrations to perform the database changes:\n\n  ```bash\n  mix ecto.migrate\n  ```\n\n  ## Custom prefix - PostgreSQL only\n\n  ErrorTracker supports namespacing its own tables using PostgreSQL schemas, also known\n  as \"prefixes\" in Ecto. With prefixes your error tables can reside outside of your primary\n  schema (which is usually named \"public\").\n\n  To use a prefix you need to specify it in your configuration:\n\n  ```elixir\n  config :error_tracker, :prefix, \"custom_prefix\"\n  ```\n\n  Migrations will automatically create the database schema for you. If the schema does already exist\n  the migration may fail when trying to recreate it. In such cases you can instruct ErrorTracker\n  not to create the schema again:\n\n  ```elixir\n  defmodule MyApp.Repo.Migrations.AddErrorTracker do\n    use Ecto.Migration\n\n    def up, do: ErrorTracker.Migration.up(create_schema: false)\n    def down, do: ErrorTracker.Migration.down()\n  end\n  ```\n\n  You can also override the configured prefix in the migration:\n\n  ```elixir\n  defmodule MyApp.Repo.Migrations.AddErrorTracker do\n    use Ecto.Migration\n\n    def up, do: ErrorTracker.Migration.up(prefix: \"custom_prefix\")\n    def down, do: ErrorTracker.Migration.down(prefix: \"custom_prefix\")\n  end\n  ```\n  \"\"\"\n\n  @callback up(Keyword.t()) :: :ok\n  @callback down(Keyword.t()) :: :ok\n  @callback current_version(Keyword.t()) :: non_neg_integer()\n\n  @spec up(Keyword.t()) :: :ok\n  def up(opts \\\\ []) when is_list(opts) do\n    migrator().up(opts)\n  end\n\n  @spec down(Keyword.t()) :: :ok\n  def down(opts \\\\ []) when is_list(opts) do\n    migrator().down(opts)\n  end\n\n  @spec migrated_version(Keyword.t()) :: non_neg_integer()\n  def migrated_version(opts \\\\ []) when is_list(opts) do\n    migrator().migrated_version(opts)\n  end\n\n  defp migrator do\n    ErrorTracker.Repo.with_adapter(fn\n      :postgres -> ErrorTracker.Migration.Postgres\n      :mysql -> ErrorTracker.Migration.MySQL\n      :sqlite -> ErrorTracker.Migration.SQLite\n      adapter -> raise \"ErrorTracker does not support #{adapter}\"\n    end)\n  end\nend\n"
  },
  {
    "path": "lib/error_tracker/plugins/pruner.ex",
    "content": "defmodule ErrorTracker.Plugins.Pruner do\n  @moduledoc \"\"\"\n  Periodically delete resolved errors based on their age.\n\n  Pruning allows you to keep your database size under control by removing old errors that are not\n  needed anymore.\n\n  ## Using the pruner\n\n  To enable the pruner you must register the plugin in the ErrorTracker configuration. This will use\n  the default options, which is to prune errors resolved after 24 hours.\n\n      config :error_tracker,\n        plugins: [ErrorTracker.Plugins.Pruner]\n\n  You can override the default options by passing them as an argument when registering the plugin.\n\n      config :error_tracker,\n        plugins: [{ErrorTracker.Plugins.Pruner, max_age: :timer.minutes(30)}]\n\n  ## Options\n\n  - `:limit`  - the maximum number of errors to prune on each execution. Occurrences are removed\n    along the errors. The default is 200 to prevent timeouts and unnecesary database load.\n\n  - `:max_age` - the number of milliseconds after a resolved error may be pruned. The default is 24\n    hours.\n\n  - `:interval` - the interval in milliseconds between pruning runs. The default is 30 minutes.\n\n  You may find the `:timer` module functions useful to pass readable values to the `:max_age` and\n  `:interval` options.\n\n  ## Manual pruning\n\n  In certain cases you may prefer to run the pruner manually. This can be done by calling the\n  `prune_errors/2` function from your application code. This function supports the `:limit` and\n  `:max_age` options as described above.\n\n  For example, you may call this function from an Oban worker so you can leverage Oban's cron\n  capabilities and have a more granular control over when pruning is run.\n\n      defmodule MyApp.ErrorPruner do\n        use Oban.Worker\n\n        def perform(%Job{}) do\n          ErrorTracker.Plugins.Pruner.prune_errors(limit: 10_000, max_age: :timer.minutes(60))\n        end\n      end\n  \"\"\"\n  use GenServer\n\n  import Ecto.Query\n\n  alias ErrorTracker.Error\n  alias ErrorTracker.Occurrence\n  alias ErrorTracker.Repo\n\n  @doc \"\"\"\n  Prunes resolved errors.\n\n  You do not need to use this function if you activate the Pruner plugin. This function is exposed\n  only for advanced use cases and Oban integration.\n\n  ## Options\n\n  - `:limit`  - the maximum number of errors to prune on each execution. Occurrences are removed\n    along the errors. The default is 200 to prevent timeouts and unnecesary database load.\n\n  - `:max_age` - the number of milliseconds after a resolved error may be pruned. The default is 24\n    hours. You may find the `:timer` module functions useful to pass readable values to this option.\n  \"\"\"\n  @spec prune_errors(keyword()) :: {:ok, list(Error.t())}\n  def prune_errors(opts \\\\ []) do\n    limit = opts[:limit] || raise \":limit option is required\"\n    max_age = opts[:max_age] || raise \":max_age option is required\"\n    time = DateTime.add(DateTime.utc_now(), -max_age, :millisecond)\n\n    errors =\n      Repo.all(\n        from error in Error,\n          select: [:id, :kind, :source_line, :source_function],\n          where: error.status == :resolved,\n          where: error.last_occurrence_at < ^time,\n          limit: ^limit\n      )\n\n    if Enum.any?(errors) do\n      _pruned_occurrences_count =\n        errors\n        |> Ecto.assoc(:occurrences)\n        |> prune_occurrences()\n        |> Enum.sum()\n\n      Repo.delete_all(from error in Error, where: error.id in ^Enum.map(errors, & &1.id))\n    end\n\n    {:ok, errors}\n  end\n\n  defp prune_occurrences(occurrences_query) do\n    Stream.unfold(occurrences_query, fn occurrences_query ->\n      occurrences_ids =\n        Repo.all(from occurrence in occurrences_query, select: occurrence.id, limit: 1000)\n\n      case Repo.delete_all(from o in Occurrence, where: o.id in ^occurrences_ids) do\n        {0, _} -> nil\n        {deleted, _} -> {deleted, occurrences_query}\n      end\n    end)\n  end\n\n  def start_link(state \\\\ []) do\n    GenServer.start_link(__MODULE__, state, name: __MODULE__)\n  end\n\n  @impl GenServer\n  @doc false\n  def init(state \\\\ []) do\n    state = %{\n      limit: state[:limit] || 200,\n      max_age: state[:max_age] || :timer.hours(24),\n      interval: state[:interval] || :timer.minutes(30)\n    }\n\n    {:ok, schedule_prune(state)}\n  end\n\n  @impl GenServer\n  @doc false\n  def handle_info(:prune, state) do\n    {:ok, _pruned} = prune_errors(state)\n\n    {:noreply, schedule_prune(state)}\n  end\n\n  defp schedule_prune(%{interval: interval} = state) do\n    Process.send_after(self(), :prune, interval)\n\n    state\n  end\nend\n"
  },
  {
    "path": "lib/error_tracker/repo.ex",
    "content": "defmodule ErrorTracker.Repo do\n  @moduledoc false\n\n  def insert!(struct_or_changeset, opts \\\\ []) do\n    dispatch(:insert!, [struct_or_changeset], opts)\n  end\n\n  def update(changeset, opts \\\\ []) do\n    dispatch(:update, [changeset], opts)\n  end\n\n  def get(queryable, id, opts \\\\ []) do\n    dispatch(:get, [queryable, id], opts)\n  end\n\n  def get!(queryable, id, opts \\\\ []) do\n    dispatch(:get!, [queryable, id], opts)\n  end\n\n  def one(queryable, opts \\\\ []) do\n    dispatch(:one, [queryable], opts)\n  end\n\n  def all(queryable, opts \\\\ []) do\n    dispatch(:all, [queryable], opts)\n  end\n\n  def delete_all(queryable, opts \\\\ []) do\n    dispatch(:delete_all, [queryable], opts)\n  end\n\n  def aggregate(queryable, aggregate, opts \\\\ []) do\n    dispatch(:aggregate, [queryable, aggregate], opts)\n  end\n\n  def transaction(fun_or_multi, opts \\\\ []) do\n    dispatch(:transaction, [fun_or_multi], opts)\n  end\n\n  def with_adapter(fun) do\n    adapter =\n      case repo().__adapter__() do\n        Ecto.Adapters.Postgres -> :postgres\n        Ecto.Adapters.MyXQL -> :mysql\n        Ecto.Adapters.SQLite3 -> :sqlite\n      end\n\n    fun.(adapter)\n  end\n\n  defp dispatch(action, args, opts) do\n    repo = repo()\n\n    defaults =\n      with_adapter(fn\n        :postgres -> [prefix: Application.get_env(:error_tracker, :prefix, \"public\")]\n        _ -> []\n      end)\n\n    opts_w_defaults = Keyword.merge(defaults, opts)\n\n    apply(repo, action, args ++ [opts_w_defaults])\n  end\n\n  defp repo do\n    Application.fetch_env!(:error_tracker, :repo)\n  end\nend\n"
  },
  {
    "path": "lib/error_tracker/schemas/error.ex",
    "content": "defmodule ErrorTracker.Error do\n  @moduledoc \"\"\"\n  Schema to store an error or exception recorded by ErrorTracker.\n\n  It stores a kind, reason and source code location to generate a unique\n  fingerprint that can be used to avoid duplicates.\n\n  The fingerprint currently does not include the reason itself because it can\n  contain specific details that can change on the same error depending on\n  runtime conditions.\n  \"\"\"\n\n  use Ecto.Schema\n\n  @type t :: %__MODULE__{\n          kind: String.t(),\n          reason: String.t(),\n          source_line: String.t(),\n          source_function: String.t(),\n          status: :resolved | :unresolved,\n          fingerprint: String.t(),\n          last_occurrence_at: DateTime.t(),\n          muted: boolean()\n        }\n\n  schema \"error_tracker_errors\" do\n    field :kind, :string\n    field :reason, :string\n    field :source_line, :string\n    field :source_function, :string\n    field :status, Ecto.Enum, values: [:resolved, :unresolved], default: :unresolved\n    field :fingerprint, :binary\n    field :last_occurrence_at, :utc_datetime_usec\n    field :muted, :boolean\n\n    has_many :occurrences, ErrorTracker.Occurrence\n\n    timestamps(type: :utc_datetime_usec)\n  end\n\n  @doc false\n  def new(kind, reason, %ErrorTracker.Stacktrace{} = stacktrace) do\n    source = ErrorTracker.Stacktrace.source(stacktrace)\n\n    {source_line, source_function} =\n      if source do\n        source_line = if source.line, do: \"#{source.file}:#{source.line}\", else: \"(nofile)\"\n        source_function = \"#{source.module}.#{source.function}/#{source.arity}\"\n\n        {source_line, source_function}\n      else\n        {\"-\", \"-\"}\n      end\n\n    params = [\n      kind: to_string(kind),\n      source_line: source_line,\n      source_function: source_function\n    ]\n\n    fingerprint = :crypto.hash(:sha256, params |> Keyword.values() |> Enum.join())\n\n    %__MODULE__{}\n    |> Ecto.Changeset.change(params)\n    |> Ecto.Changeset.put_change(:reason, reason)\n    |> Ecto.Changeset.put_change(:fingerprint, Base.encode16(fingerprint))\n    |> Ecto.Changeset.put_change(:last_occurrence_at, DateTime.utc_now())\n    |> Ecto.Changeset.apply_action(:new)\n  end\n\n  @doc \"\"\"\n  Returns if the Error has information of the source or not.\n\n  Errors usually have information about in which line and function occurred, but\n  in some cases (like an Oban job ending with `{:error, any()}`) we cannot get\n  that information and no source is stored.\n  \"\"\"\n  def has_source_info?(%__MODULE__{source_function: \"-\", source_line: \"-\"}), do: false\n  def has_source_info?(%__MODULE__{}), do: true\nend\n"
  },
  {
    "path": "lib/error_tracker/schemas/occurrence.ex",
    "content": "defmodule ErrorTracker.Occurrence do\n  @moduledoc \"\"\"\n  Schema to store a particular instance of an error in a given time.\n\n  It contains all the metadata available about the moment and the environment\n  in which the exception raised.\n  \"\"\"\n\n  use Ecto.Schema\n\n  import Ecto.Changeset\n\n  require Logger\n\n  @type t :: %__MODULE__{}\n\n  schema \"error_tracker_occurrences\" do\n    field :reason, :string\n\n    field :context, :map\n    field :breadcrumbs, {:array, :string}\n\n    embeds_one :stacktrace, ErrorTracker.Stacktrace\n    belongs_to :error, ErrorTracker.Error\n\n    timestamps(type: :utc_datetime_usec, updated_at: false)\n  end\n\n  @doc false\n  def changeset(occurrence, attrs) do\n    occurrence\n    |> cast(attrs, [:context, :reason, :breadcrumbs])\n    |> maybe_put_stacktrace()\n    |> validate_required([:reason, :stacktrace])\n    |> validate_context()\n    |> foreign_key_constraint(:error)\n  end\n\n  # This function validates if the context can be serialized to JSON before\n  # storing it to the DB.\n  #\n  # If it cannot be serialized a warning log message is emitted and an error\n  # is stored in the context.\n  #\n  defp validate_context(changeset) do\n    if changeset.valid? do\n      context = get_field(changeset, :context, %{})\n\n      db_json_encoder =\n        ErrorTracker.Repo.with_adapter(fn\n          :postgres -> Application.get_env(:postgrex, :json_library)\n          :mysql -> Application.get_env(:myxql, :json_library)\n          :sqlite -> Application.get_env(:ecto_sqlite3, :json_library)\n        end)\n\n      validated_context =\n        try do\n          json_encoder = db_json_encoder || ErrorTracker.__default_json_encoder__()\n          _iodata = json_encoder.encode_to_iodata!(context)\n\n          context\n        rescue\n          _e ->\n            Logger.warning(\"[ErrorTracker] Context has been ignored: it is not serializable to JSON.\")\n\n            %{\n              error: \"Context not stored because it contains information not serializable to JSON.\"\n            }\n        end\n\n      put_change(changeset, :context, validated_context)\n    else\n      changeset\n    end\n  end\n\n  defp maybe_put_stacktrace(changeset) do\n    if stacktrace = Map.get(changeset.params, \"stacktrace\"),\n      do: put_embed(changeset, :stacktrace, stacktrace),\n      else: changeset\n  end\nend\n"
  },
  {
    "path": "lib/error_tracker/schemas/stacktrace.ex",
    "content": "defmodule ErrorTracker.Stacktrace do\n  @moduledoc \"\"\"\n  An Stacktrace contains the information about the execution stack for a given\n  occurrence of an exception.\n  \"\"\"\n\n  use Ecto.Schema\n\n  @type t :: %__MODULE__{}\n\n  @primary_key false\n  embedded_schema do\n    embeds_many :lines, Line, primary_key: false do\n      field :application, :string\n      field :module, :string\n      field :function, :string\n      field :arity, :integer\n      field :file, :string\n      field :line, :integer\n    end\n  end\n\n  def new(stack) do\n    lines_params =\n      for {module, function, arity, opts} <- stack do\n        application = Application.get_application(module)\n\n        %{\n          application: to_string(application),\n          module: module |> to_string() |> String.replace_prefix(\"Elixir.\", \"\"),\n          function: to_string(function),\n          arity: normalize_arity(arity),\n          file: to_string(opts[:file]),\n          line: opts[:line]\n        }\n      end\n\n    %__MODULE__{}\n    |> Ecto.Changeset.cast(%{lines: lines_params}, [])\n    |> Ecto.Changeset.cast_embed(:lines, with: &line_changeset/2)\n    |> Ecto.Changeset.apply_action(:new)\n  end\n\n  defp normalize_arity(a) when is_integer(a), do: a\n  defp normalize_arity(a) when is_list(a), do: length(a)\n\n  defp line_changeset(%__MODULE__.Line{} = line, params) do\n    Ecto.Changeset.cast(line, params, ~w[application module function arity file line]a)\n  end\n\n  @doc \"\"\"\n  Source of the error stack trace.\n\n  The first line matching the client application. If no line belongs to the current\n  application, just the first line.\n  \"\"\"\n  def source(%__MODULE__{} = stack) do\n    client_app = :error_tracker |> Application.fetch_env!(:otp_app) |> to_string()\n\n    Enum.find(stack.lines, &(&1.application == client_app)) || List.first(stack.lines)\n  end\nend\n\ndefimpl String.Chars, for: ErrorTracker.Stacktrace do\n  def to_string(%ErrorTracker.Stacktrace{} = stack) do\n    Enum.join(stack.lines, \"\\n\")\n  end\nend\n\ndefimpl String.Chars, for: ErrorTracker.Stacktrace.Line do\n  def to_string(%ErrorTracker.Stacktrace.Line{} = stack_line) do\n    \"#{stack_line.module}.#{stack_line.function}/#{stack_line.arity} in #{stack_line.file}:#{stack_line.line}\"\n  end\nend\n"
  },
  {
    "path": "lib/error_tracker/telemetry.ex",
    "content": "defmodule ErrorTracker.Telemetry do\n  @moduledoc \"\"\"\n  Telemetry events of ErrorTracker.\n\n  ErrorTracker emits some events to allow third parties to receive information\n  of errors and occurrences stored.\n\n  ### Error events\n\n  Those occur during the life cycle of an error:\n\n  * `[:error_tracker, :error, :new]`: is emitted when a new error is stored and\n  no previous occurrences were known.\n\n  * `[:error_tracker, :error, :resolved]`: is emitted when a new error is marked\n  as resolved on the UI.\n\n  * `[:error_tracker, :error, :unresolved]`: is emitted when a new error is\n  marked as unresolved on the UI or a new occurrence is registered, moving the\n  error to the unresolved state.\n\n  ### Occurrence events\n\n  There is only one event emitted for occurrences:\n\n  * `[:error_tracker, :occurrence, :new]`: is emitted when a new occurrence is\n  stored.\n\n  ### Measures and metadata\n\n  Each event is emitted with some measures and metadata, which can be used to\n  receive information without having to query the database again:\n\n  | event                                   | measures       | metadata                          |\n  | --------------------------------------- | -------------- | ----------------------------------|\n  | `[:error_tracker, :error, :new]`        | `:system_time` | `:error`                          |\n  | `[:error_tracker, :error, :unresolved]` | `:system_time` | `:error`                          |\n  | `[:error_tracker, :error, :resolved]`   | `:system_time` | `:error`                          |\n  | `[:error_tracker, :occurrence, :new]`   | `:system_time` | `:occurrence`, `:error`, `:muted` |\n\n  The metadata keys contain the following data:\n\n  * `:error` - An `%ErrorTracker.Error{}` struct representing the error.\n  * `:occurrence` - An `%ErrorTracker.Occurrence{}` struct representing the occurrence.\n  * `:muted` - A boolean indicating whether the error is muted or not.\n  \"\"\"\n\n  @doc false\n  def new_error(%ErrorTracker.Error{} = error) do\n    measurements = %{system_time: System.system_time()}\n    metadata = %{error: error}\n    :telemetry.execute([:error_tracker, :error, :new], measurements, metadata)\n  end\n\n  @doc false\n  def unresolved_error(%ErrorTracker.Error{} = error) do\n    measurements = %{system_time: System.system_time()}\n    metadata = %{error: error}\n    :telemetry.execute([:error_tracker, :error, :unresolved], measurements, metadata)\n  end\n\n  @doc false\n  def resolved_error(%ErrorTracker.Error{} = error) do\n    measurements = %{system_time: System.system_time()}\n    metadata = %{error: error}\n    :telemetry.execute([:error_tracker, :error, :resolved], measurements, metadata)\n  end\n\n  @doc false\n  def new_occurrence(%ErrorTracker.Occurrence{} = occurrence, muted) when is_boolean(muted) do\n    measurements = %{system_time: System.system_time()}\n    metadata = %{error: occurrence.error, occurrence: occurrence, muted: muted}\n    :telemetry.execute([:error_tracker, :occurrence, :new], measurements, metadata)\n  end\nend\n"
  },
  {
    "path": "lib/error_tracker/web/components/core_components.ex",
    "content": "defmodule ErrorTracker.Web.CoreComponents do\n  @moduledoc false\n  use Phoenix.Component\n\n  @doc \"\"\"\n  Renders a button.\n\n  ## Examples\n\n      <.button>Send!</.button>\n      <.button phx-click=\"go\" class=\"ml-2\">Send!</.button>\n  \"\"\"\n  attr :type, :string, default: nil\n  attr :class, :string, default: nil\n  attr :rest, :global, include: ~w(disabled form name value href patch navigate)\n\n  slot :inner_block, required: true\n\n  def button(%{type: \"link\"} = assigns) do\n    ~H\"\"\"\n    <.link\n      class={[\n        \"phx-submit-loading:opacity-75 py-[11.5px]\",\n        \"text-sm font-semibold text-sky-500 hover:text-white/80\",\n        @class\n      ]}\n      {@rest}\n    >\n      {render_slot(@inner_block)}\n    </.link>\n    \"\"\"\n  end\n\n  def button(assigns) do\n    ~H\"\"\"\n    <button\n      type={@type}\n      class={[\n        \"phx-submit-loading:opacity-75 rounded-lg bg-sky-500 hover:bg-sky-700 py-2 px-4\",\n        \"text-sm text-white active:text-white/80\",\n        @class\n      ]}\n      {@rest}\n    >\n      {render_slot(@inner_block)}\n    </button>\n    \"\"\"\n  end\n\n  @doc \"\"\"\n  Renders a badge.\n\n  ## Examples\n\n      <.badge>Info</.badge>\n      <.badge color={:red}>Error</.badge>\n  \"\"\"\n  attr :color, :atom, default: :blue\n  attr :rest, :global\n\n  slot :inner_block, required: true\n\n  def badge(assigns) do\n    color_class =\n      case assigns.color do\n        :blue -> \"bg-blue-900 text-blue-300\"\n        :gray -> \"bg-gray-700 text-gray-300\"\n        :red -> \"bg-red-400/10 text-red-300 ring-red-400/20\"\n        :green -> \"bg-emerald-400/10 text-emerald-300 ring-emerald-400/20\"\n        :yellow -> \"bg-yellow-900 text-yellow-300\"\n        :indigo -> \"bg-indigo-900 text-indigo-300\"\n        :purple -> \"bg-purple-900 text-purple-300\"\n        :pink -> \"bg-pink-900 text-pink-300\"\n      end\n\n    assigns = Map.put(assigns, :color_class, color_class)\n\n    ~H\"\"\"\n    <span\n      class={[\"text-sm font-medium me-2 py-1 px-2 rounded-lg ring-1 ring-inset\", @color_class]}\n      {@rest}\n    >\n      {render_slot(@inner_block)}\n    </span>\n    \"\"\"\n  end\n\n  attr :page, :integer, required: true\n  attr :total_pages, :integer, required: true\n  attr :event_previous, :string, default: \"prev-page\"\n  attr :event_next, :string, default: \"next-page\"\n\n  def pagination(assigns) do\n    ~H\"\"\"\n    <div class=\"mt-10 w-full flex\">\n      <button\n        :if={@page > 1}\n        class=\"flex items-center justify-center px-4 h-10 text-base font-medium text-gray-400  bg-gray-900 border border-gray-400 rounded-lg hover:bg-gray-800 hover:text-white\"\n        phx-click={@event_previous}\n      >\n        Previous page\n      </button>\n      <button\n        :if={@page < @total_pages}\n        class=\"flex items-center justify-center px-4 h-10 text-base font-medium text-gray-400 bg-gray-900 border border-gray-400 rounded-lg hover:bg-gray-800 hover:text-white\"\n        phx-click={@event_next}\n      >\n        Next page\n      </button>\n    </div>\n    \"\"\"\n  end\n\n  attr :title, :string\n  attr :title_class, :string, default: nil\n  attr :rest, :global\n\n  slot :inner_block, required: true\n\n  def section(assigns) do\n    ~H\"\"\"\n    <div>\n      <h2\n        :if={assigns[:title]}\n        class={[\"text-sm font-semibold mb-2 uppercase text-gray-400\", @title_class]}\n      >\n        {@title}\n      </h2>\n      {render_slot(@inner_block)}\n    </div>\n    \"\"\"\n  end\n\n  attr :name, :string, values: ~w[bell bell-slash arrow-left arrow-right]\n\n  def icon(%{name: \"bell\"} = assigns) do\n    ~H\"\"\"\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      viewBox=\"0 0 16 16\"\n      fill=\"currentColor\"\n      class=\"!h-4 !w-4 inline-block\"\n    >\n      <path\n        fill-rule=\"evenodd\"\n        d=\"M12 5a4 4 0 0 0-8 0v2.379a1.5 1.5 0 0 1-.44 1.06L2.294 9.707a1 1 0 0 0-.293.707V11a1 1 0 0 0 1 1h2a3 3 0 1 0 6 0h2a1 1 0 0 0 1-1v-.586a1 1 0 0 0-.293-.707L12.44 8.44A1.5 1.5 0 0 1 12 7.38V5Zm-5.5 7a1.5 1.5 0 0 0 3 0h-3Z\"\n        clip-rule=\"evenodd\"\n      />\n    </svg>\n    \"\"\"\n  end\n\n  def icon(%{name: \"bell-slash\"} = assigns) do\n    ~H\"\"\"\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      viewBox=\"0 0 16 16\"\n      fill=\"currentColor\"\n      class=\"!h-4 !w-4 inline-block\"\n    >\n      <path\n        fill-rule=\"evenodd\"\n        d=\"M4 7.379v-.904l6.743 6.742A3 3 0 0 1 5 12H3a1 1 0 0 1-1-1v-.586a1 1 0 0 1 .293-.707L3.56 8.44A1.5 1.5 0 0 0 4 7.38ZM6.5 12a1.5 1.5 0 0 0 3 0h-3Z\"\n        clip-rule=\"evenodd\"\n      />\n      <path d=\"M14 11a.997.997 0 0 1-.096.429L4.92 2.446A4 4 0 0 1 12 5v2.379c0 .398.158.779.44 1.06l1.267 1.268a1 1 0 0 1 .293.707V11ZM2.22 2.22a.75.75 0 0 1 1.06 0l10.5 10.5a.75.75 0 1 1-1.06 1.06L2.22 3.28a.75.75 0 0 1 0-1.06Z\" />\n    </svg>\n    \"\"\"\n  end\n\n  def icon(%{name: \"arrow-left\"} = assigns) do\n    ~H\"\"\"\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      viewBox=\"0 0 16 16\"\n      fill=\"currentColor\"\n      class=\"!h-4 !w-4 inline-block\"\n    >\n      <path\n        fill-rule=\"evenodd\"\n        d=\"M14 8a.75.75 0 0 1-.75.75H4.56l3.22 3.22a.75.75 0 1 1-1.06 1.06l-4.5-4.5a.75.75 0 0 1 0-1.06l4.5-4.5a.75.75 0 0 1 1.06 1.06L4.56 7.25h8.69A.75.75 0 0 1 14 8Z\"\n        clip-rule=\"evenodd\"\n      />\n    </svg>\n    \"\"\"\n  end\n\n  def icon(%{name: \"arrow-right\"} = assigns) do\n    ~H\"\"\"\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      viewBox=\"0 0 16 16\"\n      fill=\"currentColor\"\n      class=\"!h-4 !w-4 inline-block\"\n    >\n      <path\n        fill-rule=\"evenodd\"\n        d=\"M2 8a.75.75 0 0 1 .75-.75h8.69L8.22 4.03a.75.75 0 0 1 1.06-1.06l4.5 4.5a.75.75 0 0 1 0 1.06l-4.5 4.5a.75.75 0 0 1-1.06-1.06l3.22-3.22H2.75A.75.75 0 0 1 2 8Z\"\n        clip-rule=\"evenodd\"\n      />\n    </svg>\n    \"\"\"\n  end\nend\n"
  },
  {
    "path": "lib/error_tracker/web/components/layouts/live.html.heex",
    "content": "<.navbar id=\"navbar\" {assigns} />\n<main class=\"container px-4 mx-auto mt-4 mb-4\">\n  {@inner_content}\n</main>\n"
  },
  {
    "path": "lib/error_tracker/web/components/layouts/root.html.heex",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <meta name=\"csrf-token\" content={get_csrf_token()} />\n    <meta name=\"live-path\" content={get_socket_config(:path)} />\n    <meta name=\"live-transport\" content={get_socket_config(:transport)} />\n\n    <title>{assigns[:page_title] || \"🐛 ErrorTracker\"}</title>\n\n    <style nonce={@csp_nonces[:style]}>\n      <%= raw get_content(:css) %>\n    </style>\n    <script nonce={@csp_nonces[:script]}>\n      <%= raw get_content(:js) %>\n    </script>\n  </head>\n\n  <body class=\"bg-gray-800 text-white\">\n    {@inner_content}\n  </body>\n</html>\n"
  },
  {
    "path": "lib/error_tracker/web/components/layouts.ex",
    "content": "defmodule ErrorTracker.Web.Layouts do\n  @moduledoc false\n  use ErrorTracker.Web, :html\n\n  phoenix_js_paths =\n    for app <- ~w[phoenix phoenix_html phoenix_live_view]a do\n      path = Application.app_dir(app, [\"priv\", \"static\", \"#{app}.js\"])\n      Module.put_attribute(__MODULE__, :external_resource, path)\n      path\n    end\n\n  @static_path Application.app_dir(:error_tracker, [\"priv\", \"static\"])\n  @external_resource css_path = Path.join(@static_path, \"app.css\")\n  @external_resource js_path = Path.join(@static_path, \"app.js\")\n\n  @css File.read!(css_path)\n\n  @js \"\"\"\n  #{for path <- phoenix_js_paths, do: path |> File.read!() |> String.replace(\"//# sourceMappingURL=\", \"// \")}\n  #{File.read!(js_path)}\n  \"\"\"\n\n  @default_socket_config %{path: \"/live\", transport: :websocket}\n\n  embed_templates \"layouts/*\"\n\n  def get_content(:css), do: @css\n  def get_content(:js), do: @js\n\n  def get_socket_config(key) do\n    default = Map.get(@default_socket_config, key)\n    config = Application.get_env(:error_tracker, :live_view_socket, [])\n    Keyword.get(config, key, default)\n  end\n\n  def navbar(assigns) do\n    ~H\"\"\"\n    <nav class=\"border-gray-400 bg-gray-900\">\n      <div class=\"container flex flex-wrap items-center justify-between mx-auto p-4\">\n        <.link\n          href={dashboard_path(@socket)}\n          class=\"self-center text-2xl font-semibold whitespace-nowrap text-white\"\n        >\n          <span class=\"mr-2\">🐛</span>ErrorTracker\n        </.link>\n        <button\n          type=\"button\"\n          class=\"inline-flex items-center p-2 w-10 h-10 justify-center text-sm rounded -lg md:hidden focus:outline-none focus:ring-2 text-gray-400 hover:bg-gray-700 focus:ring-gray-500\"\n          aria-controls=\"navbar-main\"\n          aria-expanded=\"false\"\n          phx-click={JS.toggle(to: \"#navbar-main\")}\n        >\n          <span class=\"sr-only\">Open main menu</span>\n          <svg\n            class=\"w-5 h-5\"\n            aria-hidden=\"true\"\n            xmlns=\"http://www.w3.org/2000/svg\"\n            fill=\"none\"\n            viewBox=\"0 0 17 14\"\n          >\n            <path\n              stroke=\"currentColor\"\n              stroke-linecap=\"round\"\n              stroke-linejoin=\"round\"\n              stroke-width=\"2\"\n              d=\"M1 1h15M1 7h15M1 13h15\"\n            />\n          </svg>\n        </button>\n        <div class=\"hidden w-full md:block md:w-auto\" id=\"navbar-main\">\n          <ul class=\"font-medium flex flex-col p-4 md:p-0 mt-4 border border-gray-400 bg-gray-900 rounded-lg md:flex-row md:space-x-8 rtl:space-x-reverse md:mt-0 md:border-0 md:bg-gray-800\">\n            <.navbar_item to=\"https://github.com/elixir-error-tracker/error-tracker\" target=\"_blank\">\n              <svg\n                width=\"18\"\n                height=\"18\"\n                aria-hidden=\"true\"\n                viewBox=\"0 0 24 24\"\n                version=\"1.1\"\n                class=\"inline-block mr-1 align-text-top\"\n              >\n                <path\n                  fill=\"currentColor\"\n                  d=\"M12.5.75C6.146.75 1 5.896 1 12.25c0 5.089 3.292 9.387 7.863 10.91.575.101.79-.244.79-.546 0-.273-.014-1.178-.014-2.142-2.889.532-3.636-.704-3.866-1.35-.13-.331-.69-1.352-1.18-1.625-.402-.216-.977-.748-.014-.762.906-.014 1.553.834 1.769 1.179 1.035 1.74 2.688 1.25 3.349.948.1-.747.402-1.25.733-1.538-2.559-.287-5.232-1.279-5.232-5.678 0-1.25.445-2.285 1.178-3.09-.115-.288-.517-1.467.115-3.048 0 0 .963-.302 3.163 1.179.92-.259 1.897-.388 2.875-.388.977 0 1.955.13 2.875.388 2.2-1.495 3.162-1.179 3.162-1.179.633 1.581.23 2.76.115 3.048.733.805 1.179 1.825 1.179 3.09 0 4.413-2.688 5.39-5.247 5.678.417.36.776 1.05.776 2.128 0 1.538-.014 2.774-.014 3.162 0 .302.216.662.79.547C20.709 21.637 24 17.324 24 12.25 24 5.896 18.854.75 12.5.75Z\"\n                >\n                </path>\n              </svg>\n              GitHub\n            </.navbar_item>\n          </ul>\n        </div>\n      </div>\n    </nav>\n    \"\"\"\n  end\n\n  attr :to, :string, required: true\n  attr :rest, :global\n\n  slot :inner_block, required: true\n\n  def navbar_item(assigns) do\n    ~H\"\"\"\n    <li>\n      <a\n        href={@to}\n        class=\"whitespace-nowrap flex-0 block py-2 px-3 rounded-lg text-white hover:text-white hover:bg-gray-700 md:hover:bg-transparent md:border-0 md:hover:text-sky-500\"\n        {@rest}\n      >\n        {render_slot(@inner_block)}\n      </a>\n    </li>\n    \"\"\"\n  end\nend\n"
  },
  {
    "path": "lib/error_tracker/web/helpers.ex",
    "content": "defmodule ErrorTracker.Web.Helpers do\n  @moduledoc false\n\n  @doc false\n  def sanitize_module(<<\"Elixir.\", str::binary>>), do: str\n  def sanitize_module(str), do: str\n\n  @doc false\n  def format_datetime(%DateTime{} = dt), do: Calendar.strftime(dt, \"%c %Z\")\nend\n"
  },
  {
    "path": "lib/error_tracker/web/hooks/set_assigns.ex",
    "content": "defmodule ErrorTracker.Web.Hooks.SetAssigns do\n  @moduledoc false\n\n  import Phoenix.Component, only: [assign: 2]\n\n  def on_mount({:set_dashboard_path, path}, _params, session, socket) do\n    socket = %{socket | private: Map.put(socket.private, :dashboard_path, path)}\n\n    {:cont, assign(socket, csp_nonces: session[\"csp_nonces\"])}\n  end\nend\n"
  },
  {
    "path": "lib/error_tracker/web/live/dashboard.ex",
    "content": "defmodule ErrorTracker.Web.Live.Dashboard do\n  @moduledoc false\n\n  use ErrorTracker.Web, :live_view\n\n  import Ecto.Query\n\n  alias ErrorTracker.Error\n  alias ErrorTracker.Repo\n  alias ErrorTracker.Web.Search\n\n  @per_page 10\n\n  @impl Phoenix.LiveView\n  def handle_params(params, uri, socket) do\n    path = struct(URI, uri |> URI.parse() |> Map.take([:path, :query]))\n\n    {:noreply,\n     socket\n     |> assign(\n       path: path,\n       search: Search.from_params(params),\n       page: 1,\n       search_form: Search.to_form(params)\n     )\n     |> paginate_errors()}\n  end\n\n  @impl Phoenix.LiveView\n  def handle_event(\"search\", params, socket) do\n    search = Search.from_params(params[\"search\"] || %{})\n\n    %URI{} = path = socket.assigns.path\n    path_w_filters = %{path | query: URI.encode_query(search)}\n\n    {:noreply, push_patch(socket, to: URI.to_string(path_w_filters))}\n  end\n\n  @impl Phoenix.LiveView\n  def handle_event(\"next-page\", _params, socket) do\n    {:noreply, socket |> assign(page: socket.assigns.page + 1) |> paginate_errors()}\n  end\n\n  @impl Phoenix.LiveView\n  def handle_event(\"prev-page\", _params, socket) do\n    {:noreply, socket |> assign(page: socket.assigns.page - 1) |> paginate_errors()}\n  end\n\n  @impl Phoenix.LiveView\n  def handle_event(\"resolve\", %{\"error_id\" => id}, socket) do\n    error = Repo.get(Error, id)\n    {:ok, _resolved} = ErrorTracker.resolve(error)\n\n    {:noreply, paginate_errors(socket)}\n  end\n\n  @impl Phoenix.LiveView\n  def handle_event(\"unresolve\", %{\"error_id\" => id}, socket) do\n    error = Repo.get(Error, id)\n    {:ok, _unresolved} = ErrorTracker.unresolve(error)\n\n    {:noreply, paginate_errors(socket)}\n  end\n\n  @impl Phoenix.LiveView\n  def handle_event(\"mute\", %{\"error_id\" => id}, socket) do\n    error = Repo.get(Error, id)\n    {:ok, _muted} = ErrorTracker.mute(error)\n\n    {:noreply, paginate_errors(socket)}\n  end\n\n  @impl Phoenix.LiveView\n  def handle_event(\"unmute\", %{\"error_id\" => id}, socket) do\n    error = Repo.get(Error, id)\n    {:ok, _unmuted} = ErrorTracker.unmute(error)\n\n    {:noreply, paginate_errors(socket)}\n  end\n\n  defp paginate_errors(socket) do\n    %{page: page, search: search} = socket.assigns\n    offset = (page - 1) * @per_page\n    query = filter(Error, search)\n\n    total_errors = Repo.aggregate(query, :count)\n\n    errors =\n      Repo.all(\n        from query,\n          order_by: [desc: :last_occurrence_at],\n          offset: ^offset,\n          limit: @per_page\n      )\n\n    error_ids = Enum.map(errors, & &1.id)\n\n    occurrences =\n      if errors == [] do\n        []\n      else\n        errors\n        |> Ecto.assoc(:occurrences)\n        |> where([o], o.error_id in ^error_ids)\n        |> group_by([o], o.error_id)\n        |> select([o], {o.error_id, count(o.id)})\n        |> Repo.all()\n      end\n\n    assign(socket,\n      errors: errors,\n      occurrences: Map.new(occurrences),\n      total_pages: (total_errors / @per_page) |> Float.ceil() |> trunc()\n    )\n  end\n\n  defp filter(query, search) do\n    Enum.reduce(search, query, &do_filter/2)\n  end\n\n  defp do_filter({:status, status}, query) do\n    where(query, [error], error.status == ^status)\n  end\n\n  defp do_filter({field, value}, query) do\n    # Postgres provides the ILIKE operator which produces a case-insensitive match between two\n    # strings. SQLite3 only supports LIKE, which is case-insensitive for ASCII characters.\n    Repo.with_adapter(fn\n      :postgres -> where(query, [error], ilike(field(error, ^field), ^\"%#{value}%\"))\n      :mysql -> where(query, [error], like(field(error, ^field), ^\"%#{value}%\"))\n      :sqlite -> where(query, [error], like(field(error, ^field), ^\"%#{value}%\"))\n    end)\n  end\nend\n"
  },
  {
    "path": "lib/error_tracker/web/live/dashboard.html.heex",
    "content": "<.form\n  for={@search_form}\n  id=\"search\"\n  class=\"mb-4 text-black grid md:grid-cols-4 grid-cols-2 gap-2\"\n  phx-change=\"search\"\n>\n  <input\n    name={@search_form[:reason].name}\n    value={@search_form[:reason].value}\n    type=\"text\"\n    placeholder=\"Error\"\n    class=\"border text-sm rounded-lg block p-2.5 bg-gray-700 border-gray-600 placeholder-gray-400 text-white focus:ring-blue-500 focus:border-blue-500\"\n    phx-debounce\n  />\n  <input\n    name={@search_form[:source_line].name}\n    value={@search_form[:source_line].value}\n    type=\"text\"\n    placeholder=\"Source line\"\n    class=\"border text-sm rounded-lg block p-2.5 bg-gray-700 border-gray-600 placeholder-gray-400 text-white focus:ring-blue-500 focus:border-blue-500\"\n    phx-debounce\n  />\n  <input\n    name={@search_form[:source_function].name}\n    value={@search_form[:source_function].value}\n    type=\"text\"\n    placeholder=\"Source function\"\n    class=\"border text-sm rounded-lg block p-2.5 bg-gray-700 border-gray-600 placeholder-gray-400 text-white focus:ring-blue-500 focus:border-blue-500\"\n    phx-debounce\n  />\n  <select\n    name={@search_form[:status].name}\n    class=\"border text-sm rounded-lg block p-2.5 bg-gray-700 border-gray-600 placeholder-gray-400 text-white focus:ring-blue-500 focus:border-blue-500\"\n  >\n    <option value=\"\" selected={@search_form[:status].value == \"\"}>All</option>\n    <option value=\"unresolved\" selected={@search_form[:status].value == \"unresolved\"}>\n      Unresolved\n    </option>\n    <option value=\"resolved\" selected={@search_form[:status].value == \"resolved\"}>\n      Resolved\n    </option>\n  </select>\n</.form>\n\n<div class=\"relative overflow-x-auto shadow-md sm:rounded-lg ring-1 ring-gray-900\">\n  <table class=\"w-full text-sm text-left rtl:text-right text-gray-400 table-fixed\">\n    <thead class=\"text-xs uppercase bg-gray-900\">\n      <tr>\n        <th scope=\"col\" class=\"px-4 pr-2 w-72\">Error</th>\n        <th scope=\"col\" class=\"px-4 py-3 w-72\">Occurrences</th>\n        <th scope=\"col\" class=\"px-4 py-3 w-28\">Status</th>\n        <th scope=\"col\" class=\"px-4 py-3 w-28\"></th>\n      </tr>\n    </thead>\n    <tbody>\n      <tr>\n        <td :if={@errors == []} colspan=\"4\" class=\"text-center py-8 font-extralight\">\n          No errors to show 🎉\n        </td>\n      </tr>\n      <tr\n        :for={error <- @errors}\n        class=\"border-b bg-gray-400/10 border-y border-gray-900 hover:bg-gray-800/60 last-of-type:border-b-0\"\n      >\n        <td scope=\"row\" class=\"px-4 py-4 font-medium text-white relative\">\n          <.link navigate={error_path(@socket, error, @search)} class=\"absolute inset-1\">\n            <span class=\"sr-only\">({sanitize_module(error.kind)}) {error.reason}</span>\n          </.link>\n          <p class=\"whitespace-nowrap text-ellipsis overflow-hidden\">\n            ({sanitize_module(error.kind)}) {error.reason}\n          </p>\n          <p\n            :if={ErrorTracker.Error.has_source_info?(error)}\n            class=\"whitespace-nowrap text-ellipsis overflow-hidden font-normal text-gray-400\"\n          >\n            {sanitize_module(error.source_function)}\n            <br />\n            {error.source_line}\n          </p>\n        </td>\n        <td class=\"px-4 py-4\">\n          <p>Last: {format_datetime(error.last_occurrence_at)}</p>\n          <p>Total: {@occurrences[error.id]}</p>\n        </td>\n        <td class=\"px-4 py-4\">\n          <.badge :if={error.status == :resolved} color={:green}>Resolved</.badge>\n          <.badge :if={error.status == :unresolved} color={:red}>Unresolved</.badge>\n        </td>\n        <td class=\"px-4 py-4 text-center\">\n          <div class=\"flex justify-between\">\n            <.button\n              :if={error.status == :unresolved}\n              phx-click=\"resolve\"\n              phx-value-error_id={error.id}\n            >\n              Resolve\n            </.button>\n\n            <.button\n              :if={error.status == :resolved}\n              phx-click=\"unresolve\"\n              phx-value-error_id={error.id}\n            >\n              Unresolve\n            </.button>\n\n            <.button :if={!error.muted} phx-click=\"mute\" type=\"link\" phx-value-error_id={error.id}>\n              <.icon name=\"bell-slash\" /> Mute\n            </.button>\n\n            <.button\n              :if={error.muted}\n              phx-click=\"unmute\"\n              type=\"link\"\n              phx-value-error_id={error.id}\n            >\n              <.icon name=\"bell\" /> Unmute\n            </.button>\n          </div>\n        </td>\n      </tr>\n    </tbody>\n  </table>\n</div>\n\n<.pagination page={@page} total_pages={@total_pages} />\n"
  },
  {
    "path": "lib/error_tracker/web/live/show.ex",
    "content": "defmodule ErrorTracker.Web.Live.Show do\n  @moduledoc false\n  use ErrorTracker.Web, :live_view\n\n  import Ecto.Query\n\n  alias ErrorTracker.Error\n  alias ErrorTracker.Occurrence\n  alias ErrorTracker.Repo\n  alias ErrorTracker.Web.Search\n\n  @occurrences_to_navigate 50\n\n  @impl Phoenix.LiveView\n  def mount(%{\"id\" => id} = params, _session, socket) do\n    error = Repo.get!(Error, id)\n\n    {:ok,\n     assign(socket,\n       error: error,\n       app: Application.fetch_env!(:error_tracker, :otp_app),\n       search: Search.from_params(params)\n     )}\n  end\n\n  @impl Phoenix.LiveView\n  def handle_params(params, _uri, socket) do\n    occurrence =\n      if occurrence_id = params[\"occurrence_id\"] do\n        socket.assigns.error\n        |> Ecto.assoc(:occurrences)\n        |> Repo.get!(occurrence_id)\n      else\n        socket.assigns.error\n        |> Ecto.assoc(:occurrences)\n        |> order_by([o], desc: o.id)\n        |> limit(1)\n        |> Repo.one()\n      end\n\n    socket =\n      socket\n      |> assign(occurrence: occurrence)\n      |> load_related_occurrences()\n\n    {:noreply, socket}\n  end\n\n  @impl Phoenix.LiveView\n  def handle_event(\"occurrence_navigation\", %{\"occurrence_id\" => id}, socket) do\n    occurrence_path =\n      occurrence_path(\n        socket,\n        %Occurrence{error_id: socket.assigns.error.id, id: id},\n        socket.assigns.search\n      )\n\n    {:noreply, push_patch(socket, to: occurrence_path)}\n  end\n\n  @impl Phoenix.LiveView\n  def handle_event(\"resolve\", _params, socket) do\n    {:ok, updated_error} = ErrorTracker.resolve(socket.assigns.error)\n\n    {:noreply, assign(socket, :error, updated_error)}\n  end\n\n  @impl Phoenix.LiveView\n  def handle_event(\"unresolve\", _params, socket) do\n    {:ok, updated_error} = ErrorTracker.unresolve(socket.assigns.error)\n\n    {:noreply, assign(socket, :error, updated_error)}\n  end\n\n  @impl Phoenix.LiveView\n  def handle_event(\"mute\", _params, socket) do\n    {:ok, updated_error} = ErrorTracker.mute(socket.assigns.error)\n\n    {:noreply, assign(socket, :error, updated_error)}\n  end\n\n  @impl Phoenix.LiveView\n  def handle_event(\"unmute\", _params, socket) do\n    {:ok, updated_error} = ErrorTracker.unmute(socket.assigns.error)\n\n    {:noreply, assign(socket, :error, updated_error)}\n  end\n\n  defp load_related_occurrences(socket) do\n    current_occurrence = socket.assigns.occurrence\n    base_query = Ecto.assoc(socket.assigns.error, :occurrences)\n\n    half_limit = floor(@occurrences_to_navigate / 2)\n\n    previous_occurrences_query = where(base_query, [o], o.id < ^current_occurrence.id)\n    next_occurrences_query = where(base_query, [o], o.id > ^current_occurrence.id)\n    previous_count = Repo.aggregate(previous_occurrences_query, :count)\n    next_count = Repo.aggregate(next_occurrences_query, :count)\n\n    {previous_limit, next_limit} =\n      cond do\n        previous_count < half_limit and next_count < half_limit ->\n          {previous_count, next_count}\n\n        previous_count < half_limit ->\n          {previous_count, @occurrences_to_navigate - previous_count - 1}\n\n        next_count < half_limit ->\n          {@occurrences_to_navigate - next_count - 1, next_count}\n\n        true ->\n          {half_limit, half_limit}\n      end\n\n    occurrences =\n      [\n        related_occurrences(next_occurrences_query, next_limit),\n        current_occurrence,\n        related_occurrences(previous_occurrences_query, previous_limit)\n      ]\n      |> List.flatten()\n      |> Enum.reverse()\n\n    total_occurrences =\n      socket.assigns.error\n      |> Ecto.assoc(:occurrences)\n      |> Repo.aggregate(:count)\n\n    next_occurrence =\n      base_query\n      |> where([o], o.id > ^current_occurrence.id)\n      |> order_by([o], asc: o.id)\n      |> limit(1)\n      |> select([:id, :error_id, :inserted_at])\n      |> Repo.one()\n\n    prev_occurrence =\n      base_query\n      |> where([o], o.id < ^current_occurrence.id)\n      |> order_by([o], desc: o.id)\n      |> limit(1)\n      |> select([:id, :error_id, :inserted_at])\n      |> Repo.one()\n\n    socket\n    |> assign(:occurrences, occurrences)\n    |> assign(:total_occurrences, total_occurrences)\n    |> assign(:next, next_occurrence)\n    |> assign(:prev, prev_occurrence)\n  end\n\n  defp related_occurrences(query, num_results) do\n    query\n    |> order_by([o], desc: o.id)\n    |> select([:id, :error_id, :inserted_at])\n    |> limit(^num_results)\n    |> Repo.all()\n  end\nend\n"
  },
  {
    "path": "lib/error_tracker/web/live/show.html.heex",
    "content": "<div class=\"my-6\">\n  <.link navigate={dashboard_path(@socket, @search)}>\n    <.icon name=\"arrow-left\" /> Back to the dashboard\n  </.link>\n</div>\n\n<div id=\"header\">\n  <p class=\"text-sm uppercase font-semibold text-gray-400\">\n    Error #{@error.id} @ {format_datetime(@occurrence.inserted_at)}\n  </p>\n  <h1 class=\"my-1 text-2xl w-full font-semibold whitespace-nowrap text-ellipsis overflow-hidden\">\n    ({sanitize_module(@error.kind)}) {@error.reason\n    |> String.replace(\"\\n\", \" \")\n    |> String.trim()}\n  </h1>\n</div>\n\n<div class=\"grid grid-cols-1 md:grid-cols-4 md:space-x-3 mt-6 gap-2\">\n  <div class=\"md:col-span-3 md:border-r md:border-gray-600 space-y-8 pr-5\">\n    <.section title=\"Full message\">\n      <pre class=\"overflow-auto p-4 rounded-lg bg-gray-300/10 border border-gray-900\"><%= @occurrence.reason %></pre>\n    </.section>\n\n    <.section :if={ErrorTracker.Error.has_source_info?(@error)} title=\"Source\">\n      <pre class=\"overflow-auto text-sm p-4 rounded-lg bg-gray-300/10 border border-gray-900\">\n        <%= sanitize_module(@error.source_function) %>\n        <%= @error.source_line %></pre>\n    </.section>\n\n    <.section :if={@occurrence.breadcrumbs != []} title=\"Bread crumbs\">\n      <div class=\"relative overflow-x-auto shadow-md sm:rounded-lg ring-1 ring-gray-900\">\n        <table class=\"w-full text-sm text-gray-400 table-fixed\">\n          <tr\n            :for={\n              {breadcrumb, index} <-\n                @occurrence.breadcrumbs |> Enum.reverse() |> Enum.with_index()\n            }\n            class=\"border-b bg-gray-400/10 border-gray-900 last:border-b-0\"\n          >\n            <td class=\"w-11 pl-2 py-4 font-medium text-white relative text-right\">\n              {length(@occurrence.breadcrumbs) - index}.\n            </td>\n            <td class=\"px-2 py-4 font-medium text-white relative\">{breadcrumb}</td>\n          </tr>\n        </table>\n      </div>\n    </.section>\n\n    <.section :if={@occurrence.stacktrace.lines != []} title=\"Stacktrace\">\n      <div class=\"p-4 bg-gray-300/10 border border-gray-900 rounded-lg\">\n        <div class=\"w-full mb-4\">\n          <label class=\"flex justify-end\">\n            <input\n              type=\"checkbox\"\n              id=\"show-app-frames\"\n              class=\"ml-2 mr-2 mb-1 mt-1 inline-block text-sky-600 rounded focus:ring-sky-600 ring-offset-gray-800 focus:ring-2 bg-gray-700 border-gray-600\"\n              phx-click={JS.toggle(to: \"#stacktrace tr:not([data-app=#{@app}])\")}\n            />\n            <span class=\"text-md inline-block\">\n              Show only app frames\n            </span>\n          </label>\n        </div>\n\n        <div class=\"overflow-auto\">\n          <table class=\"w-100 text-sm\" id=\"stacktrace\">\n            <tbody>\n              <tr :for={line <- @occurrence.stacktrace.lines} data-app={line.application || @app}>\n                <td class=\"px-2 align-top\"><pre>(<%= line.application || @app %>)</pre></td>\n                <td>\n                  <pre><%= \"#{sanitize_module(line.module)}.#{line.function}/#{line.arity}\" %>\n                <%= if line.line, do: \"#{line.file}:#{line.line}\", else: \"(nofile)\" %></pre>\n                </td>\n              </tr>\n            </tbody>\n          </table>\n        </div>\n      </div>\n    </.section>\n\n    <.section title=\"Context\">\n      <pre\n        id=\"context\"\n        class=\"overflow-auto text-sm p-4 rounded-lg bg-gray-300/10 border border-gray-900\"\n        phx-hook=\"JsonPrettyPrint\"\n      >\n        <%= ErrorTracker.__default_json_encoder__().encode_to_iodata!(@occurrence.context) %>\n      </pre>\n    </.section>\n  </div>\n\n  <div class=\"px-3 md:pl-0 space-y-8\">\n    <.section title={\"Occurrence (#{@total_occurrences} total)\"}>\n      <form phx-change=\"occurrence_navigation\">\n        <select\n          name=\"occurrence_id\"\n          class=\"w-full border text-sm rounded-lg block p-2.5 bg-gray-700 border-gray-600 placeholder-gray-400 text-white focus:ring-blue-500 focus:border-blue-500\"\n        >\n          <option\n            :for={occurrence <- @occurrences}\n            value={occurrence.id}\n            selected={occurrence.id == @occurrence.id}\n          >\n            {format_datetime(occurrence.inserted_at)}\n          </option>\n        </select>\n      </form>\n\n      <nav class=\"grid grid-cols-2 gap-2 mt-2\">\n        <div class=\"text-left\">\n          <.link :if={@prev} patch={occurrence_path(@socket, @prev, @search)}>\n            <.icon name=\"arrow-left\" /> Prev\n          </.link>\n        </div>\n        <div class=\"text-right\">\n          <.link :if={@next} patch={occurrence_path(@socket, @next, @search)}>\n            Next <.icon name=\"arrow-right\" />\n          </.link>\n        </div>\n      </nav>\n    </.section>\n\n    <.section title=\"Error kind\">\n      <pre><%= sanitize_module(@error.kind) %></pre>\n    </.section>\n\n    <.section title=\"Last seen\">\n      <pre><%= format_datetime(@error.last_occurrence_at) %></pre>\n    </.section>\n\n    <.section title=\"First seen\">\n      <pre><%= format_datetime(@error.inserted_at) %></pre>\n    </.section>\n\n    <.section title=\"Status\" title_class=\"mb-3\">\n      <.badge :if={@error.status == :resolved} color={:green}>Resolved</.badge>\n      <.badge :if={@error.status == :unresolved} color={:red}>Unresolved</.badge>\n    </.section>\n\n    <.section>\n      <div class=\"flex flex-col gap-y-4\">\n        <.button :if={@error.status == :unresolved} phx-click=\"resolve\">\n          Mark as resolved\n        </.button>\n\n        <.button :if={@error.status == :resolved} phx-click=\"unresolve\">\n          Mark as unresolved\n        </.button>\n\n        <.button :if={!@error.muted} phx-click=\"mute\" type=\"link\">\n          <.icon name=\"bell-slash\" /> Mute\n        </.button>\n\n        <.button :if={@error.muted} phx-click=\"unmute\" type=\"link\">\n          <.icon name=\"bell\" /> Unmute\n        </.button>\n      </div>\n    </.section>\n  </div>\n</div>\n"
  },
  {
    "path": "lib/error_tracker/web/router/routes.ex",
    "content": "defmodule ErrorTracker.Web.Router.Routes do\n  @moduledoc false\n\n  alias ErrorTracker.Error\n  alias ErrorTracker.Occurrence\n  alias Phoenix.LiveView.Socket\n\n  @doc \"\"\"\n  Returns the dashboard path\n  \"\"\"\n  def dashboard_path(%Socket{} = socket, params \\\\ %{}) do\n    socket\n    |> dashboard_uri(params)\n    |> URI.to_string()\n  end\n\n  @doc \"\"\"\n  Returns the path to see the details of an error\n  \"\"\"\n  def error_path(%Socket{} = socket, %Error{id: id}, params \\\\ %{}) do\n    socket\n    |> dashboard_uri(params)\n    |> URI.append_path(\"/#{id}\")\n    |> URI.to_string()\n  end\n\n  @doc \"\"\"\n  Returns the path to see the details of an occurrence\n  \"\"\"\n  def occurrence_path(%Socket{} = socket, %Occurrence{id: id, error_id: error_id}, params \\\\ %{}) do\n    socket\n    |> dashboard_uri(params)\n    |> URI.append_path(\"/#{error_id}/#{id}\")\n    |> URI.to_string()\n  end\n\n  defp dashboard_uri(%Socket{} = socket, params) do\n    %URI{\n      path: socket.private[:dashboard_path],\n      query: if(Enum.any?(params), do: URI.encode_query(params))\n    }\n  end\nend\n"
  },
  {
    "path": "lib/error_tracker/web/router.ex",
    "content": "defmodule ErrorTracker.Web.Router do\n  @moduledoc \"\"\"\n  ErrorTracker UI integration into your application's router.\n  \"\"\"\n\n  alias ErrorTracker.Web.Hooks.SetAssigns\n\n  @doc \"\"\"\n  Creates the routes needed to use the `ErrorTracker` web interface.\n\n  It requires a path in which you are going to serve the web interface.\n\n  In order to work properly, the route should be in a scope with CSRF protection\n  (usually the `:browser` pipeline).\n\n  ## Security considerations\n\n  The dashboard inlines both the JS and CSS assets. This means that, if your\n  application has a Content Security Policy, you need to specify the\n  `csp_nonce_assign_key` option, which is explained below.\n\n  ## Options\n\n  * `on_mount`: a list of mount hooks to use before invoking the dashboard\n  LiveView views.\n\n  * `as`: a session name to use for the dashboard LiveView session. By default\n  it uses `:error_tracker_dashboard`.\n\n  * `csp_nonce_assign_key`: an assign key to find the CSP nonce value used for assets.\n  Supports either `atom()` or a map of type\n  `%{optional(:img) => atom(), optional(:script) => atom(), optional(:style) => atom()}`\n  \"\"\"\n  defmacro error_tracker_dashboard(path, opts \\\\ []) do\n    quote bind_quoted: [path: path, opts: opts] do\n      # Ensure that the given path includes previous scopes so we can generate proper\n      # paths for navigating through the dashboard.\n      scoped_path = Phoenix.Router.scoped_path(__MODULE__, path)\n      # Generate the session name and session hooks.\n      {session_name, session_opts} = ErrorTracker.Web.Router.__parse_options__(opts, scoped_path)\n\n      scope path, alias: false, as: false do\n        import Phoenix.LiveView.Router, only: [live: 4, live_session: 3]\n\n        alias ErrorTracker.Web.Live.Show\n\n        live_session session_name, session_opts do\n          live \"/\", ErrorTracker.Web.Live.Dashboard, :index, as: session_name\n          live \"/:id\", Show, :show, as: session_name\n          live \"/:id/:occurrence_id\", Show, :show, as: session_name\n        end\n      end\n    end\n  end\n\n  @doc false\n  def __parse_options__(opts, path) do\n    custom_on_mount = Keyword.get(opts, :on_mount, [])\n    session_name = Keyword.get(opts, :as, :error_tracker_dashboard)\n\n    csp_nonce_assign_key =\n      case opts[:csp_nonce_assign_key] do\n        nil -> nil\n        key when is_atom(key) -> %{img: key, style: key, script: key}\n        keys when is_map(keys) -> Map.take(keys, [:img, :style, :script])\n      end\n\n    session_opts = [\n      session: {__MODULE__, :__session__, [csp_nonce_assign_key]},\n      on_mount: [{SetAssigns, {:set_dashboard_path, path}}] ++ custom_on_mount,\n      root_layout: {ErrorTracker.Web.Layouts, :root}\n    ]\n\n    {session_name, session_opts}\n  end\n\n  @doc false\n  def __session__(conn, csp_nonce_assign_key) do\n    %{\n      \"csp_nonces\" => %{\n        img: conn.assigns[csp_nonce_assign_key[:img]],\n        style: conn.assigns[csp_nonce_assign_key[:style]],\n        script: conn.assigns[csp_nonce_assign_key[:script]]\n      }\n    }\n  end\nend\n"
  },
  {
    "path": "lib/error_tracker/web/search.ex",
    "content": "defmodule ErrorTracker.Web.Search do\n  @moduledoc false\n\n  @types %{\n    reason: :string,\n    source_line: :string,\n    source_function: :string,\n    status: :string\n  }\n\n  defp changeset(params) do\n    Ecto.Changeset.cast({%{}, @types}, params, Map.keys(@types))\n  end\n\n  @spec from_params(map()) :: %{atom() => String.t()}\n  def from_params(params) do\n    params |> changeset() |> Ecto.Changeset.apply_changes()\n  end\n\n  @spec to_form(map()) :: Phoenix.HTML.Form.t()\n  def to_form(params) do\n    params |> changeset() |> Phoenix.Component.to_form(as: :search)\n  end\nend\n"
  },
  {
    "path": "lib/error_tracker/web.ex",
    "content": "defmodule ErrorTracker.Web do\n  @moduledoc \"\"\"\n  ErrorTracker includes a dashboard to view and inspect errors that occurred\n  on your application and are already stored in the database.\n\n  In order to use it, you need to add the following to your Phoenix's\n  `router.ex` file:\n\n  ```elixir\n  defmodule YourAppWeb.Router do\n    use Phoenix.Router\n    use ErrorTracker.Web, :router\n\n    ...\n\n    scope \"/\" do\n      ...\n\n      error_tracker_dashboard \"/errors\"\n    end\n  end\n  ```\n\n  This will add the routes needed for ErrorTracker's dashboard to work.\n\n  **Note:** when adding the dashboard routes, make sure you do it in an scope that\n  has CSRF protection (usually the `:browser` pipeline in most projects), as\n  otherwise you may experience LiveView issues like crashes and redirections.\n\n  ## Security considerations\n\n  Errors may contain sensitive information, like IP addresses, users information\n  or even passwords sent on forms!\n\n  Securing your dashboard is an important part of integrating ErrorTracker on\n  your project.\n\n  In order to do so, we recommend implementing your own security mechanisms in\n  the form of a mount hook and pass it to the `error_tracker_dashboard` macro\n  using the `on_mount` option.\n\n  You can find more details on\n  `ErrorTracker.Web.Router.error_tracker_dashboard/2`.\n\n  ### Static assets\n\n  Static assets (CSS and JS) are inlined during the compilation. If you have\n  a [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP)\n  be sure to allow inline styles and scripts.\n\n  To do this, ensure that your `style-src` and `script-src` policies include the\n  `unsafe-inline` value.\n\n  ## LiveView socket options\n\n  By default the library expects you to have your LiveView socket at `/live` and\n  using `websocket` transport.\n\n  If that's not the case, you can configure it adding the following\n  configuration to your app's config files:\n\n  ```elixir\n  config :error_tracker,\n    live_view_socket: [\n      path: \"/my-custom-live-path\"\n      transport: :longpoll # (accepted values are :longpoll or :websocket)\n    ]\n  ```\n  \"\"\"\n\n  @doc false\n  def html do\n    quote do\n      import Phoenix.Controller, only: [get_csrf_token: 0]\n\n      unquote(html_helpers())\n    end\n  end\n\n  @doc false\n  def live_view do\n    quote do\n      use Phoenix.LiveView, layout: {ErrorTracker.Web.Layouts, :live}\n\n      unquote(html_helpers())\n    end\n  end\n\n  @doc false\n  def live_component do\n    quote do\n      use Phoenix.LiveComponent\n\n      unquote(html_helpers())\n    end\n  end\n\n  @doc false\n  def router do\n    quote do\n      import ErrorTracker.Web.Router\n    end\n  end\n\n  defp html_helpers do\n    quote do\n      use Phoenix.Component\n\n      import ErrorTracker.Web.CoreComponents\n      import ErrorTracker.Web.Helpers\n      import ErrorTracker.Web.Router.Routes\n      import Phoenix.HTML\n      import Phoenix.LiveView.Helpers\n\n      alias Phoenix.LiveView.JS\n    end\n  end\n\n  defmacro __using__(which) when is_atom(which) do\n    apply(__MODULE__, which, [])\n  end\nend\n"
  },
  {
    "path": "lib/error_tracker.ex",
    "content": "defmodule ErrorTracker do\n  @moduledoc \"\"\"\n  En Elixir-based built-in error tracking solution.\n\n  The main objectives behind this project are:\n\n  * Provide a basic free error tracking solution: because tracking errors in\n  your application should be a requirement for almost any project, and helps to\n  provide quality and maintenance to your project.\n\n  * Be easy to use: by providing plug-and-play integrations, documentation and a\n  simple UI to manage your errors.\n\n  * Be as minimalistic as possible: you just need a database to store errors and\n  a Phoenix application if you want to inspect them via web. That's all.\n\n  ## Requirements\n\n  ErrorTracker requires Elixir 1.15+, Ecto 3.11+, Phoenix LiveView 0.19+, and\n  PostgreSQL, MySQL/MariaDB or SQLite3 as database.\n\n  ## Integrations\n\n  We currently include integrations for what we consider the basic stack of\n  an application: Phoenix, Plug, and Oban.\n\n  However, we may continue working in adding support for more systems and\n  libraries in the future if there is enough interest from the community.\n\n  If you want to manually report an error, you can use the `ErrorTracker.report/3` function.\n\n  ## Context\n\n  Aside from the information about each exception (kind, message, stack trace...)\n  we also store contexts.\n\n  Contexts are arbitrary maps that allow you to store extra information about an\n  exception to be able to reproduce it later.\n\n  Each integration includes a default context with useful information they\n  can gather, but aside from that, you can also add your own information. You can\n  do this in a per-process basis or in a per-call basis (or both).\n\n  There are some requirements on the type of data that can be included in the\n  context, so we recommend taking a look at `set_context/1` documentation.\n\n  **Per process**\n\n  This allows you to set a general context for the current process such as a Phoenix\n  request or an Oban job. For example, you could include the following code in your\n  authentication Plug to automatically include the user ID in any error that is\n  tracked during the Phoenix request handling.\n\n  ```elixir\n  ErrorTracker.set_context(%{user_id: conn.assigns.current_user.id})\n  ```\n\n  **Per call**\n\n  As we had seen before, you can use `ErrorTracker.report/3` to manually report an\n  error. The third parameter of this function is optional and allows you to include\n  extra context that will be tracked along with the error.\n\n  ## Breadcrumbs\n\n  Aside from contextual information, it is sometimes useful to know in which points\n  of your code the code was executed in a given request / process.\n\n  Using breadcrumbs allows you to add that information to any error generated and\n  stored on a given process / request. And if you are using `Ash` or `Splode` their\n  exceptions' breadcrumbs will be automatically populated.\n\n  If you want to add a breadcrumb in a point of your code you can do so:\n\n  ```elixir\n  ErrorTracker.add_breadcrumb(\"Executed my super secret code\")\n  ```\n\n  Breadcrumbs can be viewed in the dashboard on the details page of an occurrence.\n  \"\"\"\n\n  import Ecto.Query\n\n  alias ErrorTracker.Error\n  alias ErrorTracker.Occurrence\n  alias ErrorTracker.Repo\n  alias ErrorTracker.Telemetry\n\n  @typedoc \"\"\"\n  A map containing the relevant context for a particular error.\n  \"\"\"\n  @type context :: %{(String.t() | atom()) => any()}\n\n  @typedoc \"\"\"\n  An `Exception` or a `{kind, payload}` tuple compatible with `Exception.normalize/3`.\n  \"\"\"\n  @type exception :: Exception.t() | {:error, any()} | {Exception.non_error_kind(), any()}\n\n  @doc \"\"\"\n  Report an exception to be stored.\n\n  Returns the occurrence stored or `:noop` if the ErrorTracker is disabled by\n  configuration the exception has not been stored.\n\n  Aside from the exception, it is expected to receive the stack trace and,\n  optionally, a context map which will be merged with the current process\n  context.\n\n  Keep in mind that errors that occur in Phoenix controllers, Phoenix LiveViews\n  and Oban jobs are automatically reported. You will need this function only if you\n  want to report custom errors.\n\n  ```elixir\n  try do\n    # your code\n  catch\n    e ->\n      ErrorTracker.report(e, __STACKTRACE__)\n  end\n  ```\n\n  ## Exceptions\n\n  Exceptions can be passed in three different forms:\n\n  * An exception struct: the module of the exception is stored along with\n  the exception message.\n\n  * A `{kind, exception}` tuple in which case the information is converted to\n  an Elixir exception (if possible) and stored.\n  \"\"\"\n\n  @spec report(exception(), Exception.stacktrace(), context()) :: Occurrence.t() | :noop\n  def report(exception, stacktrace, given_context \\\\ %{}) do\n    {kind, reason} = normalize_exception(exception, stacktrace)\n    {:ok, stacktrace} = ErrorTracker.Stacktrace.new(stacktrace)\n    {:ok, error} = Error.new(kind, reason, stacktrace)\n    context = Map.merge(get_context(), given_context)\n    breadcrumbs = get_breadcrumbs() ++ exception_breadcrumbs(exception)\n\n    if enabled?() && !ignored?(error, context) do\n      sanitized_context = sanitize_context(context)\n\n      upsert_error!(error, stacktrace, sanitized_context, breadcrumbs, reason)\n    else\n      :noop\n    end\n  end\n\n  @doc \"\"\"\n  Marks an error as resolved.\n\n  If an error is marked as resolved and it happens again, it will automatically\n  appear as unresolved again.\n  \"\"\"\n  @spec resolve(Error.t()) :: {:ok, Error.t()} | {:error, Ecto.Changeset.t()}\n  def resolve(%Error{status: :unresolved} = error) do\n    changeset = Ecto.Changeset.change(error, status: :resolved)\n\n    with {:ok, updated_error} <- Repo.update(changeset) do\n      Telemetry.resolved_error(updated_error)\n      {:ok, updated_error}\n    end\n  end\n\n  @doc \"\"\"\n  Marks an error as unresolved.\n  \"\"\"\n  @spec unresolve(Error.t()) :: {:ok, Error.t()} | {:error, Ecto.Changeset.t()}\n  def unresolve(%Error{status: :resolved} = error) do\n    changeset = Ecto.Changeset.change(error, status: :unresolved)\n\n    with {:ok, updated_error} <- Repo.update(changeset) do\n      Telemetry.unresolved_error(updated_error)\n      {:ok, updated_error}\n    end\n  end\n\n  @doc \"\"\"\n  Mutes the error so new occurrences won't send telemetry events.\n\n  When an error is muted:\n  - New occurrences are still tracked and stored in the database\n  - No telemetry events are emitted for new occurrences\n  - You can still see the error and its occurrences in the web UI\n\n  This is useful for noisy errors that you want to keep tracking but don't want to\n  receive notifications about.\n  \"\"\"\n  @spec mute(Error.t()) :: {:ok, Error.t()} | {:error, Ecto.Changeset.t()}\n  def mute(%Error{} = error) do\n    changeset = Ecto.Changeset.change(error, muted: true)\n\n    Repo.update(changeset)\n  end\n\n  @doc \"\"\"\n  Unmutes the error so new occurrences will send telemetry events again.\n\n  This reverses the effect of `mute/1`, allowing telemetry events to be emitted\n  for new occurrences of this error again.\n  \"\"\"\n  @spec unmute(Error.t()) :: {:ok, Error.t()} | {:error, Ecto.Changeset.t()}\n  def unmute(%Error{} = error) do\n    changeset = Ecto.Changeset.change(error, muted: false)\n\n    Repo.update(changeset)\n  end\n\n  @doc \"\"\"\n  Sets the current process context.\n\n  The given context will be merged into the current process context. The given context\n  may override existing keys from the current process context.\n\n  ## Context depth\n\n  You can store context on more than one level of depth, but take into account\n  that the merge operation is performed on the first level.\n\n  That means that any existing data on deep levels for he current context will\n  be replaced if the first level key is received on the new contents.\n\n  ## Content serialization\n\n  The content stored on the context should be serializable using the JSON library used by the\n  application (usually `JSON` for Elixir 1.18+ and `Jason` for older versions), so it is\n  recommended to use primitive types (strings, numbers, booleans...).\n\n  If you still need to pass more complex data types to your context, please test\n  that they can be encoded to JSON or storing the errors will fail. You may need to define a\n  custom encoder for that data type if not included by default.\n  \"\"\"\n  @spec set_context(context()) :: context()\n  def set_context(params) when is_map(params) do\n    current_context = Process.get(:error_tracker_context, %{})\n\n    Process.put(:error_tracker_context, Map.merge(current_context, params))\n\n    params\n  end\n\n  @doc \"\"\"\n  Obtain the context of the current process.\n  \"\"\"\n  @spec get_context() :: context()\n  def get_context do\n    Process.get(:error_tracker_context, %{})\n  end\n\n  @doc \"\"\"\n  Adds a breadcrumb to the current process.\n\n  The new breadcrumb will be added as the most recent entry of the breadcrumbs\n  list.\n\n  ## Breadcrumbs limit\n\n  Breadcrumbs are a powerful tool that allows to add an infinite number of\n  entries. However, it is not recommended to store errors with an excessive\n  amount of breadcrumbs.\n\n  As they are stored as an array of strings under the hood, storing many\n  entries per error can lead to some delays and using extra disk space on the\n  database.\n  \"\"\"\n  @spec add_breadcrumb(String.t()) :: list(String.t())\n  def add_breadcrumb(breadcrumb) when is_binary(breadcrumb) do\n    current_breadcrumbs = Process.get(:error_tracker_breadcrumbs, [])\n    new_breadcrumbs = current_breadcrumbs ++ [breadcrumb]\n\n    Process.put(:error_tracker_breadcrumbs, new_breadcrumbs)\n\n    new_breadcrumbs\n  end\n\n  @doc \"\"\"\n  Obtain the breadcrumbs of the current process.\n  \"\"\"\n  @spec get_breadcrumbs() :: list(String.t())\n  def get_breadcrumbs do\n    Process.get(:error_tracker_breadcrumbs, [])\n  end\n\n  defp enabled? do\n    !!Application.get_env(:error_tracker, :enabled, true)\n  end\n\n  defp ignored?(error, context) do\n    ignorer = Application.get_env(:error_tracker, :ignorer)\n\n    ignorer && ignorer.ignore?(error, context)\n  end\n\n  defp sanitize_context(context) do\n    filter_mod = Application.get_env(:error_tracker, :filter)\n\n    if filter_mod,\n      do: filter_mod.sanitize(context),\n      else: context\n  end\n\n  defp normalize_exception(%struct{} = ex, _stacktrace) when is_exception(ex) do\n    {to_string(struct), Exception.message(ex)}\n  end\n\n  defp normalize_exception({kind, ex}, stacktrace) do\n    case Exception.normalize(kind, ex, stacktrace) do\n      %struct{} = ex -> {to_string(struct), Exception.message(ex)}\n      payload -> {to_string(kind), safe_to_string(payload)}\n    end\n  end\n\n  defp safe_to_string(term) do\n    to_string(term)\n  rescue\n    Protocol.UndefinedError ->\n      inspect(term)\n  end\n\n  defp exception_breadcrumbs(exception) do\n    case exception do\n      {_kind, exception} -> exception_breadcrumbs(exception)\n      %{bread_crumbs: breadcrumbs} -> breadcrumbs\n      _other -> []\n    end\n  end\n\n  defp upsert_error!(error, stacktrace, context, breadcrumbs, reason) do\n    status_and_muted_query =\n      from e in Error,\n        where: [fingerprint: ^error.fingerprint],\n        select: {e.status, e.muted}\n\n    {existing_status, muted} =\n      case Repo.one(status_and_muted_query) do\n        {existing_status, muted} -> {existing_status, muted}\n        nil -> {nil, false}\n      end\n\n    {:ok, {error, occurrence}} =\n      Repo.transaction(fn ->\n        error =\n          Repo.with_adapter(fn\n            :mysql ->\n              Repo.insert!(error,\n                on_conflict: [set: [status: :unresolved, last_occurrence_at: DateTime.utc_now()]]\n              )\n\n            _other ->\n              Repo.insert!(error,\n                on_conflict: [set: [status: :unresolved, last_occurrence_at: DateTime.utc_now()]],\n                conflict_target: :fingerprint\n              )\n          end)\n\n        occurrence =\n          error\n          |> Ecto.build_assoc(:occurrences)\n          |> Occurrence.changeset(%{\n            stacktrace: stacktrace,\n            context: context,\n            breadcrumbs: breadcrumbs,\n            reason: reason\n          })\n          |> Repo.insert!()\n\n        {error, occurrence}\n      end)\n\n    %Occurrence{} = occurrence\n    occurrence = %{occurrence | error: error}\n\n    # If the error existed and was marked as resolved before this exception,\n    # sent a Telemetry event\n    # If it is a new error, sent a Telemetry event\n    case existing_status do\n      :resolved -> Telemetry.unresolved_error(error)\n      :unresolved -> :noop\n      nil -> Telemetry.new_error(error)\n    end\n\n    Telemetry.new_occurrence(occurrence, muted)\n    occurrence\n  end\n\n  @default_json_encoder (cond do\n                           Code.ensure_loaded?(JSON) ->\n                             JSON\n\n                           Code.ensure_loaded?(Jason) ->\n                             Jason\n\n                           true ->\n                             raise \"\"\"\n                             No JSON encoder found. Please add Jason to your dependencies:\n\n                                 {:jason, \"~> 1.1\"}\n\n                             Or upgrade to Elixir 1.18+.\n                             \"\"\"\n                         end)\n\n  @doc false\n  def __default_json_encoder__, do: @default_json_encoder\nend\n"
  },
  {
    "path": "lib/mix/tasks/error_tracker.install.ex",
    "content": "defmodule Mix.Tasks.ErrorTracker.Install.Docs do\n  @moduledoc false\n\n  def short_doc do\n    \"Install and configure ErrorTracker for use in this application.\"\n  end\n\n  def example do\n    \"mix error_tracker.install\"\n  end\n\n  def long_doc do\n    \"\"\"\n    #{short_doc()}\n\n    ## Example\n\n    ```bash\n    #{example()}\n    ```\n    \"\"\"\n  end\nend\n\nif Code.ensure_loaded?(Igniter) do\n  defmodule Mix.Tasks.ErrorTracker.Install do\n    @shortdoc \"#{__MODULE__.Docs.short_doc()}\"\n\n    @moduledoc __MODULE__.Docs.long_doc()\n\n    use Igniter.Mix.Task\n\n    alias Igniter.Project.Config\n\n    @impl Igniter.Mix.Task\n    def info(_argv, _composing_task) do\n      %Igniter.Mix.Task.Info{\n        # Groups allow for overlapping arguments for tasks by the same author\n        # See the generators guide for more.\n        group: :error_tracker,\n        # *other* dependencies to add\n        # i.e `{:foo, \"~> 2.0\"}`\n        adds_deps: [],\n        # *other* dependencies to add and call their associated installers, if they exist\n        # i.e `{:foo, \"~> 2.0\"}`\n        installs: [],\n        # An example invocation\n        example: __MODULE__.Docs.example(),\n        # A list of environments that this should be installed in.\n        only: nil,\n        # a list of positional arguments, i.e `[:file]`\n        positional: [],\n        # Other tasks your task composes using `Igniter.compose_task`, passing in the CLI argv\n        # This ensures your option schema includes options from nested tasks\n        composes: [],\n        # `OptionParser` schema\n        schema: [],\n        # Default values for the options in the `schema`\n        defaults: [],\n        # CLI aliases\n        aliases: [],\n        # A list of options in the schema that are required\n        required: []\n      }\n    end\n\n    @impl Igniter.Mix.Task\n    def igniter(igniter) do\n      app_name = Igniter.Project.Application.app_name(igniter)\n      {igniter, repo} = Igniter.Libs.Ecto.select_repo(igniter)\n      {igniter, router} = Igniter.Libs.Phoenix.select_router(igniter)\n\n      igniter\n      |> set_up_configuration(app_name, repo)\n      |> set_up_formatter()\n      |> set_up_database(repo)\n      |> set_up_web_ui(app_name, router)\n    end\n\n    defp set_up_configuration(igniter, app_name, repo) do\n      igniter\n      |> Config.configure_new(\"config.exs\", :error_tracker, [:repo], repo)\n      |> Config.configure_new(\"config.exs\", :error_tracker, [:otp_app], app_name)\n      |> Config.configure_new(\"config.exs\", :error_tracker, [:enabled], true)\n    end\n\n    defp set_up_formatter(igniter) do\n      Igniter.Project.Formatter.import_dep(igniter, :error_tracker)\n    end\n\n    defp set_up_database(igniter, repo) do\n      migration_body = \"\"\"\n      def up, do: ErrorTracker.Migration.up()\n      def down, do: ErrorTracker.Migration.down(version: 1)\n      \"\"\"\n\n      Igniter.Libs.Ecto.gen_migration(igniter, repo, \"add_error_tracker\",\n        body: migration_body,\n        on_exists: :skip\n      )\n    end\n\n    defp set_up_web_ui(igniter, app_name, router) do\n      if router do\n        Igniter.Project.Module.find_and_update_module!(igniter, router, fn zipper ->\n          zipper =\n            Igniter.Code.Common.add_code(\n              zipper,\n              \"\"\"\n              if Application.compile_env(#{inspect(app_name)}, :dev_routes) do\n                use ErrorTracker.Web, :router\n\n                scope \"/dev\" do\n                  pipe_through :browser\n\n                  error_tracker_dashboard \"/errors\"\n                end\n              end\n              \"\"\",\n              placement: :after\n            )\n\n          {:ok, zipper}\n        end)\n      else\n        Igniter.add_warning(igniter, \"\"\"\n        No Phoenix router found or selected. Please ensure that Phoenix is set up\n        and then run this installer again with\n\n            mix igniter.install error_tracker\n        \"\"\")\n      end\n    end\n  end\nelse\n  defmodule Mix.Tasks.ErrorTracker.Install do\n    @shortdoc \"#{__MODULE__.Docs.short_doc()} | Install `igniter` to use\"\n\n    @moduledoc __MODULE__.Docs.long_doc()\n\n    use Mix.Task\n\n    def run(_argv) do\n      Mix.shell().error(\"\"\"\n      The task 'error_tracker.install' requires igniter. Please install igniter and try again.\n\n      For more information, see: https://hexdocs.pm/igniter/readme.html#installation\n      \"\"\")\n\n      exit({:shutdown, 1})\n    end\n  end\nend\n"
  },
  {
    "path": "mix.exs",
    "content": "defmodule ErrorTracker.MixProject do\n  use Mix.Project\n\n  def project do\n    [\n      app: :error_tracker,\n      version: \"0.8.0\",\n      elixir: \"~> 1.15\",\n      elixirc_paths: elixirc_paths(Mix.env()),\n      start_permanent: Mix.env() == :prod,\n      deps: deps(),\n      package: package(),\n      description: description(),\n      source_url: \"https://github.com/elixir-error-tracker/error-tracker\",\n      aliases: aliases(),\n      name: \"ErrorTracker\",\n      docs: [\n        main: \"ErrorTracker\",\n        formatters: [\"html\"],\n        groups_for_modules: groups_for_modules(),\n        extra_section: \"GUIDES\",\n        extras: [\n          \"guides/Getting Started.md\"\n        ],\n        api_reference: false,\n        main: \"getting-started\"\n      ]\n    ]\n  end\n\n  # Run \"mix help compile.app\" to learn about applications.\n  def application do\n    [\n      mod: {ErrorTracker.Application, []},\n      extra_applications: [:logger]\n    ]\n  end\n\n  defp elixirc_paths(:test), do: [\"lib\", \"test/support\"]\n  defp elixirc_paths(_env), do: [\"lib\"]\n\n  def package do\n    [\n      licenses: [\"Apache-2.0\"],\n      links: %{\n        \"GitHub\" => \"https://github.com/elixir-error-tracker/error-tracker\"\n      },\n      maintainers: [\n        \"Óscar de Arriba González\",\n        \"Cristian Álvarez Belaustegui\",\n        \"Víctor Ortiz Heredia\"\n      ],\n      files: ~w(lib priv/static LICENSE mix.exs README.md .formatter.exs)\n    ]\n  end\n\n  def description do\n    \"An Elixir-based built-in error tracking solution\"\n  end\n\n  defp groups_for_modules do\n    [\n      Integrations: [\n        ErrorTracker.Integrations.Oban,\n        ErrorTracker.Integrations.Phoenix,\n        ErrorTracker.Integrations.Plug\n      ],\n      Plugins: [\n        ErrorTracker.Plugins.Pruner\n      ],\n      Schemas: [\n        ErrorTracker.Error,\n        ErrorTracker.Occurrence,\n        ErrorTracker.Stacktrace,\n        ErrorTracker.Stacktrace.Line\n      ],\n      \"Web UI\": [\n        ErrorTracker.Web,\n        ErrorTracker.Web.Router\n      ]\n    ]\n  end\n\n  # Run \"mix help deps\" to learn about dependencies.\n  defp deps do\n    [\n      {:ecto_sql, \"~> 3.13\"},\n      {:ecto, \"~> 3.13\"},\n      {:phoenix_ecto, \"~> 4.6\"},\n      {:phoenix_live_view, \"~> 1.0\"},\n      {:plug, \"~> 1.10\"},\n      # Dev dependencies\n      {:bun, \"~> 1.3\", only: :dev},\n      {:ex_doc, \"~> 0.33\", only: :dev},\n      {:phoenix_live_reload, \">= 0.0.0\", only: :dev},\n      {:plug_cowboy, \">= 0.0.0\", only: :dev},\n      {:styler, \"~> 1.11\", only: [:dev, :test], runtime: false},\n      {:tailwind, \"~> 0.2\", only: :dev},\n      # Optional dependencies\n      {:ecto_sqlite3, \">= 0.0.0\", optional: true},\n      {:igniter, \"~> 0.5\", optional: true},\n      {:jason, \"~> 1.1\", optional: true},\n      {:myxql, \">= 0.0.0\", optional: true},\n      {:postgrex, \">= 0.0.0\", optional: true}\n    ]\n  end\n\n  defp aliases do\n    [\n      dev: \"run --no-halt dev.exs\",\n      \"assets.install\": [\"bun.install\", \"cmd _build/bun install --cwd assets/\"],\n      \"assets.watch\": [\"tailwind default --watch\"],\n      \"assets.build\": [\"bun default\", \"tailwind default\"]\n    ]\n  end\nend\n"
  },
  {
    "path": "priv/repo/migrations/20240527155639_create_error_tracker_tables.exs",
    "content": "defmodule ErrorTracker.Repo.Migrations.CreateErrorTrackerTables do\n  use Ecto.Migration\n\n  defdelegate up, to: ErrorTracker.Migration\n  defdelegate down, to: ErrorTracker.Migration\nend\n"
  },
  {
    "path": "priv/repo/seeds.exs",
    "content": "adapter =\n  case Application.get_env(:error_tracker, :ecto_adapter) do\n    :postgres -> Ecto.Adapters.Postgres\n    :sqlite3 -> Ecto.Adapters.SQLite3\n  end\n\ndefmodule ErrorTrackerDev.Repo do\n  use Ecto.Repo, otp_app: :error_tracker, adapter: adapter\nend\n\nErrorTrackerDev.Repo.start_link()\n\nErrorTrackerDev.Repo.delete_all(ErrorTracker.Error)\n\nerrors =\n  for i <- 1..100 do\n    %{\n      kind: \"Error #{i}\",\n      reason: \"Reason #{i}\",\n      source_line: \"line\",\n      source_function: \"function\",\n      status: :unresolved,\n      fingerprint: \"#{i}\",\n      last_occurrence_at: DateTime.utc_now(),\n      inserted_at: DateTime.utc_now(),\n      updated_at: DateTime.utc_now()\n    }\n  end\n\n{_, errors} = dbg(ErrorTrackerDev.Repo.insert_all(ErrorTracker.Error, errors, returning: [:id]))\n\nfor error <- errors do\n  occurrences =\n    for _i <- 1..200 do\n      %{\n        context: %{},\n        reason: \"REASON\",\n        stacktrace: %ErrorTracker.Stacktrace{},\n        error_id: error.id,\n        inserted_at: DateTime.utc_now()\n      }\n    end\n\n  ErrorTrackerDev.Repo.insert_all(ErrorTracker.Occurrence, occurrences)\nend\n"
  },
  {
    "path": "priv/static/app.css",
    "content": "/*\n! tailwindcss v3.4.3 | MIT License | https://tailwindcss.com\n*/\n\n/*\n1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)\n2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)\n*/\n\n*,\n::before,\n::after {\n  box-sizing: border-box;\n  /* 1 */\n  border-width: 0;\n  /* 2 */\n  border-style: solid;\n  /* 2 */\n  border-color: #e5e7eb;\n  /* 2 */\n}\n\n::before,\n::after {\n  --tw-content: '';\n}\n\n/*\n1. Use a consistent sensible line-height in all browsers.\n2. Prevent adjustments of font size after orientation changes in iOS.\n3. Use a more readable tab size.\n4. Use the user's configured `sans` font-family by default.\n5. Use the user's configured `sans` font-feature-settings by default.\n6. Use the user's configured `sans` font-variation-settings by default.\n7. Disable tap highlights on iOS\n*/\n\nhtml,\n:host {\n  line-height: 1.5;\n  /* 1 */\n  -webkit-text-size-adjust: 100%;\n  /* 2 */\n  -moz-tab-size: 4;\n  /* 3 */\n  -o-tab-size: 4;\n     tab-size: 4;\n  /* 3 */\n  font-family: ui-sans-serif, system-ui, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\";\n  /* 4 */\n  font-feature-settings: normal;\n  /* 5 */\n  font-variation-settings: normal;\n  /* 6 */\n  -webkit-tap-highlight-color: transparent;\n  /* 7 */\n}\n\n/*\n1. Remove the margin in all browsers.\n2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.\n*/\n\nbody {\n  margin: 0;\n  /* 1 */\n  line-height: inherit;\n  /* 2 */\n}\n\n/*\n1. Add the correct height in Firefox.\n2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)\n3. Ensure horizontal rules are visible by default.\n*/\n\nhr {\n  height: 0;\n  /* 1 */\n  color: inherit;\n  /* 2 */\n  border-top-width: 1px;\n  /* 3 */\n}\n\n/*\nAdd the correct text decoration in Chrome, Edge, and Safari.\n*/\n\nabbr:where([title]) {\n  -webkit-text-decoration: underline dotted;\n          text-decoration: underline dotted;\n}\n\n/*\nRemove the default font size and weight for headings.\n*/\n\nh1,\nh2,\nh3,\nh4,\nh5,\nh6 {\n  font-size: inherit;\n  font-weight: inherit;\n}\n\n/*\nReset links to optimize for opt-in styling instead of opt-out.\n*/\n\na {\n  color: inherit;\n  text-decoration: inherit;\n}\n\n/*\nAdd the correct font weight in Edge and Safari.\n*/\n\nb,\nstrong {\n  font-weight: bolder;\n}\n\n/*\n1. Use the user's configured `mono` font-family by default.\n2. Use the user's configured `mono` font-feature-settings by default.\n3. Use the user's configured `mono` font-variation-settings by default.\n4. Correct the odd `em` font sizing in all browsers.\n*/\n\ncode,\nkbd,\nsamp,\npre {\n  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace;\n  /* 1 */\n  font-feature-settings: normal;\n  /* 2 */\n  font-variation-settings: normal;\n  /* 3 */\n  font-size: 1em;\n  /* 4 */\n}\n\n/*\nAdd the correct font size in all browsers.\n*/\n\nsmall {\n  font-size: 80%;\n}\n\n/*\nPrevent `sub` and `sup` elements from affecting the line height in all browsers.\n*/\n\nsub,\nsup {\n  font-size: 75%;\n  line-height: 0;\n  position: relative;\n  vertical-align: baseline;\n}\n\nsub {\n  bottom: -0.25em;\n}\n\nsup {\n  top: -0.5em;\n}\n\n/*\n1. 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)\n2. 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)\n3. Remove gaps between table borders by default.\n*/\n\ntable {\n  text-indent: 0;\n  /* 1 */\n  border-color: inherit;\n  /* 2 */\n  border-collapse: collapse;\n  /* 3 */\n}\n\n/*\n1. Change the font styles in all browsers.\n2. Remove the margin in Firefox and Safari.\n3. Remove default padding in all browsers.\n*/\n\nbutton,\ninput,\noptgroup,\nselect,\ntextarea {\n  font-family: inherit;\n  /* 1 */\n  font-feature-settings: inherit;\n  /* 1 */\n  font-variation-settings: inherit;\n  /* 1 */\n  font-size: 100%;\n  /* 1 */\n  font-weight: inherit;\n  /* 1 */\n  line-height: inherit;\n  /* 1 */\n  letter-spacing: inherit;\n  /* 1 */\n  color: inherit;\n  /* 1 */\n  margin: 0;\n  /* 2 */\n  padding: 0;\n  /* 3 */\n}\n\n/*\nRemove the inheritance of text transform in Edge and Firefox.\n*/\n\nbutton,\nselect {\n  text-transform: none;\n}\n\n/*\n1. Correct the inability to style clickable types in iOS and Safari.\n2. Remove default button styles.\n*/\n\nbutton,\ninput:where([type='button']),\ninput:where([type='reset']),\ninput:where([type='submit']) {\n  -webkit-appearance: button;\n  /* 1 */\n  background-color: transparent;\n  /* 2 */\n  background-image: none;\n  /* 2 */\n}\n\n/*\nUse the modern Firefox focus style for all focusable elements.\n*/\n\n:-moz-focusring {\n  outline: auto;\n}\n\n/*\nRemove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)\n*/\n\n:-moz-ui-invalid {\n  box-shadow: none;\n}\n\n/*\nAdd the correct vertical alignment in Chrome and Firefox.\n*/\n\nprogress {\n  vertical-align: baseline;\n}\n\n/*\nCorrect the cursor style of increment and decrement buttons in Safari.\n*/\n\n::-webkit-inner-spin-button,\n::-webkit-outer-spin-button {\n  height: auto;\n}\n\n/*\n1. Correct the odd appearance in Chrome and Safari.\n2. Correct the outline style in Safari.\n*/\n\n[type='search'] {\n  -webkit-appearance: textfield;\n  /* 1 */\n  outline-offset: -2px;\n  /* 2 */\n}\n\n/*\nRemove the inner padding in Chrome and Safari on macOS.\n*/\n\n::-webkit-search-decoration {\n  -webkit-appearance: none;\n}\n\n/*\n1. Correct the inability to style clickable types in iOS and Safari.\n2. Change font properties to `inherit` in Safari.\n*/\n\n::-webkit-file-upload-button {\n  -webkit-appearance: button;\n  /* 1 */\n  font: inherit;\n  /* 2 */\n}\n\n/*\nAdd the correct display in Chrome and Safari.\n*/\n\nsummary {\n  display: list-item;\n}\n\n/*\nRemoves the default spacing and border for appropriate elements.\n*/\n\nblockquote,\ndl,\ndd,\nh1,\nh2,\nh3,\nh4,\nh5,\nh6,\nhr,\nfigure,\np,\npre {\n  margin: 0;\n}\n\nfieldset {\n  margin: 0;\n  padding: 0;\n}\n\nlegend {\n  padding: 0;\n}\n\nol,\nul,\nmenu {\n  list-style: none;\n  margin: 0;\n  padding: 0;\n}\n\n/*\nReset default styling for dialogs.\n*/\n\ndialog {\n  padding: 0;\n}\n\n/*\nPrevent resizing textareas horizontally by default.\n*/\n\ntextarea {\n  resize: vertical;\n}\n\n/*\n1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)\n2. Set the default placeholder color to the user's configured gray 400 color.\n*/\n\ninput::-moz-placeholder, textarea::-moz-placeholder {\n  opacity: 1;\n  /* 1 */\n  color: #9ca3af;\n  /* 2 */\n}\n\ninput::placeholder,\ntextarea::placeholder {\n  opacity: 1;\n  /* 1 */\n  color: #9ca3af;\n  /* 2 */\n}\n\n/*\nSet the default cursor for buttons.\n*/\n\nbutton,\n[role=\"button\"] {\n  cursor: pointer;\n}\n\n/*\nMake sure disabled buttons don't get the pointer cursor.\n*/\n\n:disabled {\n  cursor: default;\n}\n\n/*\n1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)\n2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)\n   This can trigger a poorly considered lint error in some tools but is included by design.\n*/\n\nimg,\nsvg,\nvideo,\ncanvas,\naudio,\niframe,\nembed,\nobject {\n  display: block;\n  /* 1 */\n  vertical-align: middle;\n  /* 2 */\n}\n\n/*\nConstrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)\n*/\n\nimg,\nvideo {\n  max-width: 100%;\n  height: auto;\n}\n\n/* Make elements with the HTML hidden attribute stay hidden by default */\n\n[hidden] {\n  display: none;\n}\n\n[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 {\n  -webkit-appearance: none;\n     -moz-appearance: none;\n          appearance: none;\n  background-color: #fff;\n  border-color: #6b7280;\n  border-width: 1px;\n  border-radius: 0px;\n  padding-top: 0.5rem;\n  padding-right: 0.75rem;\n  padding-bottom: 0.5rem;\n  padding-left: 0.75rem;\n  font-size: 1rem;\n  line-height: 1.5rem;\n  --tw-shadow: 0 0 #0000;\n}\n\n[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 {\n  outline: 2px solid transparent;\n  outline-offset: 2px;\n  --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/);\n  --tw-ring-offset-width: 0px;\n  --tw-ring-offset-color: #fff;\n  --tw-ring-color: #2563eb;\n  --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);\n  --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);\n  box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n  border-color: #2563eb;\n}\n\ninput::-moz-placeholder, textarea::-moz-placeholder {\n  color: #6b7280;\n  opacity: 1;\n}\n\ninput::placeholder,textarea::placeholder {\n  color: #6b7280;\n  opacity: 1;\n}\n\n::-webkit-datetime-edit-fields-wrapper {\n  padding: 0;\n}\n\n::-webkit-date-and-time-value {\n  min-height: 1.5em;\n}\n\n::-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 {\n  padding-top: 0;\n  padding-bottom: 0;\n}\n\nselect {\n  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\");\n  background-position: right 0.5rem center;\n  background-repeat: no-repeat;\n  background-size: 1.5em 1.5em;\n  padding-right: 2.5rem;\n  -webkit-print-color-adjust: exact;\n          print-color-adjust: exact;\n}\n\n[multiple] {\n  background-image: initial;\n  background-position: initial;\n  background-repeat: unset;\n  background-size: initial;\n  padding-right: 0.75rem;\n  -webkit-print-color-adjust: unset;\n          print-color-adjust: unset;\n}\n\n[type='checkbox'],[type='radio'] {\n  -webkit-appearance: none;\n     -moz-appearance: none;\n          appearance: none;\n  padding: 0;\n  -webkit-print-color-adjust: exact;\n          print-color-adjust: exact;\n  display: inline-block;\n  vertical-align: middle;\n  background-origin: border-box;\n  -webkit-user-select: none;\n     -moz-user-select: none;\n          user-select: none;\n  flex-shrink: 0;\n  height: 1rem;\n  width: 1rem;\n  color: #2563eb;\n  background-color: #fff;\n  border-color: #6b7280;\n  border-width: 1px;\n  --tw-shadow: 0 0 #0000;\n}\n\n[type='checkbox'] {\n  border-radius: 0px;\n}\n\n[type='radio'] {\n  border-radius: 100%;\n}\n\n[type='checkbox']:focus,[type='radio']:focus {\n  outline: 2px solid transparent;\n  outline-offset: 2px;\n  --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/);\n  --tw-ring-offset-width: 2px;\n  --tw-ring-offset-color: #fff;\n  --tw-ring-color: #2563eb;\n  --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);\n  --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);\n  box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n\n[type='checkbox']:checked,[type='radio']:checked {\n  border-color: transparent;\n  background-color: currentColor;\n  background-size: 100% 100%;\n  background-position: center;\n  background-repeat: no-repeat;\n}\n\n[type='checkbox']:checked {\n  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\");\n}\n\n[type='radio']:checked {\n  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\");\n}\n\n[type='checkbox']:checked:hover,[type='checkbox']:checked:focus,[type='radio']:checked:hover,[type='radio']:checked:focus {\n  border-color: transparent;\n  background-color: currentColor;\n}\n\n[type='checkbox']:indeterminate {\n  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\");\n  border-color: transparent;\n  background-color: currentColor;\n  background-size: 100% 100%;\n  background-position: center;\n  background-repeat: no-repeat;\n}\n\n[type='checkbox']:indeterminate:hover,[type='checkbox']:indeterminate:focus {\n  border-color: transparent;\n  background-color: currentColor;\n}\n\n[type='file'] {\n  background: unset;\n  border-color: inherit;\n  border-width: 0;\n  border-radius: 0;\n  padding: 0;\n  font-size: unset;\n  line-height: inherit;\n}\n\n[type='file']:focus {\n  outline: 1px solid ButtonText;\n  outline: 1px auto -webkit-focus-ring-color;\n}\n\n*, ::before, ::after {\n  --tw-border-spacing-x: 0;\n  --tw-border-spacing-y: 0;\n  --tw-translate-x: 0;\n  --tw-translate-y: 0;\n  --tw-rotate: 0;\n  --tw-skew-x: 0;\n  --tw-skew-y: 0;\n  --tw-scale-x: 1;\n  --tw-scale-y: 1;\n  --tw-pan-x:  ;\n  --tw-pan-y:  ;\n  --tw-pinch-zoom:  ;\n  --tw-scroll-snap-strictness: proximity;\n  --tw-gradient-from-position:  ;\n  --tw-gradient-via-position:  ;\n  --tw-gradient-to-position:  ;\n  --tw-ordinal:  ;\n  --tw-slashed-zero:  ;\n  --tw-numeric-figure:  ;\n  --tw-numeric-spacing:  ;\n  --tw-numeric-fraction:  ;\n  --tw-ring-inset:  ;\n  --tw-ring-offset-width: 0px;\n  --tw-ring-offset-color: #fff;\n  --tw-ring-color: rgb(59 130 246 / 0.5);\n  --tw-ring-offset-shadow: 0 0 #0000;\n  --tw-ring-shadow: 0 0 #0000;\n  --tw-shadow: 0 0 #0000;\n  --tw-shadow-colored: 0 0 #0000;\n  --tw-blur:  ;\n  --tw-brightness:  ;\n  --tw-contrast:  ;\n  --tw-grayscale:  ;\n  --tw-hue-rotate:  ;\n  --tw-invert:  ;\n  --tw-saturate:  ;\n  --tw-sepia:  ;\n  --tw-drop-shadow:  ;\n  --tw-backdrop-blur:  ;\n  --tw-backdrop-brightness:  ;\n  --tw-backdrop-contrast:  ;\n  --tw-backdrop-grayscale:  ;\n  --tw-backdrop-hue-rotate:  ;\n  --tw-backdrop-invert:  ;\n  --tw-backdrop-opacity:  ;\n  --tw-backdrop-saturate:  ;\n  --tw-backdrop-sepia:  ;\n  --tw-contain-size:  ;\n  --tw-contain-layout:  ;\n  --tw-contain-paint:  ;\n  --tw-contain-style:  ;\n}\n\n::backdrop {\n  --tw-border-spacing-x: 0;\n  --tw-border-spacing-y: 0;\n  --tw-translate-x: 0;\n  --tw-translate-y: 0;\n  --tw-rotate: 0;\n  --tw-skew-x: 0;\n  --tw-skew-y: 0;\n  --tw-scale-x: 1;\n  --tw-scale-y: 1;\n  --tw-pan-x:  ;\n  --tw-pan-y:  ;\n  --tw-pinch-zoom:  ;\n  --tw-scroll-snap-strictness: proximity;\n  --tw-gradient-from-position:  ;\n  --tw-gradient-via-position:  ;\n  --tw-gradient-to-position:  ;\n  --tw-ordinal:  ;\n  --tw-slashed-zero:  ;\n  --tw-numeric-figure:  ;\n  --tw-numeric-spacing:  ;\n  --tw-numeric-fraction:  ;\n  --tw-ring-inset:  ;\n  --tw-ring-offset-width: 0px;\n  --tw-ring-offset-color: #fff;\n  --tw-ring-color: rgb(59 130 246 / 0.5);\n  --tw-ring-offset-shadow: 0 0 #0000;\n  --tw-ring-shadow: 0 0 #0000;\n  --tw-shadow: 0 0 #0000;\n  --tw-shadow-colored: 0 0 #0000;\n  --tw-blur:  ;\n  --tw-brightness:  ;\n  --tw-contrast:  ;\n  --tw-grayscale:  ;\n  --tw-hue-rotate:  ;\n  --tw-invert:  ;\n  --tw-saturate:  ;\n  --tw-sepia:  ;\n  --tw-drop-shadow:  ;\n  --tw-backdrop-blur:  ;\n  --tw-backdrop-brightness:  ;\n  --tw-backdrop-contrast:  ;\n  --tw-backdrop-grayscale:  ;\n  --tw-backdrop-hue-rotate:  ;\n  --tw-backdrop-invert:  ;\n  --tw-backdrop-opacity:  ;\n  --tw-backdrop-saturate:  ;\n  --tw-backdrop-sepia:  ;\n  --tw-contain-size:  ;\n  --tw-contain-layout:  ;\n  --tw-contain-paint:  ;\n  --tw-contain-style:  ;\n}\n\n.container {\n  width: 100%;\n}\n\n@media (min-width: 640px) {\n  .container {\n    max-width: 640px;\n  }\n}\n\n@media (min-width: 768px) {\n  .container {\n    max-width: 768px;\n  }\n}\n\n@media (min-width: 1024px) {\n  .container {\n    max-width: 1024px;\n  }\n}\n\n@media (min-width: 1280px) {\n  .container {\n    max-width: 1280px;\n  }\n}\n\n@media (min-width: 1536px) {\n  .container {\n    max-width: 1536px;\n  }\n}\n\n.sr-only {\n  position: absolute;\n  width: 1px;\n  height: 1px;\n  padding: 0;\n  margin: -1px;\n  overflow: hidden;\n  clip: rect(0, 0, 0, 0);\n  white-space: nowrap;\n  border-width: 0;\n}\n\n.static {\n  position: static;\n}\n\n.absolute {\n  position: absolute;\n}\n\n.relative {\n  position: relative;\n}\n\n.inset-1 {\n  inset: 0.25rem;\n}\n\n.mx-auto {\n  margin-left: auto;\n  margin-right: auto;\n}\n\n.my-1 {\n  margin-top: 0.25rem;\n  margin-bottom: 0.25rem;\n}\n\n.my-6 {\n  margin-top: 1.5rem;\n  margin-bottom: 1.5rem;\n}\n\n.mb-1 {\n  margin-bottom: 0.25rem;\n}\n\n.mb-2 {\n  margin-bottom: 0.5rem;\n}\n\n.mb-3 {\n  margin-bottom: 0.75rem;\n}\n\n.mb-4 {\n  margin-bottom: 1rem;\n}\n\n.me-2 {\n  margin-inline-end: 0.5rem;\n}\n\n.ml-2 {\n  margin-left: 0.5rem;\n}\n\n.mr-1 {\n  margin-right: 0.25rem;\n}\n\n.mr-2 {\n  margin-right: 0.5rem;\n}\n\n.mt-1 {\n  margin-top: 0.25rem;\n}\n\n.mt-10 {\n  margin-top: 2.5rem;\n}\n\n.mt-2 {\n  margin-top: 0.5rem;\n}\n\n.mt-4 {\n  margin-top: 1rem;\n}\n\n.mt-6 {\n  margin-top: 1.5rem;\n}\n\n.block {\n  display: block;\n}\n\n.inline-block {\n  display: inline-block;\n}\n\n.inline {\n  display: inline;\n}\n\n.flex {\n  display: flex;\n}\n\n.inline-flex {\n  display: inline-flex;\n}\n\n.table {\n  display: table;\n}\n\n.grid {\n  display: grid;\n}\n\n.hidden {\n  display: none;\n}\n\n.\\!h-4 {\n  height: 1rem !important;\n}\n\n.h-10 {\n  height: 2.5rem;\n}\n\n.h-5 {\n  height: 1.25rem;\n}\n\n.\\!w-4 {\n  width: 1rem !important;\n}\n\n.w-10 {\n  width: 2.5rem;\n}\n\n.w-11 {\n  width: 2.75rem;\n}\n\n.w-28 {\n  width: 7rem;\n}\n\n.w-5 {\n  width: 1.25rem;\n}\n\n.w-72 {\n  width: 18rem;\n}\n\n.w-full {\n  width: 100%;\n}\n\n.table-fixed {\n  table-layout: fixed;\n}\n\n.grid-cols-1 {\n  grid-template-columns: repeat(1, minmax(0, 1fr));\n}\n\n.grid-cols-2 {\n  grid-template-columns: repeat(2, minmax(0, 1fr));\n}\n\n.flex-col {\n  flex-direction: column;\n}\n\n.flex-wrap {\n  flex-wrap: wrap;\n}\n\n.items-center {\n  align-items: center;\n}\n\n.justify-end {\n  justify-content: flex-end;\n}\n\n.justify-center {\n  justify-content: center;\n}\n\n.justify-between {\n  justify-content: space-between;\n}\n\n.gap-2 {\n  gap: 0.5rem;\n}\n\n.gap-y-4 {\n  row-gap: 1rem;\n}\n\n.space-y-8 > :not([hidden]) ~ :not([hidden]) {\n  --tw-space-y-reverse: 0;\n  margin-top: calc(2rem * calc(1 - var(--tw-space-y-reverse)));\n  margin-bottom: calc(2rem * var(--tw-space-y-reverse));\n}\n\n.self-center {\n  align-self: center;\n}\n\n.overflow-auto {\n  overflow: auto;\n}\n\n.overflow-hidden {\n  overflow: hidden;\n}\n\n.overflow-x-auto {\n  overflow-x: auto;\n}\n\n.text-ellipsis {\n  text-overflow: ellipsis;\n}\n\n.whitespace-nowrap {\n  white-space: nowrap;\n}\n\n.rounded {\n  border-radius: 0.25rem;\n}\n\n.rounded-lg {\n  border-radius: 0.5rem;\n}\n\n.border {\n  border-width: 1px;\n}\n\n.border-y {\n  border-top-width: 1px;\n  border-bottom-width: 1px;\n}\n\n.border-b {\n  border-bottom-width: 1px;\n}\n\n.border-gray-400 {\n  --tw-border-opacity: 1;\n  border-color: rgb(156 163 175 / var(--tw-border-opacity));\n}\n\n.border-gray-600 {\n  --tw-border-opacity: 1;\n  border-color: rgb(75 85 99 / var(--tw-border-opacity));\n}\n\n.border-gray-900 {\n  --tw-border-opacity: 1;\n  border-color: rgb(17 24 39 / var(--tw-border-opacity));\n}\n\n.bg-blue-900 {\n  --tw-bg-opacity: 1;\n  background-color: rgb(30 58 138 / var(--tw-bg-opacity));\n}\n\n.bg-emerald-400\\/10 {\n  background-color: rgb(52 211 153 / 0.1);\n}\n\n.bg-gray-300\\/10 {\n  background-color: rgb(209 213 219 / 0.1);\n}\n\n.bg-gray-400\\/10 {\n  background-color: rgb(156 163 175 / 0.1);\n}\n\n.bg-gray-700 {\n  --tw-bg-opacity: 1;\n  background-color: rgb(55 65 81 / var(--tw-bg-opacity));\n}\n\n.bg-gray-800 {\n  --tw-bg-opacity: 1;\n  background-color: rgb(31 41 55 / var(--tw-bg-opacity));\n}\n\n.bg-gray-900 {\n  --tw-bg-opacity: 1;\n  background-color: rgb(17 24 39 / var(--tw-bg-opacity));\n}\n\n.bg-indigo-900 {\n  --tw-bg-opacity: 1;\n  background-color: rgb(49 46 129 / var(--tw-bg-opacity));\n}\n\n.bg-pink-900 {\n  --tw-bg-opacity: 1;\n  background-color: rgb(131 24 67 / var(--tw-bg-opacity));\n}\n\n.bg-purple-900 {\n  --tw-bg-opacity: 1;\n  background-color: rgb(88 28 135 / var(--tw-bg-opacity));\n}\n\n.bg-red-400\\/10 {\n  background-color: rgb(248 113 113 / 0.1);\n}\n\n.bg-sky-500 {\n  --tw-bg-opacity: 1;\n  background-color: rgb(14 165 233 / var(--tw-bg-opacity));\n}\n\n.bg-yellow-900 {\n  --tw-bg-opacity: 1;\n  background-color: rgb(113 63 18 / var(--tw-bg-opacity));\n}\n\n.p-2 {\n  padding: 0.5rem;\n}\n\n.p-2\\.5 {\n  padding: 0.625rem;\n}\n\n.p-4 {\n  padding: 1rem;\n}\n\n.px-2 {\n  padding-left: 0.5rem;\n  padding-right: 0.5rem;\n}\n\n.px-3 {\n  padding-left: 0.75rem;\n  padding-right: 0.75rem;\n}\n\n.px-4 {\n  padding-left: 1rem;\n  padding-right: 1rem;\n}\n\n.py-1 {\n  padding-top: 0.25rem;\n  padding-bottom: 0.25rem;\n}\n\n.py-2 {\n  padding-top: 0.5rem;\n  padding-bottom: 0.5rem;\n}\n\n.py-3 {\n  padding-top: 0.75rem;\n  padding-bottom: 0.75rem;\n}\n\n.py-4 {\n  padding-top: 1rem;\n  padding-bottom: 1rem;\n}\n\n.py-8 {\n  padding-top: 2rem;\n  padding-bottom: 2rem;\n}\n\n.py-\\[11\\.5px\\] {\n  padding-top: 11.5px;\n  padding-bottom: 11.5px;\n}\n\n.pl-2 {\n  padding-left: 0.5rem;\n}\n\n.pr-2 {\n  padding-right: 0.5rem;\n}\n\n.pr-5 {\n  padding-right: 1.25rem;\n}\n\n.text-left {\n  text-align: left;\n}\n\n.text-center {\n  text-align: center;\n}\n\n.text-right {\n  text-align: right;\n}\n\n.align-top {\n  vertical-align: top;\n}\n\n.align-text-top {\n  vertical-align: text-top;\n}\n\n.text-2xl {\n  font-size: 1.5rem;\n  line-height: 2rem;\n}\n\n.text-base {\n  font-size: 1rem;\n  line-height: 1.5rem;\n}\n\n.text-sm {\n  font-size: 0.875rem;\n  line-height: 1.25rem;\n}\n\n.text-xs {\n  font-size: 0.75rem;\n  line-height: 1rem;\n}\n\n.font-extralight {\n  font-weight: 200;\n}\n\n.font-medium {\n  font-weight: 500;\n}\n\n.font-normal {\n  font-weight: 400;\n}\n\n.font-semibold {\n  font-weight: 600;\n}\n\n.uppercase {\n  text-transform: uppercase;\n}\n\n.text-black {\n  --tw-text-opacity: 1;\n  color: rgb(0 0 0 / var(--tw-text-opacity));\n}\n\n.text-blue-300 {\n  --tw-text-opacity: 1;\n  color: rgb(147 197 253 / var(--tw-text-opacity));\n}\n\n.text-emerald-300 {\n  --tw-text-opacity: 1;\n  color: rgb(110 231 183 / var(--tw-text-opacity));\n}\n\n.text-gray-300 {\n  --tw-text-opacity: 1;\n  color: rgb(209 213 219 / var(--tw-text-opacity));\n}\n\n.text-gray-400 {\n  --tw-text-opacity: 1;\n  color: rgb(156 163 175 / var(--tw-text-opacity));\n}\n\n.text-indigo-300 {\n  --tw-text-opacity: 1;\n  color: rgb(165 180 252 / var(--tw-text-opacity));\n}\n\n.text-pink-300 {\n  --tw-text-opacity: 1;\n  color: rgb(249 168 212 / var(--tw-text-opacity));\n}\n\n.text-purple-300 {\n  --tw-text-opacity: 1;\n  color: rgb(216 180 254 / var(--tw-text-opacity));\n}\n\n.text-red-300 {\n  --tw-text-opacity: 1;\n  color: rgb(252 165 165 / var(--tw-text-opacity));\n}\n\n.text-sky-500 {\n  --tw-text-opacity: 1;\n  color: rgb(14 165 233 / var(--tw-text-opacity));\n}\n\n.text-sky-600 {\n  --tw-text-opacity: 1;\n  color: rgb(2 132 199 / var(--tw-text-opacity));\n}\n\n.text-white {\n  --tw-text-opacity: 1;\n  color: rgb(255 255 255 / var(--tw-text-opacity));\n}\n\n.text-yellow-300 {\n  --tw-text-opacity: 1;\n  color: rgb(253 224 71 / var(--tw-text-opacity));\n}\n\n.placeholder-gray-400::-moz-placeholder {\n  --tw-placeholder-opacity: 1;\n  color: rgb(156 163 175 / var(--tw-placeholder-opacity));\n}\n\n.placeholder-gray-400::placeholder {\n  --tw-placeholder-opacity: 1;\n  color: rgb(156 163 175 / var(--tw-placeholder-opacity));\n}\n\n.shadow-md {\n  --tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);\n  --tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);\n  box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);\n}\n\n.ring-1 {\n  --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);\n  --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);\n  box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);\n}\n\n.ring-inset {\n  --tw-ring-inset: inset;\n}\n\n.ring-emerald-400\\/20 {\n  --tw-ring-color: rgb(52 211 153 / 0.2);\n}\n\n.ring-gray-900 {\n  --tw-ring-opacity: 1;\n  --tw-ring-color: rgb(17 24 39 / var(--tw-ring-opacity));\n}\n\n.ring-red-400\\/20 {\n  --tw-ring-color: rgb(248 113 113 / 0.2);\n}\n\n.ring-offset-gray-800 {\n  --tw-ring-offset-color: #1f2937;\n}\n\n.filter {\n  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);\n}\n\n::-webkit-scrollbar {\n  height: 6px;\n  width: 6px;\n  --tw-bg-opacity: 1;\n  background-color: rgb(209 213 219 / var(--tw-bg-opacity));\n}\n\n::-webkit-scrollbar-thumb {\n  --tw-bg-opacity: 1;\n  background-color: rgb(107 114 128 / var(--tw-bg-opacity));\n  border-radius: 4px;\n}\n\n.last\\:border-b-0:last-child {\n  border-bottom-width: 0px;\n}\n\n.last-of-type\\:border-b-0:last-of-type {\n  border-bottom-width: 0px;\n}\n\n.hover\\:bg-gray-700:hover {\n  --tw-bg-opacity: 1;\n  background-color: rgb(55 65 81 / var(--tw-bg-opacity));\n}\n\n.hover\\:bg-gray-800:hover {\n  --tw-bg-opacity: 1;\n  background-color: rgb(31 41 55 / var(--tw-bg-opacity));\n}\n\n.hover\\:bg-gray-800\\/60:hover {\n  background-color: rgb(31 41 55 / 0.6);\n}\n\n.hover\\:bg-sky-700:hover {\n  --tw-bg-opacity: 1;\n  background-color: rgb(3 105 161 / var(--tw-bg-opacity));\n}\n\n.hover\\:text-white:hover {\n  --tw-text-opacity: 1;\n  color: rgb(255 255 255 / var(--tw-text-opacity));\n}\n\n.hover\\:text-white\\/80:hover {\n  color: rgb(255 255 255 / 0.8);\n}\n\n.focus\\:border-blue-500:focus {\n  --tw-border-opacity: 1;\n  border-color: rgb(59 130 246 / var(--tw-border-opacity));\n}\n\n.focus\\:outline-none:focus {\n  outline: 2px solid transparent;\n  outline-offset: 2px;\n}\n\n.focus\\:ring-2:focus {\n  --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);\n  --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);\n  box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);\n}\n\n.focus\\:ring-blue-500:focus {\n  --tw-ring-opacity: 1;\n  --tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity));\n}\n\n.focus\\:ring-gray-500:focus {\n  --tw-ring-opacity: 1;\n  --tw-ring-color: rgb(107 114 128 / var(--tw-ring-opacity));\n}\n\n.focus\\:ring-sky-600:focus {\n  --tw-ring-opacity: 1;\n  --tw-ring-color: rgb(2 132 199 / var(--tw-ring-opacity));\n}\n\n.active\\:text-white\\/80:active {\n  color: rgb(255 255 255 / 0.8);\n}\n\n.phx-submit-loading\\:opacity-75.phx-submit-loading {\n  opacity: 0.75;\n}\n\n.phx-submit-loading .phx-submit-loading\\:opacity-75 {\n  opacity: 0.75;\n}\n\n@media (min-width: 640px) {\n  .sm\\:rounded-lg {\n    border-radius: 0.5rem;\n  }\n}\n\n@media (min-width: 768px) {\n  .md\\:col-span-3 {\n    grid-column: span 3 / span 3;\n  }\n\n  .md\\:mt-0 {\n    margin-top: 0px;\n  }\n\n  .md\\:block {\n    display: block;\n  }\n\n  .md\\:hidden {\n    display: none;\n  }\n\n  .md\\:w-auto {\n    width: auto;\n  }\n\n  .md\\:grid-cols-4 {\n    grid-template-columns: repeat(4, minmax(0, 1fr));\n  }\n\n  .md\\:flex-row {\n    flex-direction: row;\n  }\n\n  .md\\:space-x-3 > :not([hidden]) ~ :not([hidden]) {\n    --tw-space-x-reverse: 0;\n    margin-right: calc(0.75rem * var(--tw-space-x-reverse));\n    margin-left: calc(0.75rem * calc(1 - var(--tw-space-x-reverse)));\n  }\n\n  .md\\:space-x-8 > :not([hidden]) ~ :not([hidden]) {\n    --tw-space-x-reverse: 0;\n    margin-right: calc(2rem * var(--tw-space-x-reverse));\n    margin-left: calc(2rem * calc(1 - var(--tw-space-x-reverse)));\n  }\n\n  .md\\:border-0 {\n    border-width: 0px;\n  }\n\n  .md\\:border-r {\n    border-right-width: 1px;\n  }\n\n  .md\\:border-gray-600 {\n    --tw-border-opacity: 1;\n    border-color: rgb(75 85 99 / var(--tw-border-opacity));\n  }\n\n  .md\\:bg-gray-800 {\n    --tw-bg-opacity: 1;\n    background-color: rgb(31 41 55 / var(--tw-bg-opacity));\n  }\n\n  .md\\:p-0 {\n    padding: 0px;\n  }\n\n  .md\\:pl-0 {\n    padding-left: 0px;\n  }\n\n  .md\\:hover\\:bg-transparent:hover {\n    background-color: transparent;\n  }\n\n  .md\\:hover\\:text-sky-500:hover {\n    --tw-text-opacity: 1;\n    color: rgb(14 165 233 / var(--tw-text-opacity));\n  }\n}\n\n.rtl\\:space-x-reverse:where([dir=\"rtl\"], [dir=\"rtl\"] *) > :not([hidden]) ~ :not([hidden]) {\n  --tw-space-x-reverse: 1;\n}\n\n.rtl\\:text-right:where([dir=\"rtl\"], [dir=\"rtl\"] *) {\n  text-align: right;\n}\n"
  },
  {
    "path": "priv/static/app.js",
    "content": "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=1<e?1:e,u()),o},hide:function(){clearTimeout(h),h=null,c&&(c=!1,d!=null&&(n.cancelAnimationFrame(d),d=null),function e(){return 1<=l.progress(\"+.1\")&&(t.style.opacity-=0.05,t.style.opacity<=0.05)?(t.style.display=\"none\",void(p=null)):void(p=n.requestAnimationFrame(e))}())}};typeof g==\"object\"&&typeof m==\"object\"?g.exports=l:typeof define==\"function\"&&define.amd?define(function(){return l}):this.topbar=l}).call(m,window,document)});var f=I(y(),1),A=document.querySelector(\"meta[name='csrf-token']\").getAttribute(\"content\"),B=document.querySelector(\"meta[name='live-path']\").getAttribute(\"content\"),M=document.querySelector(\"meta[name='live-transport']\").getAttribute(\"content\"),N={JsonPrettyPrint:{mounted(){this.formatJson()},updated(){this.formatJson()},formatJson(){try{const n=this.el.textContent.trim(),s=JSON.stringify(JSON.parse(n),null,2);this.el.textContent=s}catch(n){console.error(\"Error formatting JSON:\",n)}}}},T=new LiveView.LiveSocket(B,Phoenix.Socket,{transport:M===\"longpoll\"?Phoenix.LongPoll:WebSocket,params:{_csrf_token:A},hooks:N});f.default.config({barColors:{0:\"#29d\"},shadowColor:\"rgba(0, 0, 0, .3)\"});window.addEventListener(\"phx:page-loading-start\",(n)=>f.default.show(300));window.addEventListener(\"phx:page-loading-stop\",(n)=>f.default.hide());T.connect();window.liveSocket=T;\n"
  },
  {
    "path": "test/error_tracker/filter_test.exs",
    "content": "defmodule ErrorTracker.FilterTest do\n  use ErrorTracker.Test.Case\n\n  setup context do\n    if filter = context[:filter] do\n      previous_setting = Application.get_env(:error_tracker, :filter)\n      Application.put_env(:error_tracker, :filter, filter)\n      # Ensure that the application env is restored after each test\n      on_exit(fn -> Application.put_env(:error_tracker, :filter, previous_setting) end)\n    end\n\n    []\n  end\n\n  @sensitive_ctx %{\n    \"request\" => %{\n      \"headers\" => %{\n        \"accept\" => \"application/json, text/plain, */*\",\n        \"authorization\" => \"Bearer 12341234\"\n      }\n    }\n  }\n\n  test \"without an filter, context objects are saved as they are.\" do\n    assert %ErrorTracker.Occurrence{context: ctx} =\n             report_error(fn -> raise \"BOOM\" end, @sensitive_ctx)\n\n    assert ctx == @sensitive_ctx\n  end\n\n  @tag filter: ErrorTracker.FilterTest.AuthHeaderHider\n  test \"user defined filter should be used to sanitize the context before it's saved.\" do\n    assert %ErrorTracker.Occurrence{context: ctx} =\n             report_error(fn -> raise \"BOOM\" end, @sensitive_ctx)\n\n    assert ctx != @sensitive_ctx\n\n    cleaned_header_value =\n      ctx |> Map.get(\"request\") |> Map.get(\"headers\") |> Map.get(\"authorization\")\n\n    assert cleaned_header_value == \"REMOVED\"\n  end\nend\n\ndefmodule ErrorTracker.FilterTest.AuthHeaderHider do\n  @moduledoc false\n  @behaviour ErrorTracker.Filter\n\n  def sanitize(context) do\n    context\n    |> Enum.map(fn\n      {\"authorization\", _} ->\n        {\"authorization\", \"REMOVED\"}\n\n      o ->\n        o\n    end)\n    |> Map.new(fn\n      {key, val} when is_map(val) -> {key, sanitize(val)}\n      o -> o\n    end)\n  end\nend\n"
  },
  {
    "path": "test/error_tracker/ignorer_test.exs",
    "content": "defmodule ErrorTracker.IgnorerTest do\n  use ErrorTracker.Test.Case\n\n  setup context do\n    if ignorer = context[:ignorer] do\n      previous_setting = Application.get_env(:error_tracker, :ignorer)\n      Application.put_env(:error_tracker, :ignorer, ignorer)\n      # Ensure that the application env is restored after each test\n      on_exit(fn -> Application.put_env(:error_tracker, :ignorer, previous_setting) end)\n    end\n\n    []\n  end\n\n  @tag ignorer: ErrorTracker.EveryErrorIgnorer\n  test \"with an ignorer ignores errors\" do\n    assert :noop = report_error(fn -> raise \"[IGNORE] Sample error\" end)\n    assert %ErrorTracker.Occurrence{} = report_error(fn -> raise \"Sample error\" end)\n  end\n\n  @tag ignorer: false\n  test \"without an ignorer does not ignore errors\" do\n    assert %ErrorTracker.Occurrence{} = report_error(fn -> raise \"[IGNORE] Sample error\" end)\n    assert %ErrorTracker.Occurrence{} = report_error(fn -> raise \"Sample error\" end)\n  end\nend\n\ndefmodule ErrorTracker.EveryErrorIgnorer do\n  @moduledoc false\n  @behaviour ErrorTracker.Ignorer\n\n  @impl true\n  def ignore?(error, _context) do\n    String.contains?(error.reason, \"[IGNORE]\")\n  end\nend\n"
  },
  {
    "path": "test/error_tracker/schemas/occurrence_test.exs",
    "content": "defmodule ErrorTracker.OccurrenceTest do\n  use ErrorTracker.Test.Case\n\n  import Ecto.Changeset\n\n  alias ErrorTracker.Occurrence\n  alias ErrorTracker.Stacktrace\n\n  describe inspect(&Occurrence.changeset/2) do\n    test \"works as expected with valid data\" do\n      attrs = %{context: %{foo: :bar}, reason: \"Test reason\", stacktrace: %Stacktrace{}}\n      changeset = Occurrence.changeset(%Occurrence{}, attrs)\n\n      assert changeset.valid?\n    end\n\n    test \"validates required fields\" do\n      changeset = Occurrence.changeset(%Occurrence{}, %{})\n\n      refute changeset.valid?\n      assert {_, [validation: :required]} = changeset.errors[:reason]\n      assert {_, [validation: :required]} = changeset.errors[:stacktrace]\n    end\n\n    @tag capture_log: true\n    test \"if context is not serializable, an error messgae is stored\" do\n      attrs = %{\n        context: %{foo: %ErrorTracker.Error{}},\n        reason: \"Test reason\",\n        stacktrace: %Stacktrace{}\n      }\n\n      changeset = Occurrence.changeset(%Occurrence{}, attrs)\n\n      assert %{error: err} = get_field(changeset, :context)\n      assert err =~ \"not serializable to JSON\"\n    end\n  end\nend\n"
  },
  {
    "path": "test/error_tracker/telemetry_test.exs",
    "content": "defmodule ErrorTracker.TelemetryTest do\n  use ErrorTracker.Test.Case\n\n  alias ErrorTracker.Error\n  alias ErrorTracker.Occurrence\n\n  setup do\n    attach_telemetry()\n\n    :ok\n  end\n\n  test \"events are emitted for new errors\" do\n    {exception, stacktrace} =\n      try do\n        raise \"This is a test\"\n      rescue\n        e -> {e, __STACKTRACE__}\n      end\n\n    # Since the error is new, both the new error and new occurrence events will be emitted\n    %Occurrence{error: error = %Error{}} = ErrorTracker.report(exception, stacktrace)\n    assert_receive {:telemetry_event, [:error_tracker, :error, :new], _, %{error: %Error{}}}\n\n    assert_receive {:telemetry_event, [:error_tracker, :occurrence, :new], _, %{occurrence: %Occurrence{}, muted: false}}\n\n    # The error is already known so the new error event won't be emitted\n    ErrorTracker.report(exception, stacktrace)\n\n    refute_receive {:telemetry_event, [:error_tracker, :error, :new], _, %{error: %Error{}}},\n                   150\n\n    assert_receive {:telemetry_event, [:error_tracker, :occurrence, :new], _, %{occurrence: %Occurrence{}, muted: false}}\n\n    # The error is muted so the new occurrence event will include the muted=true metadata\n    ErrorTracker.mute(error)\n    ErrorTracker.report(exception, stacktrace)\n\n    assert_receive {:telemetry_event, [:error_tracker, :occurrence, :new], _, %{occurrence: %Occurrence{}, muted: true}}\n  end\n\n  test \"events are emitted for resolved and unresolved errors\" do\n    %Occurrence{error: error = %Error{}} = report_error(fn -> raise \"This is a test\" end)\n\n    # The resolved event will be emitted\n    {:ok, resolved = %Error{}} = ErrorTracker.resolve(error)\n    assert_receive {:telemetry_event, [:error_tracker, :error, :resolved], _, %{error: %Error{}}}\n\n    # The unresolved event will be emitted\n    {:ok, _unresolved} = ErrorTracker.unresolve(resolved)\n\n    assert_receive {:telemetry_event, [:error_tracker, :error, :unresolved], _, %{error: %Error{}}}\n  end\nend\n"
  },
  {
    "path": "test/error_tracker_test.exs",
    "content": "defmodule ErrorTrackerTest do\n  use ErrorTracker.Test.Case\n\n  alias ErrorTracker.Error\n  alias ErrorTracker.Occurrence\n\n  # We use this file path because for some reason the test scripts are not\n  # handled as part of the application, so the last line of the app executed is\n  # on the case module.\n  @relative_file_path \"test/support/case.ex\"\n\n  describe inspect(&ErrorTracker.report/3) do\n    setup context do\n      if Map.has_key?(context, :enabled) do\n        Application.put_env(:error_tracker, :enabled, context[:enabled])\n        # Ensure that the application env is restored after each test\n        on_exit(fn -> Application.delete_env(:error_tracker, :enabled) end)\n      end\n\n      []\n    end\n\n    test \"reports exceptions\" do\n      %Occurrence{error: error = %Error{}} =\n        report_error(fn -> raise \"This is a test\" end)\n\n      assert error.kind == to_string(RuntimeError)\n      assert error.reason == \"This is a test\"\n      assert error.source_line =~ @relative_file_path\n    end\n\n    test \"reports badarith errors\" do\n      string_var = to_string(1)\n\n      %Occurrence{error: error = %Error{}, stacktrace: %{lines: [last_line | _]}} =\n        report_error(fn -> 1 + string_var end)\n\n      assert error.kind == to_string(ArithmeticError)\n      assert error.reason == \"bad argument in arithmetic expression\"\n\n      # Elixir 1.17.0 reports these errors differently than previous versions\n      if Version.compare(System.version(), \"1.17.0\") == :lt do\n        assert last_line.module == \"ErrorTrackerTest\"\n        assert last_line.function =~ \"&ErrorTracker.report/3 reports badarith errors\"\n        assert last_line.arity == 1\n        assert last_line.file\n        assert last_line.line\n      else\n        assert last_line.module == \"erlang\"\n        assert last_line.function == \"+\"\n        assert last_line.arity == 2\n        refute last_line.file\n        refute last_line.line\n      end\n    end\n\n    test \"reports undefined function errors\" do\n      # This function does not exist and will raise when called\n      {m, f, a} = {ErrorTracker, :invalid_fun, []}\n\n      %Occurrence{error: error = %Error{}} =\n        report_error(fn -> apply(m, f, a) end)\n\n      assert error.kind == to_string(UndefinedFunctionError)\n      assert error.reason =~ \"is undefined or private\"\n      assert error.source_function == Exception.format_mfa(m, f, Enum.count(a))\n      assert error.source_line == \"(nofile)\"\n    end\n\n    test \"reports throws\" do\n      %Occurrence{error: error = %Error{}} =\n        report_error(fn -> throw(\"This is a test\") end)\n\n      assert error.kind == \"throw\"\n      assert error.reason == \"This is a test\"\n      assert error.source_line =~ @relative_file_path\n    end\n\n    test \"reports exits\" do\n      %Occurrence{error: error = %Error{}} =\n        report_error(fn -> exit(\"This is a test\") end)\n\n      assert error.kind == \"exit\"\n      assert error.reason == \"This is a test\"\n      assert error.source_line =~ @relative_file_path\n    end\n\n    @tag capture_log: true\n    test \"reports errors with invalid context\" do\n      # It's invalid because cannot be serialized to JSON\n      invalid_context = %{foo: %Error{}}\n\n      assert %Occurrence{} = report_error(fn -> raise \"test\" end, invalid_context)\n    end\n\n    test \"without enabled flag it works as expected\" do\n      # Ensure no value is set\n      Application.delete_env(:error_tracker, :enabled)\n\n      assert %Occurrence{} = report_error(fn -> raise \"Sample error\" end)\n    end\n\n    @tag enabled: true\n    test \"with enabled flag to true it works as expected\" do\n      assert %Occurrence{} = report_error(fn -> raise \"Sample error\" end)\n    end\n\n    @tag enabled: false\n    test \"with enabled flag to false it does not store the exception\" do\n      assert report_error(fn -> raise \"Sample error\" end) == :noop\n    end\n\n    test \"includes breadcrumbs if present\" do\n      breadcrumbs = [\"breadcrumb 1\", \"breadcrumb 2\"]\n\n      occurrence =\n        report_error(fn ->\n          raise ErrorWithBreadcrumbs, message: \"test\", bread_crumbs: breadcrumbs\n        end)\n\n      assert occurrence.breadcrumbs == breadcrumbs\n    end\n\n    test \"includes breadcrumbs if stored by the user\" do\n      ErrorTracker.add_breadcrumb(\"breadcrumb 1\")\n      ErrorTracker.add_breadcrumb(\"breadcrumb 2\")\n\n      occurrence = report_error(fn -> raise \"Sample error\" end)\n\n      assert occurrence.breadcrumbs == [\"breadcrumb 1\", \"breadcrumb 2\"]\n    end\n\n    test \"merges breadcrumbs stored by the user and contained on the exception\" do\n      ErrorTracker.add_breadcrumb(\"breadcrumb 1\")\n      ErrorTracker.add_breadcrumb(\"breadcrumb 2\")\n\n      occurrence =\n        report_error(fn ->\n          raise ErrorWithBreadcrumbs, message: \"test\", bread_crumbs: [\"breadcrumb 3\"]\n        end)\n\n      assert occurrence.breadcrumbs == [\"breadcrumb 1\", \"breadcrumb 2\", \"breadcrumb 3\"]\n    end\n  end\n\n  describe inspect(&ErrorTracker.resolve/1) do\n    test \"marks the error as resolved\" do\n      %Occurrence{error: error} = report_error(fn -> raise \"This is a test\" end)\n\n      assert {:ok, %Error{status: :resolved}} = ErrorTracker.resolve(error)\n    end\n  end\n\n  describe inspect(&ErrorTracker.unresolve/1) do\n    test \"marks the error as unresolved\" do\n      %Occurrence{error: error} = report_error(fn -> raise \"This is a test\" end)\n      # Manually mark the error as resolved\n      {:ok, resolved} = ErrorTracker.resolve(error)\n\n      assert {:ok, %Error{status: :unresolved}} = ErrorTracker.unresolve(resolved)\n    end\n  end\n\n  describe inspect(&ErrorTracker.add_breadcrumb/1) do\n    test \"adds an entry to the breadcrumbs list\" do\n      ErrorTracker.add_breadcrumb(\"breadcrumb 1\")\n      ErrorTracker.add_breadcrumb(\"breadcrumb 2\")\n\n      assert [\"breadcrumb 1\", \"breadcrumb 2\"] = ErrorTracker.get_breadcrumbs()\n    end\n  end\nend\n\ndefmodule ErrorWithBreadcrumbs do\n  @moduledoc false\n  defexception [:message, :bread_crumbs]\nend\n"
  },
  {
    "path": "test/integrations/plug_test.exs",
    "content": "defmodule ErrorTracker.Integrations.PlugTest do\n  use ErrorTracker.Test.Case\n\n  alias ErrorTracker.Integrations.Plug, as: IntegrationPlug\n\n  @fake_callstack []\n\n  setup do\n    [conn: Phoenix.ConnTest.build_conn()]\n  end\n\n  test \"it reports errors, including the request headers\", %{conn: conn} do\n    conn = Plug.Conn.put_req_header(conn, \"accept\", \"application/json\")\n\n    IntegrationPlug.report_error(\n      conn,\n      {\"an error from Phoenix\", \"something bad happened\"},\n      @fake_callstack\n    )\n\n    [error] = repo().all(ErrorTracker.Error)\n\n    assert error.kind == \"an error from Phoenix\"\n    assert error.reason == \"something bad happened\"\n\n    [occurrence] = repo().all(ErrorTracker.Occurrence)\n    assert occurrence.error_id == error.id\n\n    %{\"request.headers\" => request_headers} = occurrence.context\n    assert request_headers == %{\"accept\" => \"application/json\"}\n  end\n\n  test \"it does not save sensitive request headers, to avoid storing them in cleartext\", %{\n    conn: conn\n  } do\n    conn =\n      conn\n      |> Plug.Conn.put_req_header(\"cookie\", \"who stole the cookie from the cookie jar ?\")\n      |> Plug.Conn.put_req_header(\"authorization\", \"Bearer plz-dont-leak-my-secrets\")\n      |> Plug.Conn.put_req_header(\"safe\", \"this can be safely stored in cleartext\")\n\n    IntegrationPlug.report_error(\n      conn,\n      {\"an error from Phoenix\", \"something bad happened\"},\n      @fake_callstack\n    )\n\n    [occurrence] = repo().all(ErrorTracker.Occurrence)\n\n    assert occurrence.context[\"request.headers\"][\"cookie\"] == \"[REDACTED]\"\n    assert occurrence.context[\"request.headers\"][\"authorization\"] == \"[REDACTED]\"\n    assert occurrence.context[\"request.headers\"][\"safe\"] != \"[REDACTED]\"\n  end\nend\n"
  },
  {
    "path": "test/support/case.ex",
    "content": "defmodule ErrorTracker.Test.Case do\n  @moduledoc false\n  use ExUnit.CaseTemplate\n\n  using do\n    quote do\n      import Ecto.Query\n      import ErrorTracker.Test.Case\n    end\n  end\n\n  setup do\n    Ecto.Adapters.SQL.Sandbox.checkout(repo())\n  end\n\n  @doc \"\"\"\n  Reports the error produced by the given function.\n  \"\"\"\n  def report_error(fun, context \\\\ %{}) do\n    occurrence =\n      try do\n        fun.()\n      rescue\n        exception ->\n          ErrorTracker.report(exception, __STACKTRACE__, context)\n      catch\n        kind, reason ->\n          ErrorTracker.report({kind, reason}, __STACKTRACE__, context)\n      end\n\n    case occurrence do\n      %ErrorTracker.Occurrence{} -> repo().preload(occurrence, :error)\n      other -> other\n    end\n  end\n\n  @doc \"\"\"\n  Sends telemetry events as messages to the current process.\n\n  This allows test cases to check that telemetry events are fired with:\n\n      assert_receive {:telemetry_event, event, measurements, metadata}\n  \"\"\"\n  def attach_telemetry do\n    :telemetry.attach_many(\n      \"telemetry-test\",\n      [\n        [:error_tracker, :error, :new],\n        [:error_tracker, :error, :resolved],\n        [:error_tracker, :error, :unresolved],\n        [:error_tracker, :occurrence, :new]\n      ],\n      &__MODULE__._send_telemetry/4,\n      nil\n    )\n  end\n\n  def _send_telemetry(event, measurements, metadata, _opts) do\n    send(self(), {:telemetry_event, event, measurements, metadata})\n  end\n\n  def repo do\n    Application.fetch_env!(:error_tracker, :repo)\n  end\nend\n"
  },
  {
    "path": "test/support/lite_repo.ex",
    "content": "defmodule ErrorTracker.Test.LiteRepo do\n  @moduledoc false\n  use Ecto.Repo, otp_app: :error_tracker, adapter: Ecto.Adapters.SQLite3\nend\n"
  },
  {
    "path": "test/support/mysql_repo.ex",
    "content": "defmodule ErrorTracker.Test.MySQLRepo do\n  @moduledoc false\n  use Ecto.Repo, otp_app: :error_tracker, adapter: Ecto.Adapters.MyXQL\nend\n"
  },
  {
    "path": "test/support/repo.ex",
    "content": "defmodule ErrorTracker.Test.Repo do\n  @moduledoc false\n  use Ecto.Repo, otp_app: :error_tracker, adapter: Ecto.Adapters.Postgres\nend\n"
  },
  {
    "path": "test/test_helper.exs",
    "content": "# Use the appropriate repo for the desired database\nrepo =\n  case System.get_env(\"DB\") do\n    \"sqlite\" ->\n      ErrorTracker.Test.LiteRepo\n\n    \"mysql\" ->\n      ErrorTracker.Test.MySQLRepo\n\n    \"postgres\" ->\n      ErrorTracker.Test.Repo\n\n    _other ->\n      raise \"Please run either `DB=sqlite mix test`, `DB=postgres mix test` or `DB=mysql mix test`\"\n  end\n\nApplication.put_env(:error_tracker, :repo, repo)\n\n# Create the database and start the repo\nrepo.__adapter__().storage_up(repo.config())\nrepo.start_link()\n\n# Run migrations\nEcto.Migrator.run(repo, :up, all: true, log_migrations_sql: false, log: false)\n\nExUnit.start()\n\nEcto.Adapters.SQL.Sandbox.mode(repo, :manual)\n"
  }
]