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