├── .formatter.exs ├── .github ├── dependabot.yml └── workflows │ ├── elixir.yml │ └── publish.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── dummy └── mixapp │ ├── .gitignore │ ├── README.md │ ├── lib │ └── mixapp │ │ └── application.ex │ ├── mix.exs │ ├── mix.lock │ └── test │ ├── mixapp_test.exs │ └── test_helper.exs ├── lib ├── honeybadger.ex ├── honeybadger │ ├── backtrace.ex │ ├── breadcrumbs │ │ ├── breadcrumb.ex │ │ ├── collector.ex │ │ ├── ring_buffer.ex │ │ └── telemetry.ex │ ├── client.ex │ ├── event_context.ex │ ├── event_filter.ex │ ├── event_filter │ │ ├── default.ex │ │ └── mixin.ex │ ├── events_sampler.ex │ ├── events_worker.ex │ ├── exclude_errors.ex │ ├── exclude_errors │ │ └── default.ex │ ├── filter.ex │ ├── filter │ │ ├── default.ex │ │ └── mixin.ex │ ├── fingerprint_adapter.ex │ ├── http_adapter.ex │ ├── http_adapter │ │ ├── hackney.ex │ │ └── req.ex │ ├── insights │ │ ├── absinthe.ex │ │ ├── base.ex │ │ ├── ecto.ex │ │ ├── finch.ex │ │ ├── live_view.ex │ │ ├── oban.ex │ │ ├── plug.ex │ │ └── tesla.ex │ ├── invalid_response_error.ex │ ├── json.ex │ ├── logger.ex │ ├── notice.ex │ ├── notice_filter.ex │ ├── notice_filter │ │ └── default.ex │ ├── plug.ex │ ├── plug_data.ex │ ├── server_unreachable_error.ex │ └── utils.ex └── mix │ └── tasks │ └── test.ex ├── mix.exs ├── mix.lock ├── mix └── tasks │ └── hex_release.ex └── test ├── honeybadger ├── backtrace_test.exs ├── breadcrumbs │ ├── collector_test.exs │ ├── ring_buffer_test.exs │ └── telemetry_test.exs ├── client_test.exs ├── event_context_test.exs ├── events_sampler_test.exs ├── events_worker_test.exs ├── fingerprint_adapter_test.exs ├── http_adapter │ ├── hackney_test.exs │ └── req_test.exs ├── http_adapter_test.exs ├── insights │ ├── absinthe_test.exs │ ├── base_test.exs │ ├── ecto_test.exs │ ├── finch_test.exs │ ├── live_view_test.exs │ ├── oban_test.exs │ ├── plug_test.exs │ └── tesla_test.exs ├── json_test.exs ├── logger_test.exs ├── notice_test.exs ├── plug_data_test.exs ├── plug_test.exs └── utils_test.exs ├── honeybadger_test.exs ├── support └── insights_case.ex └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["{mix,.formatter}.exs", "{lib,test}/**/*.{ex,exs}"], 3 | import_deps: [], 4 | locals_without_parens: [] 5 | ] 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: mix 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "09:00" 8 | timezone: America/Los_Angeles 9 | open-pull-requests-limit: 99 10 | ignore: 11 | - dependency-name: hackney 12 | versions: 13 | - 1.17.1 14 | - 1.17.2 15 | - 1.17.3 16 | - dependency-name: ex_doc 17 | versions: 18 | - 0.24.0 19 | - dependency-name: ecto 20 | versions: 21 | - 3.5.6 22 | - package-ecosystem: "github-actions" 23 | directory: "/" 24 | schedule: 25 | interval: "weekly" 26 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | name: Elixir CI 2 | 3 | on: 4 | workflow_call: 5 | push: 6 | branches: [ master ] 7 | pull_request: 8 | branches: [ master ] 9 | 10 | jobs: 11 | build: 12 | name: Build and test 13 | 14 | env: 15 | MIX_ENV: test 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | include: 20 | - pair: 21 | elixir: '1.15' 22 | otp: '24.3' 23 | - pair: 24 | elixir: '1.16' 25 | otp: '26.0' 26 | - pair: 27 | elixir: '1.17' 28 | otp: '27.0' 29 | - pair: 30 | elixir: '1.18' 31 | otp: '27.0' 32 | lint: lint 33 | 34 | runs-on: ubuntu-22.04 35 | 36 | steps: 37 | - uses: actions/checkout@v4 38 | 39 | - uses: erlef/setup-elixir@v1 40 | with: 41 | otp-version: ${{matrix.pair.otp}} 42 | elixir-version: ${{matrix.pair.elixir}} 43 | 44 | - name: Restore dependencies cache 45 | uses: actions/cache@v4 46 | with: 47 | path: deps 48 | key: ${{ runner.os }}-${{ matrix.pair.otp }}-${{ matrix.pair.elixir }}-mix-${{ hashFiles('**/mix.lock') }} 49 | restore-keys: ${{ runner.os }}-${{ matrix.pair.otp }}-${{ matrix.pair.elixir }}-mix- 50 | 51 | - name: Run mix deps.get 52 | run: mix deps.ci 53 | 54 | - name: Run mix format 55 | run: mix format --check-formatted 56 | if: ${{ matrix.lint }} 57 | 58 | - name: Run mix deps.compile 59 | run: mix deps.compile 60 | 61 | - name: Run mix compile 62 | run: mix compile --warnings-as-errors 63 | if: ${{ matrix.lint }} 64 | 65 | - name: Run tests 66 | run: mix test.ci 67 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish New Release 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | version: 6 | type: choice 7 | description: What version to publish? 8 | options: 9 | - patch 10 | - minor 11 | - major 12 | changes: 13 | description: Specify an entry for the changelog 14 | required: true 15 | 16 | jobs: 17 | ci: 18 | uses: ./.github/workflows/elixir.yml 19 | secrets: inherit 20 | 21 | publish: 22 | needs: [ci] 23 | name: Build and publish to hex.pm 24 | runs-on: ubuntu-22.04 25 | env: 26 | HEX_API_KEY: ${{ secrets.HEX_API_KEY }} 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v4 30 | with: 31 | token: ${{ secrets.GH_PUBLISH_PAT }} 32 | fetch-depth: 0 33 | ref: master 34 | 35 | - name: Git Config 36 | run: | 37 | git config --global user.email "github-actions[bot]@users.noreply.github.com" 38 | git config --global user.name "github-actions[bot]" 39 | 40 | - name: Setup Elixir 41 | uses: erlef/setup-elixir@v1 42 | with: 43 | otp-version: '24.3' 44 | elixir-version: '1.15' 45 | 46 | - name: Restore dependencies cache 47 | uses: actions/cache@v4 48 | with: 49 | path: deps 50 | key: ${{ runner.os }}-publish-mix-${{ hashFiles('**/mix.lock') }} 51 | restore-keys: ${{ runner.os }}-publish-mix- 52 | 53 | - name: Install deps 54 | run: mix deps.get 55 | 56 | - name: Add changelog entry 57 | run: echo "${{ inputs.changes }}" > RELEASE.md 58 | 59 | - name: Bump version, generate changelog, push to git, publish on hex.pm 60 | run: mix expublish.${{ inputs.version }} --disable-test 61 | 62 | 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /deps 3 | erl_crash.dump 4 | *.ez 5 | *.beam 6 | .iex.exs 7 | /doc 8 | RELEASE.md 9 | .idea 10 | .tool-versions 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Honeybadger Industries LLC. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /dummy/mixapp/.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | -------------------------------------------------------------------------------- /dummy/mixapp/README.md: -------------------------------------------------------------------------------- 1 | # Mixapp 2 | 3 | This is a test app which includes honeybadger as a dependency in the context 4 | of a simple mix app and runs a few integration tests. 5 | -------------------------------------------------------------------------------- /dummy/mixapp/lib/mixapp/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Mixapp.Application do 2 | @moduledoc false 3 | 4 | use Application 5 | 6 | def start(_type, _args) do 7 | Supervisor.start_link([], strategy: :one_for_one, name: Mixapp.Supervisor) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /dummy/mixapp/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Mixapp.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :mixapp, 7 | version: "0.1.0", 8 | elixir: "~> 1.3", 9 | start_permanent: Mix.env == :prod, 10 | deps: deps() 11 | ] 12 | end 13 | 14 | # Run "mix help compile.app" to learn about applications. 15 | def application do 16 | [ 17 | extra_applications: [:logger], 18 | mod: {Mixapp.Application, []} 19 | ] 20 | end 21 | 22 | # Run "mix help deps" to learn about dependencies. 23 | defp deps do 24 | [ 25 | {:honeybadger, path: "../../"}, 26 | {:req, "~> 0.5.0", only: [:test]} 27 | ] 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /dummy/mixapp/mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, 3 | "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, 4 | "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, 5 | "hpax": {:hex, :hpax, "1.0.2", "762df951b0c399ff67cc57c3995ec3cf46d696e41f0bba17da0518d94acd4aac", [:mix], [], "hexpm", "2f09b4c1074e0abd846747329eaa26d535be0eb3d189fa69d812bfb8bfefd32f"}, 6 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 7 | "jason": {:hex, :jason, "1.0.0", "0f7cfa9bdb23fed721ec05419bcee2b2c21a77e926bce0deda029b5adc716fe2", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b96c400e04b7b765c0854c05a4966323e90c0d11fee0483b1567cda079abb205"}, 8 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 9 | "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, 10 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 11 | "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, 12 | "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 13 | "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, 14 | "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, 15 | "plug": {:hex, :plug, "1.4.3", "236d77ce7bf3e3a2668dc0d32a9b6f1f9b1f05361019946aae49874904be4aed", [], [{:cowboy, "~> 1.0.1 or ~> 1.1", [hex: :cowboy, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}], "hexpm"}, 16 | "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"}, 17 | "req": {:hex, :req, "0.5.8", "50d8d65279d6e343a5e46980ac2a70e97136182950833a1968b371e753f6a662", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "d7fc5898a566477e174f26887821a3c5082b243885520ee4b45555f5d53f40ef"}, 18 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, 19 | "telemetry": {:hex, :telemetry, "0.4.0", "8339bee3fa8b91cb84d14c2935f8ecf399ccd87301ad6da6b71c09553834b2ab", [:rebar3], [], "hexpm", "e9e3cacfd37c1531c0ca70ca7c0c30ce2dbb02998a4f7719de180fe63f8d41e4"}, 20 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 21 | } 22 | -------------------------------------------------------------------------------- /dummy/mixapp/test/mixapp_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MixappTest do 2 | use ExUnit.Case 3 | 4 | test "does not crash when HONEYBADGER_API_KEY is not set" do 5 | assert System.get_env("HONEYBADGER_API_KEY") == nil 6 | # the app would crash before this assert if there was an error 7 | assert 1 == 1 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /dummy/mixapp/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /lib/honeybadger/backtrace.ex: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.Backtrace do 2 | @moduledoc false 3 | 4 | @typep location :: {:file, binary()} | {:line, pos_integer()} 5 | @typep stack_item :: {module(), atom(), arity() | [term()], [location()]} 6 | 7 | @type line :: %{ 8 | file: binary(), 9 | method: binary(), 10 | args: [binary()], 11 | number: pos_integer(), 12 | context: binary() 13 | } 14 | 15 | @type t :: [line()] 16 | 17 | @inspect_opts charlists: :as_lists, 18 | limit: 5, 19 | printable_limit: 1024, 20 | pretty: false 21 | 22 | @spec from_stacktrace([stack_item()]) :: list(map) 23 | def from_stacktrace(stacktrace) when is_list(stacktrace) do 24 | Enum.map(stacktrace, &format_line/1) 25 | end 26 | 27 | defp format_line({mod, fun, args, extra_info}) do 28 | app = Honeybadger.get_env(:app) 29 | filter_args = Honeybadger.get_env(:filter_args) 30 | file = Keyword.get(extra_info, :file) 31 | line = Keyword.get(extra_info, :line) 32 | 33 | %{ 34 | file: format_file(file), 35 | method: format_method(fun, args), 36 | args: format_args(args, filter_args), 37 | number: line, 38 | context: app_context(app, Application.get_application(mod)) 39 | } 40 | end 41 | 42 | defp app_context(app, app) when not is_nil(app), do: "app" 43 | defp app_context(_app1, _app2), do: "all" 44 | 45 | defp format_file(""), do: nil 46 | defp format_file(file) when is_binary(file), do: file 47 | defp format_file(file), do: file |> to_string() |> format_file() 48 | 49 | defp format_method(fun, args) when is_list(args), do: format_method(fun, length(args)) 50 | defp format_method(fun, arity) when is_integer(arity), do: "#{fun}/#{arity}" 51 | 52 | defp format_args(args, false = _filter) when is_list(args) do 53 | Enum.map(args, &Kernel.inspect(&1, @inspect_opts)) 54 | end 55 | 56 | defp format_args(_args_or_arity, _filter) do 57 | [] 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/honeybadger/breadcrumbs/breadcrumb.ex: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.Breadcrumbs.Breadcrumb do 2 | @moduledoc false 3 | 4 | @derive Jason.Encoder 5 | 6 | @type t :: %__MODULE__{ 7 | message: String.t(), 8 | category: String.t(), 9 | timestamp: DateTime.t(), 10 | metadata: map() 11 | } 12 | 13 | @type opts :: [{:metadata, map()} | {:category, String.t()}] 14 | @enforce_keys [:message, :category, :timestamp, :metadata] 15 | 16 | @default_category "custom" 17 | @default_metadata %{} 18 | 19 | defstruct [:message, :category, :timestamp, :metadata] 20 | 21 | @spec new(String.t(), opts()) :: t() 22 | def new(message, opts) do 23 | %__MODULE__{ 24 | message: message, 25 | category: opts[:category] || @default_category, 26 | timestamp: DateTime.utc_now(), 27 | metadata: opts[:metadata] || @default_metadata 28 | } 29 | end 30 | 31 | @spec from_error(any()) :: t() 32 | def from_error(error) do 33 | error = Exception.normalize(:error, error, []) 34 | 35 | %{__struct__: error_mod} = error 36 | 37 | new( 38 | Honeybadger.Utils.module_to_string(error_mod), 39 | metadata: %{message: error_mod.message(error)}, 40 | category: "error" 41 | ) 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/honeybadger/breadcrumbs/collector.ex: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.Breadcrumbs.Collector do 2 | @moduledoc false 3 | 4 | # The Collector provides an interface for accessing and affecting the current set of 5 | # breadcrumbs. Most operations are delegated to the supplied Buffer implementation. This is 6 | # mainly for internal use. 7 | 8 | alias Honeybadger.Breadcrumbs.{RingBuffer, Breadcrumb} 9 | alias Honeybadger.Utils 10 | 11 | @buffer_size 40 12 | @collector_key :hb_breadcrumbs 13 | 14 | @type t :: %{enabled: boolean(), trail: [Breadcrumb.t()]} 15 | 16 | def key, do: @collector_key 17 | 18 | @spec output() :: t() 19 | def output(), do: output(breadcrumbs()) 20 | 21 | @spec output(RingBuffer.t()) :: t() 22 | def output(breadcrumbs) do 23 | %{ 24 | enabled: Honeybadger.get_env(:breadcrumbs_enabled), 25 | trail: RingBuffer.to_list(breadcrumbs) 26 | } 27 | end 28 | 29 | @spec put(RingBuffer.t(), Breadcrumb.t()) :: RingBuffer.t() 30 | def put(breadcrumbs, breadcrumb) do 31 | RingBuffer.add( 32 | breadcrumbs, 33 | Map.update(breadcrumb, :metadata, %{}, &Utils.sanitize(&1, max_depth: 1)) 34 | ) 35 | end 36 | 37 | @spec add(Breadcrumb.t()) :: :ok 38 | def add(breadcrumb) do 39 | if Honeybadger.get_env(:breadcrumbs_enabled) do 40 | Process.put(@collector_key, put(breadcrumbs(), breadcrumb)) 41 | end 42 | 43 | :ok 44 | end 45 | 46 | @spec clear() :: :ok 47 | def clear() do 48 | Process.put(@collector_key, RingBuffer.new(@buffer_size)) 49 | end 50 | 51 | @spec breadcrumbs() :: RingBuffer.t() 52 | def breadcrumbs() do 53 | Process.get(@collector_key, RingBuffer.new(@buffer_size)) 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/honeybadger/breadcrumbs/ring_buffer.ex: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.Breadcrumbs.RingBuffer do 2 | @moduledoc false 3 | 4 | @type t :: %__MODULE__{buffer: [any()], size: pos_integer(), ct: non_neg_integer()} 5 | 6 | defstruct [:size, buffer: [], ct: 0] 7 | 8 | defimpl Jason.Encoder do 9 | def encode(buffer, opts) do 10 | Jason.Encode.list(Honeybadger.Breadcrumbs.RingBuffer.to_list(buffer), opts) 11 | end 12 | end 13 | 14 | @spec new(pos_integer()) :: t() 15 | def new(size) do 16 | %__MODULE__{size: size} 17 | end 18 | 19 | @spec add(t(), any()) :: t() 20 | def add(ring = %{ct: ct, size: ct, buffer: [_head | rest]}, item) do 21 | %__MODULE__{ring | buffer: rest ++ [item]} 22 | end 23 | 24 | def add(ring = %{ct: ct, buffer: buffer}, item) do 25 | %__MODULE__{ring | buffer: buffer ++ [item], ct: ct + 1} 26 | end 27 | 28 | @spec to_list(t()) :: [any()] 29 | def to_list(%{buffer: buffer}), do: buffer 30 | end 31 | -------------------------------------------------------------------------------- /lib/honeybadger/breadcrumbs/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.Breadcrumbs.Telemetry do 2 | @moduledoc false 3 | 4 | alias __MODULE__ 5 | 6 | @spec telemetry_events() :: [[atom()]] 7 | def telemetry_events do 8 | [] 9 | |> append_phoenix_events() 10 | |> append_ecto_events() 11 | end 12 | 13 | @spec attach() :: :ok 14 | def attach do 15 | :telemetry.attach_many( 16 | "hb-telemetry", 17 | telemetry_events(), 18 | &Telemetry.handle_telemetry/4, 19 | nil 20 | ) 21 | 22 | :ok 23 | end 24 | 25 | @spec append_phoenix_events([[atom()]]) :: [[atom()]] 26 | defp append_phoenix_events(events) do 27 | Enum.concat(events, [[:phoenix, :router_dispatch, :start]]) 28 | end 29 | 30 | @spec append_ecto_events([[atom()]]) :: [[atom()]] 31 | defp append_ecto_events(events) do 32 | Honeybadger.get_env(:ecto_repos) 33 | |> Enum.map(&get_telemetry_prefix/1) 34 | |> Enum.concat(events) 35 | end 36 | 37 | @spec get_telemetry_prefix(Ecto.Repo.t()) :: [atom()] 38 | defp get_telemetry_prefix(repo) do 39 | case Keyword.get(repo.config(), :telemetry_prefix) do 40 | nil -> 41 | [] 42 | 43 | telemetry_prefix -> 44 | telemetry_prefix ++ [:query] 45 | end 46 | end 47 | 48 | def handle_telemetry(_path, %{decode_time: _} = time, %{query: _} = meta, _) do 49 | time 50 | |> Map.merge(meta) 51 | |> handle_sql() 52 | end 53 | 54 | def handle_telemetry(_path, _time, %{query: _} = meta, _) do 55 | handle_sql(meta) 56 | end 57 | 58 | def handle_telemetry([:phoenix, :router_dispatch, :start], _timing, meta, _) do 59 | metadata = 60 | meta 61 | |> Map.take([:plug, :plug_opts, :route, :pipe_through]) 62 | |> Map.update(:pipe_through, "", &inspect/1) 63 | 64 | Honeybadger.add_breadcrumb("Phoenix Router Dispatch", 65 | metadata: metadata, 66 | category: "request" 67 | ) 68 | end 69 | 70 | defp handle_sql(meta) do 71 | metadata = 72 | meta 73 | |> Map.take([:query, :decode_time, :query_time, :queue_time, :source]) 74 | |> Map.update(:decode_time, nil, &time_format/1) 75 | |> Map.update(:query_time, nil, &time_format/1) 76 | |> Map.update(:queue_time, nil, &time_format/1) 77 | 78 | Honeybadger.add_breadcrumb("Ecto SQL Query (#{meta[:source]})", 79 | metadata: metadata, 80 | category: "query" 81 | ) 82 | end 83 | 84 | defp time_format(nil), do: nil 85 | 86 | defp time_format(time) do 87 | us = System.convert_time_unit(time, :native, :microsecond) 88 | ms = div(us, 100) / 10 89 | "#{:io_lib_format.fwrite_g(ms)}ms" 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /lib/honeybadger/event_context.ex: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.EventContext do 2 | @moduledoc false 3 | 4 | @key __MODULE__ 5 | 6 | @doc """ 7 | Merge the given map or keyword list into the current event context. 8 | """ 9 | @spec merge(map() | list()) :: map() 10 | def merge(kw) when is_list(kw), do: merge(Map.new(kw)) 11 | 12 | def merge(context) when is_map(context) do 13 | get() 14 | |> Map.merge(context) 15 | |> tap(&Process.put(@key, &1)) 16 | end 17 | 18 | @doc """ 19 | Replace the current event context with the given map or keyword list. 20 | """ 21 | @spec replace(map() | list()) :: map() 22 | def replace(kw) when is_list(kw), do: replace(Map.new(kw)) 23 | 24 | def replace(context) when is_map(context) do 25 | Process.put(@key, context) 26 | 27 | context 28 | end 29 | 30 | @doc """ 31 | Put a new key-value pair in the current event context if the key does not 32 | already exist. You can lazy initialize the value by passing a function that 33 | returns the value. The function will only be called if the key does not exist 34 | in the current event context. 35 | """ 36 | @spec put_new(atom(), (-> any()) | any()) :: map() 37 | def put_new(key, fun) when is_function(fun, 0) do 38 | new_context = Map.put_new_lazy(get(), key, fun) 39 | Process.put(@key, new_context) 40 | new_context 41 | end 42 | 43 | def put_new(key, value) when is_atom(key) do 44 | new_context = Map.put_new(get(), key, value) 45 | Process.put(@key, new_context) 46 | new_context 47 | end 48 | 49 | @doc """ 50 | Get the current event context map 51 | """ 52 | @spec get() :: map() 53 | def get, do: Process.get(@key, %{}) 54 | 55 | @spec get(atom()) :: any() | nil 56 | def get(key) when is_atom(key) do 57 | Map.get(get(), key) 58 | end 59 | 60 | @doc """ 61 | Get the current event context map from the closest parent process. Will not 62 | inherit if current process already has a context. 63 | """ 64 | @spec inherit() :: :already_initialized | :inherited | :not_found 65 | def inherit do 66 | if Process.get(@key) == nil do 67 | case get_from_parents() do 68 | nil -> 69 | :not_found 70 | 71 | data -> 72 | merge(data) 73 | :inherited 74 | end 75 | else 76 | :already_initialized 77 | end 78 | end 79 | 80 | @doc false 81 | defp get_from_parents, do: ProcessTree.get(@key) 82 | end 83 | -------------------------------------------------------------------------------- /lib/honeybadger/event_filter.ex: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.EventFilter do 2 | @moduledoc """ 3 | Specification for filtering instrumented events. 4 | 5 | Most users won't need this, but if you need complete control over 6 | filtering, implement this behaviour and configure like: 7 | 8 | config :honeybadger, 9 | event_filter: MyApp.MyEventFilter 10 | """ 11 | 12 | @doc """ 13 | Filters an instrumented telemetry event. 14 | 15 | ## Parameters 16 | 17 | * `data` - The current data for the event 18 | * `raw_event` - The raw event metadata 19 | * `event` - The telemetry event being processed, e.g. [:phoenix, :endpoint, :start] 20 | 21 | ## Returns 22 | 23 | The filtered metadata map that will be sent to Honeybadger or `nil` to skip 24 | the event. 25 | """ 26 | @callback filter_telemetry_event(data :: map(), raw_event :: map(), event :: [atom(), ...]) :: 27 | map() | nil 28 | 29 | @doc """ 30 | Filters all Insights events. 31 | 32 | ## Parameters 33 | 34 | * `event` - The current event map 35 | 36 | ## Returns 37 | 38 | The filtered metadata map that will be sent to Honeybadger or `nil` to skip 39 | the event. 40 | """ 41 | @callback filter_event(event :: map()) :: map() | nil 42 | end 43 | -------------------------------------------------------------------------------- /lib/honeybadger/event_filter/default.ex: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.EventFilter.Default do 2 | use Honeybadger.EventFilter.Mixin 3 | end 4 | -------------------------------------------------------------------------------- /lib/honeybadger/event_filter/mixin.ex: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.EventFilter.Mixin do 2 | @moduledoc """ 3 | Provides a mixin for implementing the Honeybadger.EventFilter behaviour. 4 | 5 | If you need to implement custom filtering for events, you can define your own filter module 6 | and register it in the config. For example, to filter based on event_type: 7 | 8 | defmodule MyApp.MyFilter do 9 | use Honeybadger.EventFilter.Mixin 10 | 11 | # Drop analytics events by returning nil 12 | def filter_event(%{event_type: "analytics"} = _event), do: nil 13 | 14 | # Anonymize user data in login events 15 | def filter_event(%{event_type: "login"} = event) do 16 | event 17 | |> update_in([:data, :user_email], fn _ -> "[REDACTED]" end) 18 | |> put_in([:metadata, :filtered], true) 19 | end 20 | 21 | # For telemetry events, you can customize while still applying default filtering 22 | def filter_telemetry_event(data, raw, event) do 23 | # First apply default filtering 24 | filtered_data = apply_default_telemetry_filtering(data) 25 | 26 | # Then apply custom logic 27 | case event do 28 | [:auth, :login, :start] -> 29 | Map.put(filtered_data, :security_filtered, true) 30 | _ -> 31 | filtered_data 32 | end 33 | end 34 | 35 | # Keep all other events as they are 36 | def filter_event(event), do: event 37 | end 38 | 39 | And set the configuration to: 40 | 41 | config :honeybadger, 42 | event_filter: MyApp.MyFilter 43 | 44 | Return `nil` from `filter_event/1` to prevent the event from being processed. 45 | If you override `filter_telemetry_event/3`, you can still apply the default 46 | filtering by calling `apply_default_telemetry_filtering/1`. 47 | """ 48 | 49 | defmacro __using__(_) do 50 | quote location: :keep do 51 | @behaviour Honeybadger.EventFilter 52 | 53 | def filter_event(event), do: event 54 | 55 | def filter_telemetry_event(data, _raw, _event) do 56 | apply_default_telemetry_filtering(data) 57 | end 58 | 59 | # Default filtering implementation as a public function 60 | def apply_default_telemetry_filtering(data) do 61 | data 62 | |> disable(:filter_disable_url, :url) 63 | |> disable(:filter_disable_session, :session) 64 | |> disable(:filter_disable_assigns, :assigns) 65 | |> disable(:filter_disable_params, :params) 66 | |> Honeybadger.Utils.sanitize(remove_filtered: true) 67 | end 68 | 69 | defp disable(meta, config_key, map_key) do 70 | if Honeybadger.get_env(config_key) do 71 | Map.drop(meta, [map_key]) 72 | else 73 | meta 74 | end 75 | end 76 | 77 | defoverridable Honeybadger.EventFilter 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/honeybadger/events_sampler.ex: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.EventsSampler do 2 | @moduledoc false 3 | 4 | use GenServer 5 | require Logger 6 | 7 | # Every 5 minutes, we log the number of sampled events 8 | @sampled_log_interval 5 * 60 * 1000 9 | @hash_max 1_000_000 10 | @fully_sampled_rate 100 11 | 12 | def start_link(opts \\ []) do 13 | {name, opts} = Keyword.pop(opts, :name, __MODULE__) 14 | GenServer.start_link(__MODULE__, opts, name: name) 15 | end 16 | 17 | @impl true 18 | def init(opts) do 19 | state = 20 | %{ 21 | sample_rate: Honeybadger.get_env(:insights_sample_rate), 22 | sampled_log_interval: @sampled_log_interval, 23 | sample_count: 0, 24 | ignore_count: 0 25 | } 26 | |> Map.merge(Map.new(opts)) 27 | 28 | schedule_report(state.sampled_log_interval) 29 | 30 | {:ok, state} 31 | end 32 | 33 | @doc """ 34 | Determines if an event should be sampled 35 | 36 | ## Options 37 | * `:sample_rate` - Override the default sample rate from the server state 38 | * `:hash_value` - The hash value to use for sampling. If not provided, random sampling is used. 39 | * `:server` - Specify the GenServer to use (default: `__MODULE__`) 40 | 41 | ## Examples 42 | iex> Sampler.sample?() 43 | true 44 | 45 | iex> Sampler.sample?(sample_rate: 1) 46 | false 47 | 48 | iex> Sampler.sample?(hash_value: "abc-123") 49 | false 50 | """ 51 | @spec sample?(Keyword.t()) :: boolean() 52 | def sample?(opts \\ []) do 53 | {server, opts} = Keyword.pop(opts, :server, __MODULE__) 54 | # Remove nil values from options 55 | opts = Keyword.filter(opts, fn {_k, v} -> not is_nil(v) end) 56 | 57 | if sampling_at_full_rate?(opts) do 58 | true 59 | else 60 | GenServer.call(server, {:sample?, opts}) 61 | end 62 | end 63 | 64 | @impl true 65 | def handle_call({:sample?, opts}, _from, state) do 66 | decision = 67 | do_sample?( 68 | Keyword.get(opts, :hash_value), 69 | Keyword.get(opts, :sample_rate, state.sample_rate) 70 | ) 71 | 72 | # Increment the count of sampled or ignored events 73 | count_key = if decision, do: :sample_count, else: :ignore_count 74 | state = update_in(state, [count_key], &(&1 + 1)) 75 | {:reply, decision, state} 76 | end 77 | 78 | @impl true 79 | def handle_info(:report, %{sample_count: sample_count, ignore_count: ignore_count} = state) do 80 | if sample_count > 0 do 81 | Logger.debug( 82 | "[Honeybadger] Sampled #{sample_count} events (of #{sample_count + ignore_count} total events)" 83 | ) 84 | end 85 | 86 | schedule_report(state.sampled_log_interval) 87 | 88 | {:noreply, %{state | sample_count: 0, ignore_count: 0}} 89 | end 90 | 91 | defp sampling_at_full_rate?(opts) when is_list(opts) do 92 | sample_rate = Keyword.get(opts, :sample_rate, Honeybadger.get_env(:insights_sample_rate)) 93 | sample_rate == @fully_sampled_rate 94 | end 95 | 96 | # Use random sampling when no hash value is provided 97 | defp do_sample?(nil, sample_rate) do 98 | :rand.uniform() * @fully_sampled_rate < sample_rate 99 | end 100 | 101 | # Use hash sampling when a hash value is provided 102 | defp do_sample?(hash_value, sample_rate) when is_binary(hash_value) or is_atom(hash_value) do 103 | :erlang.phash2(hash_value, @hash_max) / @hash_max * @fully_sampled_rate < sample_rate 104 | end 105 | 106 | defp schedule_report(interval) do 107 | Process.send_after(self(), :report, interval) 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /lib/honeybadger/events_worker.ex: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.EventsWorker do 2 | @moduledoc """ 3 | A GenServer that batches and sends events with retry and throttling logic. 4 | 5 | It accumulates events in a queue, forms batches when the batch size is reached or 6 | when a flush timeout expires, and then sends these batches to a backend module. 7 | If a batch fails to send, it will be retried (up to a configurable maximum) or dropped. 8 | In case of throttling (e.g. receiving a 429), the flush delay is increased. 9 | """ 10 | 11 | @dropped_log_interval 60_000 12 | 13 | use GenServer 14 | require Logger 15 | 16 | defmodule State do 17 | @typedoc """ 18 | Function that accepts a list of events to be processed. 19 | """ 20 | @type send_events_fn :: ([term()] -> :ok | {:error, :throttled} | {:error, term()}) 21 | 22 | @typedoc """ 23 | State for the event batching GenServer. 24 | """ 25 | @type t :: %__MODULE__{ 26 | # Configuration 27 | send_events_fn: send_events_fn(), 28 | batch_size: pos_integer(), 29 | max_queue_size: pos_integer(), 30 | timeout: pos_integer(), 31 | max_batch_retries: non_neg_integer(), 32 | throttle_wait: pos_integer(), 33 | 34 | # Internal state 35 | timeout_started_at: non_neg_integer(), 36 | throttling: boolean(), 37 | dropped_events: non_neg_integer(), 38 | last_dropped_log: non_neg_integer(), 39 | queue: [any()], 40 | batches: :queue.queue() 41 | } 42 | 43 | @enforce_keys [ 44 | :send_events_fn, 45 | :batch_size, 46 | :max_queue_size, 47 | :max_batch_retries 48 | ] 49 | 50 | defstruct [ 51 | :send_events_fn, 52 | :batch_size, 53 | :max_queue_size, 54 | :timeout, 55 | :max_batch_retries, 56 | :last_dropped_log, 57 | timeout_started_at: 0, 58 | throttle_wait: 60000, 59 | throttling: false, 60 | dropped_events: 0, 61 | queue: [], 62 | batches: :queue.new() 63 | ] 64 | end 65 | 66 | @spec start_link(Keyword.t()) :: GenServer.on_start() 67 | def start_link(opts \\ []) do 68 | if Honeybadger.get_env(:events_worker_enabled) do 69 | {name, opts} = Keyword.pop(opts, :name, __MODULE__) 70 | GenServer.start_link(__MODULE__, opts, name: name) 71 | else 72 | :ignore 73 | end 74 | end 75 | 76 | @spec push(event :: map(), GenServer.server()) :: :ok 77 | def push(event, server \\ __MODULE__) do 78 | GenServer.cast(server, {:push, event}) 79 | end 80 | 81 | @spec state(GenServer.server()) :: State.t() 82 | def state(server \\ __MODULE__) do 83 | GenServer.call(server, {:state}) 84 | end 85 | 86 | @impl true 87 | def init(opts) do 88 | config = %{ 89 | send_events_fn: Keyword.get(opts, :send_events_fn, &Honeybadger.Client.send_events/1), 90 | batch_size: Keyword.get(opts, :batch_size, Honeybadger.get_env(:events_batch_size)), 91 | timeout: Keyword.get(opts, :timeout, Honeybadger.get_env(:events_timeout)), 92 | throttle_wait: 93 | Keyword.get(opts, :throttle_wait, Honeybadger.get_env(:events_throttle_wait)), 94 | max_queue_size: 95 | Keyword.get(opts, :max_queue_size, Honeybadger.get_env(:events_max_queue_size)), 96 | max_batch_retries: 97 | Keyword.get(opts, :max_batch_retries, Honeybadger.get_env(:events_max_batch_retries)), 98 | last_dropped_log: System.monotonic_time(:millisecond) 99 | } 100 | 101 | state = struct!(State, config) 102 | {:ok, state} 103 | end 104 | 105 | @impl true 106 | def handle_call({:state}, _from, %State{} = state) do 107 | {:reply, state, state, current_timeout(state)} 108 | end 109 | 110 | @impl true 111 | def handle_cast({:push, event}, %State{timeout_started_at: 0} = state) do 112 | handle_cast({:push, event}, reset_timeout(state)) 113 | end 114 | 115 | def handle_cast({:push, event}, %State{} = state) do 116 | if total_event_count(state) >= state.max_queue_size do 117 | {:noreply, %{state | dropped_events: state.dropped_events + 1}, current_timeout(state)} 118 | else 119 | queue = [event | state.queue] 120 | 121 | if length(queue) >= state.batch_size do 122 | flush(%{state | queue: queue}) 123 | else 124 | {:noreply, %{state | queue: queue}, current_timeout(state)} 125 | end 126 | end 127 | end 128 | 129 | @impl true 130 | def handle_info(:timeout, state), do: flush(state) 131 | 132 | @impl true 133 | def terminate(_reason, %State{} = state) do 134 | Logger.debug("[Honeybadger] Terminating with #{total_event_count(state)} events unsent") 135 | _ = flush(state) 136 | :ok 137 | end 138 | 139 | @spec flush(State.t()) :: {:noreply, State.t(), pos_integer()} 140 | defp flush(state) do 141 | cond do 142 | state.queue == [] and :queue.is_empty(state.batches) -> 143 | # It's all empty so we stop the timeout and reset the 144 | # timeout_started_at which will restart on the next push 145 | {:noreply, %{state | timeout_started_at: 0}} 146 | 147 | state.queue == [] -> 148 | attempt_send(state) 149 | 150 | true -> 151 | batches = :queue.in(%{batch: Enum.reverse(state.queue), attempts: 0}, state.batches) 152 | attempt_send(%{state | queue: [], batches: batches}) 153 | end 154 | end 155 | 156 | @spec attempt_send(State.t()) :: {:noreply, State.t(), pos_integer()} 157 | # Sends pending batches, handling retries and throttling 158 | defp attempt_send(%State{} = state) do 159 | {new_batches_list, throttling} = 160 | Enum.reduce(:queue.to_list(state.batches), {[], false}, fn 161 | # If already throttled, skip sending and retain the batch. 162 | b, {acc, true} -> 163 | {acc ++ [b], true} 164 | 165 | %{batch: batch, attempts: attempts} = b, {acc, false} -> 166 | case state.send_events_fn.(batch) do 167 | :ok -> 168 | {acc, false} 169 | 170 | {:error, reason} -> 171 | throttling = reason == :throttled 172 | updated_attempts = attempts + 1 173 | 174 | if throttling do 175 | Logger.warning( 176 | "[Honeybadger] Rate limited (429) events - (batch attempt #{updated_attempts}) - waiting for #{state.throttle_wait}ms" 177 | ) 178 | else 179 | Logger.debug( 180 | "[Honeybadger] Failed to send events batch (attempt #{updated_attempts}): #{inspect(reason)}" 181 | ) 182 | end 183 | 184 | if updated_attempts < state.max_batch_retries do 185 | {acc ++ [%{b | attempts: updated_attempts}], throttling} 186 | else 187 | Logger.debug( 188 | "[Honeybadger] Dropping events batch after #{updated_attempts} attempts." 189 | ) 190 | 191 | {acc, throttling} 192 | end 193 | end 194 | end) 195 | 196 | current_time = System.monotonic_time(:millisecond) 197 | 198 | # Log dropped events if present and we haven't logged within the last 199 | # @dropped_log_interval 200 | state = 201 | if state.dropped_events > 0 and 202 | current_time - state.last_dropped_log >= @dropped_log_interval do 203 | Logger.info("[Honeybadger] Dropped #{state.dropped_events} events due to max queue limit") 204 | %{state | dropped_events: 0, last_dropped_log: current_time} 205 | else 206 | state 207 | end 208 | 209 | new_state = 210 | %{state | batches: :queue.from_list(new_batches_list), throttling: throttling} 211 | |> reset_timeout() 212 | 213 | {:noreply, new_state, current_timeout(new_state)} 214 | end 215 | 216 | @spec total_event_count(State.t()) :: non_neg_integer() 217 | # Counts events in both the queue and pending batches. 218 | defp total_event_count(%State{batches: batches, queue: queue}) do 219 | events_count = length(queue) 220 | 221 | batch_count = :queue.fold(fn %{batch: b}, acc -> acc + length(b) end, 0, batches) 222 | 223 | events_count + batch_count 224 | end 225 | 226 | # Returns the time remaining until the next flush 227 | defp current_timeout(%State{ 228 | throttling: throttling, 229 | timeout: timeout, 230 | throttle_wait: throttle_wait, 231 | timeout_started_at: timeout_started_at 232 | }) do 233 | elapsed = System.monotonic_time(:millisecond) - timeout_started_at 234 | timeout = if throttling, do: throttle_wait, else: timeout 235 | max(1, timeout - elapsed) 236 | end 237 | 238 | defp reset_timeout(state) do 239 | %{state | timeout_started_at: System.monotonic_time(:millisecond)} 240 | end 241 | end 242 | -------------------------------------------------------------------------------- /lib/honeybadger/exclude_errors.ex: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.ExcludeErrors do 2 | @moduledoc """ 3 | Specification of user overrideable exclude_errors function. 4 | """ 5 | 6 | alias Honeybadger.Notice 7 | 8 | @doc """ 9 | Takes in a notice struct and supposed to return true or false depending with the user Specification 10 | """ 11 | @callback exclude_error?(Notice.t()) :: boolean 12 | end 13 | -------------------------------------------------------------------------------- /lib/honeybadger/exclude_errors/default.ex: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.ExcludeErrors.Default do 2 | @moduledoc """ 3 | The default implementation for the `exclude_errors` configuration. Doesn't 4 | exclude any error. 5 | """ 6 | 7 | alias Honeybadger.ExcludeErrors 8 | 9 | @behaviour ExcludeErrors 10 | 11 | @impl ExcludeErrors 12 | def exclude_error?(_), do: false 13 | end 14 | -------------------------------------------------------------------------------- /lib/honeybadger/filter.ex: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.Filter do 2 | alias Honeybadger.Breadcrumbs.Breadcrumb 3 | 4 | @moduledoc """ 5 | Specification of user overrideable filter functions. 6 | 7 | See moduledoc for `Honeybadger.Filter.Mixin` for details on implementing 8 | your own filter. 9 | """ 10 | 11 | @doc """ 12 | Filter the context Map. The context is a map of application supplied data. 13 | """ 14 | @callback filter_context(map) :: map 15 | 16 | @doc """ 17 | For applications that use `Honeybadger.Plug`, filters the query parameters. 18 | The parameters is a map of `String.t` to `String.t`, e.g.: 19 | 20 | %{"user_name" => "fred", "password" => "12345"} 21 | """ 22 | @callback filter_params(map) :: map 23 | 24 | @doc """ 25 | For applications that use `Honeybadger.Plug`, filter the cgi_data. 26 | 27 | `cgi_data` is a map of `String.t` to `String.t` which includes HTTP headers 28 | and other pre-defined request data (including `PATH_INFO`, `QUERY_STRING`, 29 | `SERVER_PORT` etc.). 30 | """ 31 | @callback filter_cgi_data(map) :: map 32 | 33 | @doc """ 34 | For applications that use `Honeybadger.Plug`, filter the session. 35 | """ 36 | @callback filter_session(map) :: map 37 | 38 | @doc """ 39 | Filter the error message string. This is the message from the most 40 | recently thrown error. 41 | """ 42 | @callback filter_error_message(String.t()) :: String.t() 43 | 44 | @doc """ 45 | Filter breadcrumbs. This filter function receives a list of Breadcrumb 46 | structs. You could use any Enum function to constrain the set. Let's say you 47 | want to remove any breadcrumb that have metadata that contain SSN: 48 | 49 | def filter_breadcrumbs(breadcrumbs) do 50 | Enum.reject(breadcrumbs, fn breadcrumb -> do 51 | Map.has_key?(breadcrumb.metadata, :ssn) 52 | end) 53 | end 54 | """ 55 | @callback filter_breadcrumbs([Breadcrumb.t()]) :: [Breadcrumb.t()] 56 | end 57 | -------------------------------------------------------------------------------- /lib/honeybadger/filter/default.ex: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.Filter.Default do 2 | @moduledoc """ 3 | The default implementation for the `filter` configuration. Removes 4 | keys listed in `filter_keys` from maps and respects the 5 | `filter_disable_*` configuration values. 6 | """ 7 | 8 | use Honeybadger.Filter.Mixin 9 | end 10 | -------------------------------------------------------------------------------- /lib/honeybadger/filter/mixin.ex: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.Filter.Mixin do 2 | @moduledoc """ 3 | A default implementation of `Honeybadger.Filter`. 4 | 5 | If you need to implement custom filtering for one or more of the elements in 6 | a `Honeybadger.Notice`, you can define your own filter module and register it 7 | in the config. E.g., if you wanted to filter the error message string, but 8 | keep all of the other default filtering, you could do: 9 | 10 | defmodule MyApp.MyFilter do 11 | use Honeybadger.Filter.Mixin 12 | 13 | def filter_error_message(message) do 14 | # replace passwords in error message with `"xxx"` 15 | Regex.replace(~r/(password:\s*)"([^"]+)"/, message, ~s(\\1"xxx")) 16 | end 17 | end 18 | 19 | And set the configuration to: 20 | 21 | config :honeybadger, 22 | filter: MyApp.MyFilter 23 | 24 | See the documentation for `Honeybadger.Filter` for a list of functions that 25 | may be overridden. The default implementations for all of the functions that 26 | take a `map` are to remove any keys from the map that match a key in 27 | `filter_keys`. The check matches atoms and strings in a case insensitive 28 | manner. 29 | """ 30 | defmacro __using__(_) do 31 | quote location: :keep do 32 | @behaviour Honeybadger.Filter 33 | 34 | def filter_context(context), do: filter_map(context) 35 | def filter_params(params), do: filter_map(params) 36 | def filter_cgi_data(cgi_data), do: filter_map(cgi_data) 37 | def filter_session(session), do: filter_map(session) 38 | def filter_error_message(message), do: message 39 | def filter_breadcrumbs(breadcrumbs), do: breadcrumbs 40 | 41 | @doc false 42 | def filter_map(map) do 43 | filter_map(map, Honeybadger.get_env(:filter_keys)) 44 | end 45 | 46 | def filter_map(map, keys) when is_list(keys) do 47 | filter_keys = Enum.map(keys, &Honeybadger.Utils.canonicalize/1) 48 | 49 | drop_keys = 50 | map 51 | |> Map.keys() 52 | |> Enum.filter(&Enum.member?(filter_keys, Honeybadger.Utils.canonicalize(&1))) 53 | 54 | Map.drop(map, drop_keys) 55 | end 56 | 57 | def filter_map(map, _keys) do 58 | map 59 | end 60 | 61 | defoverridable Honeybadger.Filter 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/honeybadger/fingerprint_adapter.ex: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.FingerprintAdapter do 2 | @moduledoc """ 3 | The callbacks required to implement the FingerprintAdapter behaviour 4 | """ 5 | 6 | alias Honeybadger.Notice 7 | 8 | @doc """ 9 | This function receives a `t:Honeybadger.Notice.t/0` and must return a string that will be used 10 | as a fingerprint for the request: 11 | 12 | def parse(notice) do 13 | notice.notifier.language <> "_" <> notice.notifier.name 14 | end 15 | 16 | """ 17 | @callback parse(Notice.t()) :: String.t() 18 | end 19 | -------------------------------------------------------------------------------- /lib/honeybadger/http_adapter.ex: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.HTTPAdapter do 2 | @moduledoc """ 3 | HTTP adapter helper module. 4 | 5 | You can configure the HTTP adapter that Honeybadger uses by setting the 6 | following option: 7 | 8 | http_adapter: Honeybadger.HTTPAdapter.Req 9 | 10 | Default options can be set by passing a list of options: 11 | 12 | http_adapter: {Honeybadger.HTTPAdapter.Req, [...]} 13 | 14 | You can also set the client for the application: 15 | 16 | config :honeybadger, :http_adapter, Honeybadger.HTTPAdapter.Req 17 | 18 | ## Usage 19 | defmodule MyApp.MyHTTPAdapter do 20 | @behaviour Honeybadger.HTTPAdapter 21 | 22 | @impl true 23 | def request(method, url, body, headers, opts) do 24 | # ... 25 | end 26 | 27 | @impl true 28 | def decode_response_body(response, opts) do 29 | # ... 30 | end 31 | end 32 | """ 33 | 34 | alias Honeybadger.{InvalidResponseError, ServerUnreachableError} 35 | 36 | defmodule HTTPResponse do 37 | @moduledoc """ 38 | Struct used by HTTP adapters to normalize HTTP responses. 39 | """ 40 | 41 | @type header :: {binary(), binary()} 42 | @type t :: %__MODULE__{ 43 | http_adapter: atom(), 44 | request_url: binary(), 45 | status: integer(), 46 | headers: [header()], 47 | body: binary() | term() 48 | } 49 | 50 | defstruct http_adapter: nil, request_url: nil, status: 200, headers: [], body: "" 51 | 52 | def format(response) do 53 | [request_url | _rest] = String.split(response.request_url, "?", parts: 2) 54 | 55 | """ 56 | HTTP Adapter: #{inspect(response.http_adapter)} 57 | Request URL: #{request_url} 58 | 59 | Response status: #{response.status} 60 | 61 | Response headers: 62 | #{Enum.map_join(response.headers, "\n", fn {key, val} -> "#{key}: #{val}" end)} 63 | 64 | Response body: 65 | #{inspect(response.body)} 66 | """ 67 | end 68 | end 69 | 70 | @type method :: :get | :post 71 | @type body :: binary() | nil 72 | @type headers :: [{binary(), binary()}] 73 | 74 | @callback request(method(), binary(), body(), headers(), Keyword.t()) :: 75 | {:ok, map()} | {:error, any()} 76 | 77 | @callback decode_response_body(HTTPResponse.t(), Keyword.t()) :: 78 | {:ok, HTTPResponse.t()} | {:error, InvalidResponseError.t()} 79 | 80 | @doc """ 81 | Makes an HTTP request. 82 | 83 | ## Options 84 | 85 | - `:http_adapter` - The HTTP adapter to use, defaults to to one of the available adapters 86 | (Req is preferred, falling back to Hackney if available) 87 | """ 88 | @spec request(atom(), binary(), binary() | nil, list(), Keyword.t()) :: 89 | {:ok, HTTPResponse.t()} | {:error, HTTPResponse.t()} | {:error, term()} 90 | def request(method, url, body, headers, opts) do 91 | {http_adapter, http_adapter_opts} = get_adapter(opts) 92 | 93 | with {:ok, response} <- http_adapter.request(method, url, body, headers, http_adapter_opts), 94 | {:ok, decoded_response} <- http_adapter.decode_response_body(response, opts) do 95 | {:ok, %{decoded_response | http_adapter: http_adapter, request_url: url}} 96 | else 97 | {:error, %Honeybadger.InvalidResponseError{} = error} -> 98 | {:error, error} 99 | 100 | {:error, error} -> 101 | {:error, 102 | ServerUnreachableError.exception( 103 | reason: error, 104 | http_adapter: http_adapter, 105 | request_url: url 106 | )} 107 | end 108 | end 109 | 110 | defp get_adapter(opts) do 111 | default_http_adapter = Application.get_env(:honeybadger, :http_adapter, installed_adapter()) 112 | 113 | case Keyword.get(opts, :http_adapter, default_http_adapter) do 114 | {http_adapter, opts} -> {http_adapter, opts} 115 | http_adapter when is_atom(http_adapter) -> {http_adapter, nil} 116 | end 117 | end 118 | 119 | defp installed_adapter do 120 | key = {__MODULE__, :installed_adapter} 121 | 122 | case :persistent_term.get(key, :undefined) do 123 | :undefined -> 124 | adapter = find_installed_adapter() 125 | :persistent_term.put(key, adapter) 126 | adapter 127 | 128 | adapter -> 129 | adapter 130 | end 131 | end 132 | 133 | defp find_installed_adapter do 134 | Enum.find_value( 135 | [ 136 | {Req, Honeybadger.HTTPAdapter.Req}, 137 | {:hackney, Honeybadger.HTTPAdapter.Hackney} 138 | ], 139 | fn {dep, module} -> 140 | Code.ensure_loaded?(dep) && {module, []} 141 | end 142 | ) 143 | end 144 | 145 | @doc """ 146 | Validates that the configured HTTP adapter's dependencies are available. 147 | 148 | This should be called during application startup to ensure that the 149 | configured adapter can be used. 150 | """ 151 | def validate_adapter_availability! do 152 | {adapter, _opts} = get_adapter([]) 153 | 154 | case adapter do 155 | Honeybadger.HTTPAdapter.Hackney -> 156 | ensure_dependency_available!(:hackney, "~> 1.8", adapter) 157 | 158 | Honeybadger.HTTPAdapter.Req -> 159 | ensure_dependency_available!(Req, "~> 0.3", adapter) 160 | 161 | nil -> 162 | raise """ 163 | Honeybadger requires an HTTP client but neither Req nor Hackney is available. 164 | Please add one of the following to your dependencies: 165 | {:req, "~> 0.3"} # Recommended 166 | {:hackney, "~> 1.8"} 167 | """ 168 | 169 | _ -> 170 | # Custom adapter - assume user knows what they're doing 171 | :ok 172 | end 173 | end 174 | 175 | defp ensure_dependency_available!(module, version, adapter) do 176 | unless Code.ensure_loaded?(module) do 177 | raise """ 178 | Honeybadger is configured to use #{inspect(adapter)}, but #{inspect(module)} is not available. 179 | 180 | Please add it to your dependencies: 181 | 182 | {#{inspect(module)}, "#{version}"} 183 | 184 | Or configure a different HTTP adapter in your config. 185 | """ 186 | end 187 | end 188 | end 189 | -------------------------------------------------------------------------------- /lib/honeybadger/http_adapter/hackney.ex: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.HTTPAdapter.Hackney do 2 | @moduledoc """ 3 | HTTP adapter module for making http requests with `:hackney`. 4 | 5 | You can also override the hackney options by updating the configuration: 6 | 7 | http_adapter: {Honeybadger.HTTPAdapter.Hackney, [...]} 8 | 9 | See `Honeybadger.HTTPAdapter` for more. 10 | """ 11 | 12 | alias Honeybadger.{HTTPAdapter, HTTPAdapter.HTTPResponse} 13 | alias Honeybadger.InvalidResponseError 14 | 15 | @behaviour HTTPAdapter 16 | 17 | @impl HTTPAdapter 18 | def request(method, url, body, headers, opts \\ []) do 19 | opts = opts || [] 20 | 21 | body = binary_or_empty_binary(body) 22 | 23 | apply(:hackney, :request, [method, url, headers, body, opts]) 24 | |> format_response() 25 | end 26 | 27 | @impl HTTPAdapter 28 | def decode_response_body(response, opts) do 29 | case decode(response.headers, response.body, opts) do 30 | {:ok, body} -> {:ok, %{response | body: body}} 31 | {:error, _error} -> {:error, InvalidResponseError.exception(response: response)} 32 | end 33 | end 34 | 35 | defp decode(headers, body, opts) when is_binary(body) do 36 | content_type = 37 | headers 38 | |> Enum.find_value(fn {k, v} -> 39 | if String.downcase(k) == "content-type", do: String.downcase(v) 40 | end) 41 | 42 | case content_type do 43 | nil -> 44 | {:ok, body} 45 | 46 | "application/json" <> _rest -> 47 | Jason.decode(body, opts) 48 | 49 | "text/javascript" <> _rest -> 50 | Jason.decode(body, opts) 51 | 52 | "application/x-www-form-urlencoded" <> _rest -> 53 | {:ok, URI.decode_query(body)} 54 | 55 | _any -> 56 | {:ok, body} 57 | end 58 | end 59 | 60 | defp decode(_headers, body, _opts), do: {:ok, body} 61 | 62 | defp format_response({:ok, status_code, headers, client_ref}) do 63 | {:ok, %HTTPResponse{status: status_code, headers: headers, body: body_from_ref(client_ref)}} 64 | end 65 | 66 | defp format_response({:error, error}), do: {:error, error} 67 | 68 | defp body_from_ref(ref) do 69 | apply(:hackney, :body, [ref]) 70 | |> elem(1) 71 | end 72 | 73 | defp binary_or_empty_binary(nil), do: "" 74 | defp binary_or_empty_binary(str), do: str 75 | end 76 | -------------------------------------------------------------------------------- /lib/honeybadger/http_adapter/req.ex: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.HTTPAdapter.Req do 2 | @moduledoc """ 3 | HTTP adapter module for making http requests with Req. 4 | 5 | You can also override the Req options by updating the configuration: 6 | 7 | http_adapter: {Honeybadger.HTTPAdapter.Req, [...]} 8 | 9 | See `Honeybadger.HTTPAdapter` for more. 10 | """ 11 | alias Honeybadger.{HTTPAdapter, HTTPAdapter.HTTPResponse} 12 | 13 | @behaviour HTTPAdapter 14 | 15 | @impl HTTPAdapter 16 | def request(method, url, body, headers, opts \\ []) do 17 | opts = 18 | Keyword.merge( 19 | [ 20 | method: method, 21 | url: url, 22 | headers: headers, 23 | body: body 24 | ], 25 | opts || [] 26 | ) 27 | 28 | req = apply(Req, :new, [opts]) 29 | 30 | apply(Req, :request, [req]) 31 | |> case do 32 | {:ok, response} -> 33 | {:ok, 34 | %HTTPResponse{status: response.status, headers: response.headers, body: response.body}} 35 | 36 | {:error, error} -> 37 | {:error, error} 38 | end 39 | end 40 | 41 | @impl HTTPAdapter 42 | def decode_response_body(response, _opts) do 43 | {:ok, response} 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/honeybadger/insights/absinthe.ex: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.Insights.Absinthe do 2 | @moduledoc """ 3 | Captures telemetry events from GraphQL operations executed via Absinthe. 4 | 5 | ## Default Configuration 6 | 7 | By default, this module listens for the following Absinthe telemetry events: 8 | 9 | "absinthe.execute.operation.stop" 10 | "absinthe.execute.operation.exception" 11 | 12 | ## Custom Configuration 13 | 14 | You can customize the telemetry events to listen for by updating the insights_config: 15 | 16 | config :honeybadger, insights_config: %{ 17 | absinthe: %{ 18 | telemetry_events: [ 19 | [:absinthe, :execute, :operation, :stop], 20 | [:absinthe, :execute, :operation, :exception], 21 | [:absinthe, :resolve, :field, :stop] 22 | ] 23 | } 24 | } 25 | 26 | Note that adding field-level events like "absinthe.resolve.field.stop" can 27 | significantly increase the number of telemetry events generated. 28 | """ 29 | 30 | use Honeybadger.Insights.Base 31 | 32 | @required_dependencies [Absinthe] 33 | 34 | @telemetry_events [ 35 | [:absinthe, :execute, :operation, :stop], 36 | [:absinthe, :execute, :operation, :exception] 37 | ] 38 | 39 | # This is not loaded by default since it can add a ton of events, but is here 40 | # in case it is added to the insights_config. 41 | def extract_metadata(%{resolution: resolution}, [:absinthe, :resolve, :field, :stop]) do 42 | %{ 43 | field_name: resolution.definition.name, 44 | parent_type: resolution.parent_type.name, 45 | state: resolution.state 46 | } 47 | end 48 | 49 | def extract_metadata(meta, _name) do 50 | %{ 51 | operation_name: get_operation_name(meta), 52 | operation_type: get_operation_type(meta), 53 | selections: get_graphql_selections(meta), 54 | schema: get_schema(meta), 55 | errors: get_errors(meta) 56 | } 57 | end 58 | 59 | defp get_schema(%{blueprint: blueprint}) when is_map(blueprint), do: Map.get(blueprint, :schema) 60 | defp get_schema(_), do: nil 61 | 62 | defp get_errors(%{blueprint: blueprint}) when is_map(blueprint) do 63 | case Map.get(blueprint, :result) do 64 | result when is_map(result) -> Map.get(result, :errors) 65 | _ -> nil 66 | end 67 | end 68 | 69 | defp get_errors(_), do: nil 70 | 71 | defp get_graphql_selections(%{blueprint: blueprint}) when is_map(blueprint) do 72 | operation = current_operation(blueprint) 73 | 74 | case operation do 75 | nil -> 76 | [] 77 | 78 | operation -> 79 | case Map.get(operation, :selections) do 80 | selections when is_list(selections) -> 81 | selections 82 | |> Enum.map(fn selection -> Map.get(selection, :name) end) 83 | |> Enum.uniq() 84 | 85 | _ -> 86 | [] 87 | end 88 | end 89 | end 90 | 91 | defp get_graphql_selections(_), do: [] 92 | 93 | defp get_operation_type(%{blueprint: blueprint}) when is_map(blueprint) do 94 | operation = current_operation(blueprint) 95 | 96 | case operation do 97 | nil -> nil 98 | operation -> Map.get(operation, :type) 99 | end 100 | end 101 | 102 | defp get_operation_type(_), do: nil 103 | 104 | defp get_operation_name(%{blueprint: blueprint}) when is_map(blueprint) do 105 | operation = current_operation(blueprint) 106 | 107 | case operation do 108 | nil -> nil 109 | operation -> Map.get(operation, :name) 110 | end 111 | end 112 | 113 | defp get_operation_name(_), do: nil 114 | 115 | # Replace Absinthe.Blueprint.current_operation/1 116 | defp current_operation(blueprint) do 117 | case Map.get(blueprint, :operations) do 118 | operations when is_list(operations) -> 119 | Enum.find(operations, fn op -> Map.get(op, :current) == true end) 120 | 121 | _ -> 122 | nil 123 | end 124 | end 125 | end 126 | -------------------------------------------------------------------------------- /lib/honeybadger/insights/base.ex: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.Insights.Base do 2 | @moduledoc """ 3 | Base module providing common telemetry attachment functionality. 4 | """ 5 | 6 | defmacro __using__(_opts) do 7 | quote do 8 | require Logger 9 | 10 | # Define empty defaults for the attributes 11 | Module.register_attribute(__MODULE__, :required_dependencies, []) 12 | @required_dependencies [] 13 | Module.register_attribute(__MODULE__, :telemetry_events, []) 14 | @time_keys ~w(duration total_time decode_time query_time queue_time idle_time)a 15 | 16 | @before_compile Honeybadger.Insights.Base 17 | 18 | def dependencies_available? do 19 | Honeybadger.Insights.Base.dependencies_available?(@required_dependencies) 20 | end 21 | 22 | def get_insights_config(key, default) do 23 | Honeybadger.Insights.Base.get_insights_config(key, default, __MODULE__) 24 | end 25 | end 26 | end 27 | 28 | @doc """ 29 | Checks if all required dependencies are available. 30 | """ 31 | def dependencies_available?(deps) do 32 | if Enum.empty?(deps), do: true, else: Enum.all?(deps, &Code.ensure_loaded?/1) 33 | end 34 | 35 | @doc """ 36 | Retrieves a configuration value from the insights configuration. 37 | """ 38 | def get_insights_config(key, default, mod) do 39 | config_namespace = 40 | mod 41 | |> Atom.to_string() 42 | |> String.split(".") 43 | |> List.last() 44 | |> Macro.underscore() 45 | |> String.to_atom() 46 | 47 | insights_config = Application.get_env(:honeybadger, :insights_config, %{}) 48 | module_config = Map.get(insights_config, config_namespace, %{}) 49 | Map.get(module_config, key, default) 50 | end 51 | 52 | defmacro __before_compile__(_env) do 53 | quote do 54 | def event_filter(map, meta, event) do 55 | if Application.get_env(:honeybadger, :event_filter) do 56 | Application.get_env(:honeybadger, :event_filter).filter_telemetry_event( 57 | map, 58 | meta, 59 | event 60 | ) 61 | else 62 | map 63 | end 64 | end 65 | 66 | def get_telemetry_events() do 67 | get_insights_config(:telemetry_events, @telemetry_events) 68 | end 69 | 70 | @doc """ 71 | Attaches telemetry handlers if all required dependencies are available. 72 | """ 73 | def attach do 74 | if dependencies_available?() and !get_insights_config(:disabled, false) do 75 | Enum.each(get_telemetry_events(), &attach_event/1) 76 | 77 | :ok 78 | else 79 | Logger.debug( 80 | "[Honeybadger] Missing Insights dependencies for #{inspect(@required_dependencies)}" 81 | ) 82 | 83 | {:error, :missing_dependencies} 84 | end 85 | end 86 | 87 | @doc """ 88 | Attaches a telemetry handler for a specific event. 89 | """ 90 | def attach_event(event) do 91 | event_name = Honeybadger.Utils.dotify(event) 92 | 93 | :telemetry.attach( 94 | # Use the event name as the handler ID 95 | event_name, 96 | event, 97 | &__MODULE__.handle_telemetry/4, 98 | nil 99 | ) 100 | end 101 | 102 | defp process_measurements(measurements) do 103 | Enum.reduce(measurements, %{}, fn 104 | {key, _vl}, acc when key in ~w(monotonic_time end_time_mono)a -> 105 | acc 106 | 107 | {key, val}, acc when key in @time_keys -> 108 | Map.put(acc, key, System.convert_time_unit(val, :native, :microsecond)) 109 | 110 | {key, val}, acc -> 111 | Map.put(acc, key, val) 112 | end) 113 | end 114 | 115 | @doc """ 116 | Handles telemetry events and processes the data. 117 | This implementation forwards to handle_event_impl which can be overridden 118 | by child modules to customize behavior while still calling the parent implementation. 119 | """ 120 | def handle_telemetry(event_name, measurements, metadata, opts) do 121 | handle_event_impl(event_name, measurements, metadata, opts) 122 | end 123 | 124 | @doc """ 125 | Implementation of handle_telemetry that can be called by overriding methods. 126 | """ 127 | def handle_event_impl(event, measurements, metadata, _opts) do 128 | name = Honeybadger.Utils.dotify(event) 129 | 130 | unless ignore?(metadata) do 131 | %{event_type: name} 132 | |> Map.merge(process_measurements(measurements)) 133 | |> Map.merge( 134 | metadata 135 | |> extract_metadata(event) 136 | |> Map.reject(fn {_, v} -> is_nil(v) end) 137 | ) 138 | |> event_filter(metadata, event) 139 | |> process_event() 140 | end 141 | 142 | :ok 143 | end 144 | 145 | @doc false 146 | defp maybe_put(map, key, value) do 147 | if value != nil do 148 | Map.put(map, key, value) 149 | else 150 | map 151 | end 152 | end 153 | 154 | @doc false 155 | defp get_module_name(module) when is_atom(module), do: inspect(module) 156 | defp get_module_name(module) when is_binary(module), do: module 157 | defp get_module_name(_), do: nil 158 | 159 | @doc """ 160 | Determines if an event should be ignored based on its metadata. 161 | Child modules should override this for specific filtering logic. 162 | Note: this is the metadata before any transformations. 163 | """ 164 | def ignore?(_metadata), do: false 165 | 166 | @doc """ 167 | Extracts metadata from the telemetry event. 168 | Child modules should override this for specific events. 169 | """ 170 | def extract_metadata(meta, _event), do: meta 171 | 172 | @doc """ 173 | Process the event data. Child modules can override this for custom 174 | processing. 175 | """ 176 | def process_event(event_data) when is_map(event_data), do: Honeybadger.event(event_data) 177 | def process_event(_event_data), do: nil 178 | 179 | defoverridable handle_telemetry: 4, 180 | extract_metadata: 2, 181 | process_event: 1, 182 | get_telemetry_events: 0, 183 | ignore?: 1 184 | end 185 | end 186 | end 187 | -------------------------------------------------------------------------------- /lib/honeybadger/insights/ecto.ex: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.Insights.Ecto do 2 | @moduledoc """ 3 | Captures database query telemetry events from Ecto repositories. 4 | 5 | ## Default Configuration 6 | 7 | By default, this module listens for telemetry events from all configured 8 | Ecto repositories. It reads the `:ecto_repos` configuration to identify 9 | repositories and their telemetry prefixes. 10 | 11 | ## Custom Configuration 12 | 13 | You can customize this module's behavior with the following configuration options: 14 | 15 | config :honeybadger, insights_config: %{ 16 | ecto: %{ 17 | # A list of strings or regex patterns of queries to exclude 18 | excluded_queries: [ 19 | ~r/^(begin|commit)( immediate)?( transaction)?$/i, 20 | ~r/SELECT pg_notify/, 21 | ~r/schema_migrations/ 22 | ], 23 | 24 | # Format & include the stacktrace with each query. You must also 25 | # update your repo config to enable: 26 | # 27 | # config :my_app, MyApp.Repo, 28 | # stacktrace: true 29 | # 30 | # Can be a boolean to enable for all or a list of sources to enable. 31 | include_stacktrace: true 32 | 33 | # Alternative source whitelist example: 34 | # include_stacktrace: ["source_a", "source_b"], 35 | 36 | # Format & include the query parameters with each query. Can be a 37 | # boolean to enable for all or a list of sources to enable. 38 | include_params: true 39 | 40 | # Alternative source whitelist example: 41 | # include_params:["source_a", "source_b"], 42 | 43 | # A list of table/source names to exclude 44 | excluded_sources: [ 45 | "schema_migrations", 46 | "oban_jobs", 47 | "oban_peers" 48 | ] 49 | } 50 | } 51 | 52 | By default, transaction bookkeeping queries and schema migration checks are excluded, 53 | as well as queries to common background job tables. 54 | """ 55 | 56 | use Honeybadger.Insights.Base 57 | 58 | @required_dependencies [Ecto.Repo] 59 | @telemetry_events [] 60 | 61 | @excluded_queries [ 62 | ~r/^(begin|commit)( immediate)?( transaction)?$/i, 63 | # Also exclude pg_notify which is often used with Oban 64 | ~r/SELECT pg_notify/, 65 | ~r/schema_migrations/ 66 | ] 67 | 68 | @excluded_sources [ 69 | "schema_migrations", 70 | "oban_jobs", 71 | "oban_peers" 72 | ] 73 | 74 | def get_telemetry_events do 75 | :ecto_repos 76 | |> Honeybadger.get_env() 77 | |> Enum.map(&get_telemetry_prefix/1) 78 | end 79 | 80 | def extract_metadata(meta, _name) do 81 | meta 82 | |> Map.take([:query, :decode_time, :query_time, :queue_time, :source]) 83 | |> Map.update!(:query, &obfuscate(&1, meta.repo.__adapter__())) 84 | |> include_stacktrace(meta) 85 | |> include_params(meta) 86 | end 87 | 88 | defp include_params(data, %{params: params, source: source}) do 89 | case get_insights_config(:include_params, false) do 90 | false -> 91 | data 92 | 93 | true -> 94 | Map.put(data, :params, params) 95 | 96 | sources when is_list(sources) -> 97 | if source in sources do 98 | Map.put(data, :params, params) 99 | else 100 | data 101 | end 102 | end 103 | end 104 | 105 | defp include_params(data, _), do: data 106 | 107 | defp include_stacktrace(data, %{stacktrace: stacktrace, source: source}) do 108 | case get_insights_config(:include_stacktrace, false) do 109 | false -> 110 | data 111 | 112 | true -> 113 | Map.put(data, :stacktrace, format_stacktrace(stacktrace)) 114 | 115 | sources when is_list(sources) -> 116 | if source in sources do 117 | Map.put(data, :stacktrace, format_stacktrace(stacktrace)) 118 | else 119 | data 120 | end 121 | end 122 | end 123 | 124 | defp include_stacktrace(data, _), do: data 125 | 126 | defp format_stacktrace(stacktrace) do 127 | Enum.map(stacktrace, &format_frame/1) 128 | end 129 | 130 | defp format_frame({module, function, arity, location}) do 131 | position = 132 | if is_list(location) and Keyword.has_key?(location, :file) do 133 | "#{location[:file]}:#{location[:line]}" 134 | else 135 | nil 136 | end 137 | 138 | [position, Exception.format_mfa(module, function, arity)] 139 | end 140 | 141 | def ignore?(%{query: query, source: source}) do 142 | if source in get_insights_config(:excluded_sources, @excluded_sources) do 143 | true 144 | else 145 | :excluded_queries 146 | |> get_insights_config(@excluded_queries) 147 | |> Enum.any?(fn 148 | pattern when is_binary(pattern) -> query == pattern 149 | %Regex{} = pattern -> Regex.match?(pattern, query) 150 | _pattern -> false 151 | end) 152 | end 153 | end 154 | 155 | defp get_telemetry_prefix(repo) do 156 | Keyword.get(repo.config(), :telemetry_prefix, []) ++ [:query] 157 | end 158 | 159 | @escape_quotes ~r/(\\\"|\\')/ 160 | @squote_data ~r/'(?:[^']|'')*'/ 161 | @dquote_data ~r/"(?:[^"]|"")*"/ 162 | @number_data ~r/\b\d+\b/ 163 | @double_quoters ~r/(postgres|sqlite3|postgis)/i 164 | 165 | def obfuscate(sql, adapter) when is_binary(sql) do 166 | sql 167 | |> String.replace(~r/\s+/, " ") 168 | |> String.replace(@escape_quotes, "") 169 | |> String.replace(@squote_data, "'?'") 170 | |> maybe_replace_dquote(adapter) 171 | |> String.replace(@number_data, "?") 172 | |> String.trim() 173 | end 174 | 175 | defp maybe_replace_dquote(sql, adapter) do 176 | if Regex.match?(@double_quoters, to_string(adapter)) do 177 | sql 178 | else 179 | String.replace(sql, @dquote_data, "\"?\"") 180 | end 181 | end 182 | end 183 | -------------------------------------------------------------------------------- /lib/honeybadger/insights/finch.ex: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.Insights.Finch do 2 | @moduledoc """ 3 | Captures telemetry events from HTTP requests made using Finch. 4 | 5 | ## Default Configuration 6 | 7 | By default, this module listens for the standard Finch request telemetry event: 8 | 9 | "finch.request.stop" 10 | 11 | ## Custom Configuration 12 | 13 | You can customize the telemetry events to listen for by updating the insights_config: 14 | 15 | config :honeybadger, insights_config: %{ 16 | finch: %{ 17 | telemetry_events: ["finch.request.stop", "finch.request.exception"], 18 | 19 | # Include full URLs in telemetry events (default: false - only hostname is included) 20 | full_url: false 21 | } 22 | } 23 | 24 | By default, only the hostname from URLs is captured for security and privacy reasons. 25 | If you need to capture the full URL including paths (but not query parameters), 26 | you can enable the `full_url` option. 27 | """ 28 | 29 | use Honeybadger.Insights.Base 30 | 31 | @required_dependencies [Finch] 32 | 33 | @telemetry_events [ 34 | [:finch, :request, :stop] 35 | ] 36 | 37 | def extract_metadata(meta, _) do 38 | metadata = %{ 39 | name: meta.name, 40 | method: meta.request.method, 41 | host: meta.request.host 42 | } 43 | 44 | metadata = 45 | if get_insights_config(:full_url, false) do 46 | Map.put(metadata, :url, reconstruct_url(meta.request)) 47 | else 48 | metadata 49 | end 50 | 51 | case meta.result do 52 | {:ok, response} when is_struct(response, Finch.Response) -> 53 | Map.merge(metadata, %{status: response.status}) 54 | 55 | {:ok, _acc} -> 56 | # For streaming requests 57 | Map.put(metadata, :streaming, true) 58 | 59 | {:error, error} -> 60 | Map.put(metadata, :error, Exception.message(error)) 61 | end 62 | end 63 | 64 | def ignore?(%{request: %{headers: headers}}) when is_list(headers) do 65 | Enum.any?(headers, fn 66 | {"user-agent", value} -> String.contains?(value, "Honeybadger Elixir") 67 | _ -> false 68 | end) 69 | end 70 | 71 | defp reconstruct_url(request) do 72 | # Exclude query parameters for security reasons 73 | port_string = get_port_string(request.scheme, request.port) 74 | 75 | "#{request.scheme}://#{request.host}#{port_string}#{request.path}" 76 | end 77 | 78 | defp get_port_string(scheme, port) do 79 | cond do 80 | is_nil(port) -> "" 81 | (scheme == :http and port == 80) or (scheme == :https and port == 443) -> "" 82 | true -> ":#{port}" 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/honeybadger/insights/live_view.ex: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.Insights.LiveView do 2 | @moduledoc """ 3 | Captures telemetry events from Phoenix LiveView. 4 | 5 | ## Default Configuration 6 | 7 | By default, this module listens for the following LiveView telemetry events: 8 | 9 | "phoenix.live_view.mount.stop" 10 | "phoenix.live_component.handle_event.stop" 11 | "phoenix.live_view.update.stop" 12 | 13 | ## Custom Configuration 14 | 15 | You can customize the telemetry events to listen for by updating the insights_config: 16 | 17 | config :honeybadger, insights_config: %{ 18 | live_view: %{ 19 | telemetry_events: [ 20 | [:phoenix, :live_view, :mount, :stop], 21 | [:phoenix, :live_component, :handle_event, :stop], 22 | [:phoenix, :live_component, :update, :stop] 23 | [:phoenix, :live_view, :handle_event, :stop], 24 | [:phoenix, :live_view, :handle_params, :stop], 25 | [:phoenix, :live_view, :update, :stop] 26 | ] 27 | } 28 | } 29 | """ 30 | 31 | use Honeybadger.Insights.Base 32 | 33 | @required_dependencies [Phoenix.LiveView] 34 | 35 | @telemetry_events [ 36 | # LiveView events 37 | [:phoenix, :live_view, :mount, :stop], 38 | [:phoenix, :live_view, :handle_params, :stop], 39 | [:phoenix, :live_view, :handle_event, :stop], 40 | 41 | # LiveComponent events 42 | [:phoenix, :live_component, :handle_event, :stop], 43 | [:phoenix, :live_component, :update, :stop] 44 | ] 45 | 46 | def get_telemetry_events() do 47 | events = get_insights_config(:telemetry_events, @telemetry_events) 48 | 49 | [[:phoenix, :live_view, :mount, :start]] ++ events 50 | end 51 | 52 | def handle_telemetry( 53 | [:phoenix, :live_view, :mount, :start] = event, 54 | measurements, 55 | metadata, 56 | opts 57 | ) do 58 | Honeybadger.EventContext.inherit() 59 | 60 | Honeybadger.EventContext.put_new(:request_id, fn -> 61 | Honeybadger.Utils.rand_id() 62 | end) 63 | 64 | if event in get_insights_config(:telemetry_events, @telemetry_events) do 65 | handle_event_impl(event, measurements, metadata, opts) 66 | end 67 | end 68 | 69 | def extract_metadata(meta, _event) do 70 | %{ 71 | url: Map.get(meta, :uri), 72 | socket_id: extract_socket_id(meta), 73 | view: extract_view(meta), 74 | component: extract_component(meta), 75 | assigns: extract_assigns(meta), 76 | params: Map.get(meta, :params), 77 | event: Map.get(meta, :event) 78 | } 79 | end 80 | 81 | defp extract_component(%{component: component}), do: get_module_name(component) 82 | defp extract_component(%{socket: %{live_component: component}}), do: get_module_name(component) 83 | defp extract_component(_), do: nil 84 | 85 | defp extract_socket_id(%{socket_id: id}), do: id 86 | defp extract_socket_id(%{socket: %{id: id}}), do: id 87 | defp extract_socket_id(_), do: nil 88 | 89 | defp extract_view(%{socket: %{view: view}}), do: get_module_name(view) 90 | defp extract_view(_), do: nil 91 | 92 | defp extract_assigns(%{socket: %{assigns: assigns}}), do: assigns 93 | defp extract_assigns(_), do: nil 94 | end 95 | -------------------------------------------------------------------------------- /lib/honeybadger/insights/oban.ex: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.Insights.Oban do 2 | @moduledoc """ 3 | Captures telemetry events from Oban job processing. 4 | 5 | ## Default Configuration 6 | 7 | By default, this module listens for the following Oban telemetry events: 8 | 9 | "oban.job.stop" 10 | "oban.job.exception" 11 | 12 | ## Custom Configuration 13 | 14 | You can customize the telemetry events to listen for by updating the insights_config: 15 | 16 | config :honeybadger, insights_config: %{ 17 | oban: %{ 18 | telemetry_events: [ 19 | [:oban, :job, :stop], 20 | [:oban, :job, :exception], 21 | [:oban, :engine, :start] 22 | ] 23 | } 24 | } 25 | """ 26 | 27 | use Honeybadger.Insights.Base 28 | 29 | @required_dependencies [Oban] 30 | 31 | @telemetry_events [ 32 | [:oban, :job, :stop], 33 | [:oban, :job, :exception] 34 | ] 35 | 36 | ## Public API 37 | 38 | @doc """ 39 | Adds the current Honeybadger event context to the Oban job's metadata. 40 | 41 | ## Example 42 | 43 | MyApp.Worker.new() 44 | |> Honeybadger.Insights.Oban.add_event_context() 45 | |> Oban.insert() 46 | """ 47 | def add_event_context(changeset) do 48 | meta = 49 | changeset 50 | |> Ecto.Changeset.get_field(:meta, %{}) 51 | |> Map.put("hb_event_context", Honeybadger.event_context()) 52 | 53 | Ecto.Changeset.put_change(changeset, :meta, meta) 54 | end 55 | 56 | ## Overridable Telemetry Handlers (Internal) 57 | # 58 | def get_telemetry_events() do 59 | events = get_insights_config(:telemetry_events, @telemetry_events) 60 | 61 | [[:oban, :job, :start]] ++ events 62 | end 63 | 64 | @doc false 65 | def extract_metadata(%{conf: conf, job: job, state: state}, _event) do 66 | job 67 | |> Map.take(~w(args attempt id queue tags worker)a) 68 | |> Map.put(:prefix, conf.prefix) 69 | |> Map.put(:state, state) 70 | end 71 | 72 | @doc false 73 | def handle_telemetry([:oban, :job, :start] = event, measurements, metadata, opts) do 74 | if event_context = metadata.job.meta["hb_event_context"] do 75 | Honeybadger.event_context(event_context) 76 | else 77 | Honeybadger.inherit_event_context() 78 | end 79 | 80 | Honeybadger.EventContext.put_new(:request_id, fn -> 81 | Honeybadger.Utils.rand_id() 82 | end) 83 | 84 | if event in get_insights_config(:telemetry_events, @telemetry_events) do 85 | handle_event_impl(event, measurements, metadata, opts) 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /lib/honeybadger/insights/plug.ex: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.Insights.Plug do 2 | @moduledoc """ 3 | Captures telemetry events from HTTP requests processed by Plug and Phoenix. 4 | 5 | ## Default Configuration 6 | 7 | By default, this module listens for the standard Phoenix endpoint telemetry event: 8 | 9 | "phoenix.endpoint.stop" 10 | 11 | This is compatible with the default Phoenix configuration that adds telemetry 12 | via `Plug.Telemetry`: 13 | 14 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 15 | 16 | ## Custom Configuration 17 | 18 | You can customize the telemetry events to listen for by updating the insights_config: 19 | 20 | config :honeybadger, insights_config: %{ 21 | plug: %{ 22 | telemetry_events: [[:my, :prefix, :stop]] 23 | } 24 | } 25 | """ 26 | 27 | use Honeybadger.Insights.Base 28 | 29 | @required_dependencies [Plug] 30 | 31 | @telemetry_events [ 32 | [:phoenix, :endpoint, :stop] 33 | ] 34 | 35 | def get_telemetry_events do 36 | events = get_insights_config(:telemetry_events, @telemetry_events) 37 | 38 | Enum.reduce(events, events, fn init, acc -> 39 | if List.last(init) == :stop do 40 | acc ++ [Enum.drop(init, -1) ++ [:start]] 41 | else 42 | acc 43 | end 44 | end) 45 | end 46 | 47 | def handle_telemetry([_, _, :start] = event, measurements, metadata, opts) do 48 | metadata.conn 49 | |> get_request_id() 50 | |> then(fn 51 | nil -> :ok 52 | request_id -> Honeybadger.EventContext.put_new(:request_id, request_id) 53 | end) 54 | 55 | if event in get_insights_config(:telemetry_events, @telemetry_events) do 56 | handle_event_impl(event, measurements, metadata, opts) 57 | end 58 | end 59 | 60 | def extract_metadata(meta, _event) do 61 | conn = meta.conn 62 | 63 | %{} 64 | |> add_basic_conn_info(conn) 65 | |> add_phoenix_live_view_metadata(conn) 66 | |> add_phoenix_controller_metadata(conn) 67 | |> add_common_phoenix_metadata(conn) 68 | end 69 | 70 | defp add_basic_conn_info(metadata, conn) do 71 | Map.merge(metadata, %{ 72 | params: conn.params, 73 | method: conn.method, 74 | request_path: conn.request_path, 75 | status: conn.status 76 | }) 77 | end 78 | 79 | defp add_phoenix_live_view_metadata(metadata, conn) do 80 | case conn.private[:phoenix_live_view] do 81 | nil -> 82 | metadata 83 | 84 | {module, opts, _extra} -> 85 | metadata 86 | |> Map.put(:route_type, :live) 87 | |> Map.put(:live_view, get_module_name(module)) 88 | |> maybe_put(:live_action, get_in(opts, [:action])) 89 | end 90 | end 91 | 92 | defp add_phoenix_controller_metadata(metadata, conn) do 93 | case conn.private[:phoenix_controller] do 94 | nil -> 95 | metadata 96 | 97 | controller -> 98 | metadata 99 | |> Map.put(:route_type, :controller) 100 | |> Map.put(:controller, get_module_name(controller)) 101 | |> maybe_put(:action, conn.private[:phoenix_action]) 102 | end 103 | end 104 | 105 | defp add_common_phoenix_metadata(metadata, conn) do 106 | # Common keys regardless of route type 107 | common_keys = [ 108 | {:phoenix_format, :format}, 109 | {:phoenix_view, :view}, 110 | {:phoenix_template, :template} 111 | ] 112 | 113 | # Set a default route_type if none has been set yet 114 | metadata_with_type = 115 | if Map.has_key?(metadata, :route_type), 116 | do: metadata, 117 | else: Map.put(metadata, :route_type, :unknown) 118 | 119 | # Add all available common keys 120 | Enum.reduce(common_keys, metadata_with_type, fn 121 | {:phoenix_view, key}, acc -> 122 | case conn.private[:phoenix_view] do 123 | %{_: view} -> 124 | maybe_put(acc, key, get_module_name(view)) 125 | 126 | view when is_binary(view) -> 127 | maybe_put(acc, key, view) 128 | 129 | _ -> 130 | acc 131 | end 132 | 133 | {:phoenix_template, key}, acc -> 134 | case conn.private[:phoenix_template] do 135 | %{_: template} -> 136 | maybe_put(acc, key, template) 137 | 138 | template_map when is_map(template_map) -> 139 | format = conn.private[:phoenix_format] 140 | template = Map.get(template_map, format) 141 | if template, do: Map.put(acc, key, template), else: acc 142 | 143 | template when is_binary(template) -> 144 | maybe_put(acc, key, template) 145 | 146 | _ -> 147 | acc 148 | end 149 | 150 | {private_key, map_key}, acc -> 151 | maybe_put(acc, map_key, conn.private[private_key]) 152 | end) 153 | end 154 | 155 | defp get_request_id(conn) do 156 | case conn.assigns[:request_id] do 157 | nil -> 158 | conn 159 | |> Plug.Conn.get_resp_header("x-request-id") 160 | |> List.first() 161 | 162 | id -> 163 | id 164 | end 165 | end 166 | end 167 | -------------------------------------------------------------------------------- /lib/honeybadger/insights/tesla.ex: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.Insights.Tesla do 2 | @moduledoc """ 3 | Captures telemetry events from Tesla HTTP requests. 4 | 5 | ## Configuration 6 | 7 | This module can be configured in the application config: 8 | 9 | ```elixir 10 | config :honeybadger, insights_config: %{ 11 | tesla: %{ 12 | # Include full URLs in telemetry events (default: false - only hostname is included) 13 | full_url: false, 14 | 15 | # Custom telemetry event patterns to listen for (optional) 16 | telemetry_events: [[:tesla, :request, :stop], [:tesla, :request, :exception]] 17 | } 18 | } 19 | """ 20 | 21 | use Honeybadger.Insights.Base 22 | 23 | @required_dependencies [Tesla] 24 | 25 | @telemetry_events [ 26 | [:tesla, :request, :stop], 27 | [:tesla, :request, :exception] 28 | ] 29 | 30 | def extract_metadata(meta, _name) do 31 | env = Map.get(meta, :env, %{}) 32 | 33 | base = %{ 34 | method: env.method |> to_string() |> String.upcase(), 35 | host: extract_host(env.url), 36 | status_code: env.status 37 | } 38 | 39 | if get_insights_config(:full_url, false) do 40 | Map.put(base, :url, env.url) 41 | else 42 | base 43 | end 44 | end 45 | 46 | # Ignore telemetry events from Finch adapters, since we are already 47 | # instrumenting Finch requests in the Finch adapter module. 48 | def ignore?(meta) do 49 | adapter = 50 | meta 51 | |> Map.get(:env, %{}) 52 | |> Map.get(:__client__, %{}) 53 | |> Map.get(:adapter) 54 | 55 | case adapter do 56 | {Tesla.Adapter.Finch, _, _} -> true 57 | _ -> false 58 | end 59 | end 60 | 61 | defp extract_host(url) when is_binary(url) do 62 | case URI.parse(url) do 63 | %URI{host: host} when is_binary(host) and host != "" -> host 64 | _ -> nil 65 | end 66 | end 67 | 68 | defp extract_host(_), do: nil 69 | end 70 | -------------------------------------------------------------------------------- /lib/honeybadger/invalid_response_error.ex: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.InvalidResponseError do 2 | defexception [:response] 3 | 4 | alias Honeybadger.HTTPAdapter.HTTPResponse 5 | 6 | @type t :: %__MODULE__{ 7 | response: HTTPResponse.t() 8 | } 9 | 10 | def message(exception) do 11 | """ 12 | An invalid response was received. 13 | 14 | #{HTTPResponse.format(exception.response)} 15 | """ 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/honeybadger/json.ex: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.JSON do 2 | @moduledoc false 3 | 4 | @spec encode(term) :: 5 | {:ok, String.t()} 6 | | {:error, Jason.EncodeError.t()} 7 | | {:error, Exception.t()} 8 | def encode(term) do 9 | case safe_encode(term) do 10 | {:ok, output} -> 11 | {:ok, output} 12 | 13 | {:error, _error} -> 14 | term 15 | |> to_encodeable() 16 | |> Jason.encode() 17 | end 18 | end 19 | 20 | # Keep from converting DateTime to a map 21 | defp to_encodeable(%DateTime{} = datetime), do: datetime 22 | 23 | # struct 24 | defp to_encodeable(%_{} = struct) do 25 | struct 26 | |> Map.from_struct() 27 | |> to_encodeable() 28 | end 29 | 30 | # map 31 | defp to_encodeable(map) when is_map(map) do 32 | for {key, val} <- map, into: %{} do 33 | {key, to_encodeable(val)} 34 | end 35 | end 36 | 37 | # list 38 | defp to_encodeable(list) when is_list(list) do 39 | for element <- list, do: to_encodeable(element) 40 | end 41 | 42 | # tuple 43 | defp to_encodeable(tuple) when is_tuple(tuple) do 44 | tuple 45 | |> Tuple.to_list() 46 | |> to_encodeable 47 | end 48 | 49 | defp to_encodeable(input) 50 | when is_pid(input) or is_port(input) or is_reference(input) or is_function(input) do 51 | inspect(input) 52 | end 53 | 54 | defp to_encodeable(input) when is_binary(input) do 55 | case :unicode.characters_to_binary(input) do 56 | {:error, binary, _rest} -> binary 57 | {:incomplete, binary, _rest} -> binary 58 | _ -> input 59 | end 60 | end 61 | 62 | defp to_encodeable(input) do 63 | input 64 | end 65 | 66 | def safe_encode(input) do 67 | Jason.encode(input) 68 | rescue 69 | error in Protocol.UndefinedError -> 70 | {:error, error} 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/honeybadger/logger.ex: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.Logger do 2 | alias Honeybadger.Breadcrumbs.{Collector, Breadcrumb} 3 | 4 | @moduledoc false 5 | 6 | @behaviour :gen_event 7 | 8 | @impl true 9 | def init(__MODULE__) do 10 | init({__MODULE__, []}) 11 | end 12 | 13 | def init({__MODULE__, opts}) when is_list(opts) do 14 | {:ok, %{level: opts[:level]}} 15 | end 16 | 17 | @impl true 18 | def handle_call({:configure, _options}, state) do 19 | {:ok, :ok, state} 20 | end 21 | 22 | @impl true 23 | def handle_event({_type, gl, _msg}, state) when node(gl) != node() do 24 | {:ok, state} 25 | end 26 | 27 | def handle_event({:error, _gl, {Logger, message, _ts, metadata}}, state) do 28 | unless domain_ignored?(metadata[:domain], Honeybadger.get_env(:ignored_domains)) || 29 | internal_error?(metadata[:application]) do 30 | details = extract_details(message) 31 | context = extract_context(metadata) 32 | full_context = Map.merge(details, context) 33 | 34 | case Keyword.get(metadata, :crash_reason) do 35 | {reason, stacktrace} -> 36 | notify(reason, full_context, stacktrace) 37 | 38 | reason when is_atom(reason) and not is_nil(reason) -> 39 | notify(reason, full_context, []) 40 | 41 | _ -> 42 | unless get_config(:sasl_logging_only) do 43 | notify(%RuntimeError{message: message}, full_context, []) 44 | end 45 | end 46 | end 47 | 48 | {:ok, state} 49 | end 50 | 51 | def handle_event(_, state) do 52 | {:ok, state} 53 | end 54 | 55 | @impl true 56 | def handle_info(_msg, state) do 57 | {:ok, state} 58 | end 59 | 60 | @impl true 61 | def code_change(_old_vsn, state, _extra) do 62 | {:ok, state} 63 | end 64 | 65 | @impl true 66 | def terminate(_reason, _state) do 67 | :ok 68 | end 69 | 70 | ## Helpers 71 | 72 | defp notify(reason, metadata, stacktrace) do 73 | breadcrumbs = Collector.put(Collector.breadcrumbs(), Breadcrumb.from_error(reason)) 74 | metadata_with_breadcrumbs = Map.put(metadata, :breadcrumbs, breadcrumbs) 75 | 76 | Honeybadger.notify(reason, metadata: metadata_with_breadcrumbs, stacktrace: stacktrace) 77 | end 78 | 79 | def domain_ignored?(domain, ignored) when is_list(domain) and is_list(ignored) do 80 | Enum.any?(ignored, fn ignore -> Enum.member?(domain, ignore) end) 81 | end 82 | 83 | def domain_ignored?(_domain, _ignored), do: false 84 | 85 | def internal_error?(:honeybadger), do: true 86 | def internal_error?(_), do: false 87 | 88 | @standard_metadata ~w(ancestors callers crash_reason file function line module pid)a 89 | 90 | defp extract_context(metadata) do 91 | metadata 92 | |> Keyword.drop(@standard_metadata) 93 | |> Map.new() 94 | end 95 | 96 | # Elixir < 1.17 97 | defp extract_details([["GenServer ", _pid, _res, _stack, _last, _, _, last], _, state]) do 98 | %{last_message: last, state: state} 99 | end 100 | 101 | # Elixir < 1.17 102 | defp extract_details([[":gen_event handler ", name, _, _, _, _stack, _last, last], _, state]) do 103 | %{name: name, last_message: last, state: state} 104 | end 105 | 106 | # Elixir >= 1.17 107 | defp extract_details([["GenServer ", _pid, _res, _stack, _last, _, _, _, last], _, state]) do 108 | %{last_message: last, state: state} 109 | end 110 | 111 | # Elixir >= 1.17 112 | defp extract_details([[":gen_event handler ", name, _, _, _, _stack, _, _last, last], _, state]) do 113 | %{name: name, last_message: last, state: state} 114 | end 115 | 116 | defp extract_details(["Process ", pid | _]) do 117 | %{name: pid} 118 | end 119 | 120 | defp extract_details(["Task " <> _, _, "\nFunction: " <> fun, "\n Args: " <> args]) do 121 | %{function: fun, args: args} 122 | end 123 | 124 | defp extract_details(_message) do 125 | %{} 126 | end 127 | 128 | defp get_config(key) do 129 | Application.get_env(:honeybadger, key) 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /lib/honeybadger/notice.ex: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.Notice do 2 | @doc false 3 | 4 | alias Honeybadger.{Backtrace, Utils} 5 | alias Honeybadger.Breadcrumbs.{Collector} 6 | 7 | @type error :: %{ 8 | backtrace: list, 9 | class: atom | iodata, 10 | fingerprint: String.t(), 11 | message: iodata, 12 | tags: list 13 | } 14 | 15 | @type notifier :: %{name: String.t(), url: String.t(), version: String.t()} 16 | 17 | @type server :: %{ 18 | environment_name: atom, 19 | hostname: String.t(), 20 | project_root: Path.t(), 21 | revision: String.t() 22 | } 23 | 24 | @type noticeable :: Exception.t() | String.t() | map() | atom() 25 | 26 | @type t :: %__MODULE__{ 27 | notifier: notifier(), 28 | server: server(), 29 | error: error(), 30 | breadcrumbs: Collector.t(), 31 | request: map(), 32 | correlation_context: map() 33 | } 34 | 35 | @url get_in(Honeybadger.Mixfile.project(), [:package, :links, "GitHub"]) 36 | @version Honeybadger.Mixfile.project()[:version] 37 | @notifier %{name: "honeybadger-elixir", language: "elixir", url: @url, version: @version} 38 | 39 | @derive Jason.Encoder 40 | @enforce_keys [:breadcrumbs, :notifier, :server, :error, :request, :correlation_context] 41 | defstruct [:breadcrumbs, :notifier, :server, :error, :request, :correlation_context] 42 | 43 | @spec new(noticeable(), map(), list(), String.t()) :: t() 44 | def new(error, metadata, stacktrace, fingerprint \\ "") 45 | 46 | def new(message, metadata, stacktrace, fingerprint) 47 | when is_binary(message) and is_map(metadata) and is_list(stacktrace) do 48 | new(%RuntimeError{message: message}, metadata, stacktrace, fingerprint) 49 | end 50 | 51 | def new(%{class: exception_name, message: message}, metadata, stacktrace, fingerprint) 52 | when is_map(metadata) and is_list(stacktrace) do 53 | new(exception_name, message, metadata, stacktrace, fingerprint) 54 | end 55 | 56 | def new(exception, metadata, stacktrace, fingerprint) 57 | when is_map(metadata) and is_list(stacktrace) do 58 | {exception, _stacktrace} = Exception.blame(:error, exception, stacktrace) 59 | 60 | %{__struct__: exception_mod} = exception 61 | 62 | class = Utils.module_to_string(exception_mod) 63 | message = exception_mod.message(exception) 64 | 65 | new(class, message, metadata, stacktrace, fingerprint) 66 | end 67 | 68 | # bundles exception (or pseudo exception) information in to notice 69 | defp new(class, message, metadata, stacktrace, fingerprint) do 70 | message = if message, do: IO.iodata_to_binary(message), else: nil 71 | 72 | error = %{ 73 | class: class, 74 | message: message, 75 | backtrace: Backtrace.from_stacktrace(stacktrace), 76 | tags: Map.get(metadata, :tags, []), 77 | fingerprint: fingerprint 78 | } 79 | 80 | request = 81 | metadata 82 | |> Map.get(:plug_env, %{}) 83 | |> Map.put(:context, Map.get(metadata, :context, %{})) 84 | 85 | correlation_context = Map.take(metadata, [:request_id]) 86 | 87 | filter(%__MODULE__{ 88 | breadcrumbs: Map.get(metadata, :breadcrumbs, %{}), 89 | error: error, 90 | request: request, 91 | notifier: @notifier, 92 | server: server(), 93 | correlation_context: correlation_context 94 | }) 95 | end 96 | 97 | defp filter(notice) do 98 | case Honeybadger.get_env(:notice_filter) do 99 | nil -> notice 100 | notice_filter -> notice_filter.filter(notice) 101 | end 102 | end 103 | 104 | defp server do 105 | %{ 106 | environment_name: Honeybadger.get_env(:environment_name), 107 | hostname: Honeybadger.get_env(:hostname), 108 | project_root: Honeybadger.get_env(:project_root), 109 | revision: Honeybadger.get_env(:revision) 110 | } 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /lib/honeybadger/notice_filter.ex: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.NoticeFilter do 2 | @moduledoc """ 3 | Specification for a top level `Honeybadger.Notice` filter. 4 | 5 | Most users won't need this, but if you need complete control over 6 | filtering, implement this behaviour and configure like: 7 | 8 | config :honeybadger, 9 | notice_filter: MyApp.MyNoticeFilter 10 | """ 11 | 12 | @callback filter(Honeybadger.Notice.t()) :: Honeybadger.Notice.t() 13 | end 14 | -------------------------------------------------------------------------------- /lib/honeybadger/notice_filter/default.ex: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.NoticeFilter.Default do 2 | @behaviour Honeybadger.NoticeFilter 3 | 4 | @cgi_disable_url_filters ~w(original_fullpath query_string path_info) 5 | 6 | def filter(%Honeybadger.Notice{} = notice) do 7 | if filter = Honeybadger.get_env(:filter) do 8 | notice 9 | |> Map.put(:request, filter_request(notice.request, filter)) 10 | |> Map.put(:error, filter_error(notice.error, filter)) 11 | |> Map.put(:breadcrumbs, filter_breadcrumbs(notice.breadcrumbs, filter)) 12 | else 13 | notice 14 | end 15 | end 16 | 17 | defp filter_request(request, filter) do 18 | request 19 | |> apply_filter(:context, &filter.filter_context/1) 20 | |> apply_filter(:params, &filter.filter_params/1) 21 | |> apply_filter(:cgi_data, &filter_cgi_data(filter, &1)) 22 | |> apply_filter(:session, &filter.filter_session/1) 23 | |> disable(:filter_disable_url, :url) 24 | |> disable(:filter_disable_session, :session) 25 | |> disable(:filter_disable_params, :params) 26 | end 27 | 28 | defp filter_error(%{message: message} = error, filter) do 29 | Map.put(error, :message, filter.filter_error_message(message)) 30 | end 31 | 32 | defp filter_breadcrumbs(breadcrumbs, filter) do 33 | Map.update(breadcrumbs, :trail, %{}, &filter.filter_breadcrumbs/1) 34 | end 35 | 36 | defp apply_filter(request, key, filter_fn) do 37 | case Map.get(request, key) do 38 | nil -> request 39 | target -> Map.put(request, key, filter_fn.(target)) 40 | end 41 | end 42 | 43 | defp filter_cgi_data(filter, map) do 44 | keys = cgi_filter_keys() 45 | 46 | map 47 | |> filter.filter_cgi_data() 48 | |> filter.filter_map(keys) 49 | end 50 | 51 | defp cgi_filter_keys do 52 | filter_keys = Honeybadger.get_env(:filter_keys) 53 | 54 | if Honeybadger.get_env(:filter_disable_url) do 55 | filter_keys ++ @cgi_disable_url_filters 56 | else 57 | filter_keys 58 | end 59 | end 60 | 61 | defp disable(request, config_key, map_key) do 62 | if Honeybadger.get_env(config_key) do 63 | Map.drop(request, [map_key]) 64 | else 65 | request 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/honeybadger/plug.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Plug) do 2 | defmodule Honeybadger.Plug do 3 | @moduledoc """ 4 | The `Honeybadger.Plug` adds automatic error handling to a plug pipeline. 5 | 6 | Within a `Plug.Router` or `Phoenix.Router` use the module and crashes will 7 | be reported to Honeybadger. It's best to `use Honeybadger.Plug` **after 8 | the Router plugs** so that exceptions due to non-matching routes are not 9 | reported to Honeybadger. 10 | 11 | ### Example 12 | 13 | defmodule MyPhoenixApp.Router do 14 | use Crywolf.Web, :router 15 | use Honeybadger.Plug 16 | 17 | pipeline :browser do 18 | [...] 19 | end 20 | end 21 | 22 | ## Customizing 23 | 24 | Data reporting may be customized by passing an alternate `:plug_data` 25 | module. This is useful when working with alternate frameworks, such as 26 | Absinthe for GraphQL APIs. 27 | 28 | Any module with a `metadata/2` function that accepts a `Plug.Conn` and a 29 | `module` name can be used to generate metadata. 30 | 31 | ### Example 32 | 33 | defmodule MyPhoenixApp.Router do 34 | use Crywolf.Web, :router 35 | use Honeybadger.Plug, plug_data: MyAbsinthePlugData 36 | end 37 | """ 38 | 39 | alias Honeybadger.PlugData 40 | alias Honeybadger.Breadcrumbs.{Breadcrumb, Collector} 41 | 42 | @doc false 43 | defmacro __using__(opts) do 44 | quote location: :keep do 45 | use Plug.ErrorHandler 46 | 47 | @plug_data Keyword.get(unquote(opts), :plug_data, PlugData) 48 | 49 | @doc """ 50 | Called by `Plug.ErrorHandler` when an error is caught. 51 | 52 | By default this ignores "Not Found" errors for `Plug` or `Phoenix` 53 | pipelines. It may be overridden to ignore additional errors or to 54 | customize the data that is used for notifications. 55 | """ 56 | @impl Plug.ErrorHandler 57 | def handle_errors(_conn, %{reason: %FunctionClauseError{function: :do_match}}), do: :ok 58 | 59 | def handle_errors(conn, %{reason: reason, stack: stack}) do 60 | if Plug.Exception.status(reason) == 404 do 61 | # 404 errors are not reported 62 | :ok 63 | else 64 | Collector.add(Breadcrumb.from_error(reason)) 65 | metadata = @plug_data.metadata(conn, __MODULE__) 66 | Honeybadger.notify(reason, metadata: metadata, stacktrace: stack) 67 | end 68 | end 69 | 70 | defoverridable handle_errors: 2 71 | end 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/honeybadger/plug_data.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Plug) do 2 | defmodule Honeybadger.PlugData do 3 | @moduledoc false 4 | 5 | alias Plug.Conn 6 | 7 | @type plug_env :: %{ 8 | action: binary(), 9 | cgi_data: map(), 10 | component: binary(), 11 | params: map(), 12 | session: map(), 13 | url: binary() 14 | } 15 | 16 | @type metadata :: %{ 17 | context: any(), 18 | plug_env: plug_env() 19 | } 20 | 21 | @doc """ 22 | Generate notification metadata from a `Plug.Conn`. 23 | 24 | The map that is returned contains the current context and environmental 25 | data. 26 | """ 27 | @spec metadata(Conn.t(), module()) :: metadata() 28 | def metadata(conn, module) do 29 | %{context: Honeybadger.context(), plug_env: build_plug_env(conn, module)} 30 | end 31 | 32 | @doc false 33 | @spec build_plug_env(Conn.t(), module()) :: plug_env() 34 | def build_plug_env(%Conn{} = conn, module) do 35 | conn = Conn.fetch_query_params(conn) 36 | 37 | %{ 38 | params: conn.params, 39 | session: %{}, 40 | url: conn.request_path, 41 | action: action(conn), 42 | component: component(conn, module), 43 | cgi_data: build_cgi_data(conn) 44 | } 45 | end 46 | 47 | @doc false 48 | @spec build_cgi_data(Conn.t()) :: map() 49 | def build_cgi_data(%Conn{} = conn) do 50 | cgi_data = %{ 51 | "REQUEST_METHOD" => conn.method, 52 | "PATH_INFO" => Enum.join(conn.path_info, "/"), 53 | "QUERY_STRING" => conn.query_string, 54 | "SCRIPT_NAME" => Enum.join(conn.script_name, "/"), 55 | "REMOTE_ADDR" => remote_addr(conn), 56 | "REMOTE_PORT" => remote_port(conn), 57 | "SERVER_ADDR" => "127.0.0.1", 58 | "SERVER_NAME" => Honeybadger.get_env(:hostname), 59 | "SERVER_PORT" => conn.port, 60 | "CONTENT_LENGTH" => Conn.get_req_header(conn, "content-length"), 61 | "ORIGINAL_FULLPATH" => conn.request_path 62 | } 63 | 64 | headers = rack_format_headers(conn) 65 | 66 | Map.merge(cgi_data, headers) 67 | end 68 | 69 | # Helpers 70 | 71 | defp component(%Conn{private: private}, module) do 72 | import Honeybadger.Utils, only: [module_to_string: 1] 73 | 74 | case private do 75 | %{phoenix_controller: controller} -> module_to_string(controller) 76 | _ -> module_to_string(module) 77 | end 78 | end 79 | 80 | defp action(%Conn{private: private}) do 81 | case private do 82 | %{phoenix_action: action_name} -> to_string(action_name) 83 | _ -> "" 84 | end 85 | end 86 | 87 | defp rack_format_headers(%Conn{req_headers: req_headers}) do 88 | Enum.reduce(req_headers, %{}, fn {header, value}, acc -> 89 | header = ("HTTP_" <> String.upcase(header)) |> String.replace("-", "_") 90 | 91 | Map.put(acc, header, value) 92 | end) 93 | end 94 | 95 | defp remote_addr(%Conn{remote_ip: remote_ip}) do 96 | case :inet.ntoa(remote_ip) do 97 | {:error, :einval} -> 98 | to_string(remote_ip) 99 | 100 | charlist -> 101 | List.to_string(charlist) 102 | end 103 | end 104 | 105 | defp remote_port(conn) do 106 | cond do 107 | function_exported?(Conn, :get_peer_data, 1) -> 108 | conn 109 | |> Conn.get_peer_data() 110 | |> Map.get(:port) 111 | 112 | Map.has_key?(conn, :peer) -> 113 | conn 114 | |> Map.get(:peer) 115 | |> elem(1) 116 | 117 | true -> 118 | nil 119 | end 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /lib/honeybadger/server_unreachable_error.ex: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.ServerUnreachableError do 2 | defexception [:http_adapter, :request_url, :reason] 3 | 4 | @type t :: %__MODULE__{ 5 | http_adapter: module(), 6 | request_url: binary(), 7 | reason: term() 8 | } 9 | 10 | def message(exception) do 11 | [url | _rest] = String.split(exception.request_url, "?", parts: 2) 12 | 13 | """ 14 | The server was unreachable. 15 | 16 | HTTP Adapter: #{inspect(exception.http_adapter)} 17 | Request URL: #{url} 18 | 19 | Reason: 20 | #{inspect(exception.reason)} 21 | """ 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/honeybadger/utils.ex: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.Utils do 2 | @moduledoc """ 3 | Assorted helper functions used through out the Honeybadger package. 4 | """ 5 | 6 | @doc """ 7 | Internally all modules are prefixed with Elixir. This function removes the 8 | `Elixir` prefix from the module when it is converted to a string. 9 | 10 | # Example 11 | 12 | iex> Honeybadger.Utils.module_to_string(Honeybadger.Utils) 13 | "Honeybadger.Utils" 14 | """ 15 | def module_to_string(module) do 16 | module 17 | |> Module.split() 18 | |> Enum.join(".") 19 | end 20 | 21 | @doc """ 22 | Concatenate a list of items with a dot separator. 23 | 24 | # Example 25 | 26 | iex> Honeybadger.Utils.dotify([:Honeybadger, :Utils]) 27 | "Honeybadger.Utils" 28 | """ 29 | def dotify(path) when is_list(path) do 30 | Enum.map_join(path, ".", &to_string/1) 31 | end 32 | 33 | @doc """ 34 | Transform value into a consistently cased string representation 35 | 36 | # Example 37 | 38 | iex> Honeybadger.Utils.canonicalize(:User_SSN) 39 | "user_ssn" 40 | 41 | """ 42 | def canonicalize(val) do 43 | val 44 | |> to_string() 45 | |> String.downcase() 46 | end 47 | 48 | def rand_id(size \\ 16) do 49 | size 50 | |> :crypto.strong_rand_bytes() 51 | |> Base.encode16(case: :lower) 52 | end 53 | 54 | @doc """ 55 | Configurable data sanitization. This currently: 56 | 57 | - recursively truncates deep structures (to a depth of 20) 58 | - constrains large string values (to 64k) 59 | - filters out any map keys that might contain sensitive information. 60 | 61 | Options: 62 | - `:remove_filtered` - When `true`, filtered keys will be removed instead of replaced with "[FILTERED]". Default: `false` 63 | """ 64 | @depth_token "[DEPTH]" 65 | @truncated_token "[TRUNCATED]" 66 | @filtered_token "[FILTERED]" 67 | 68 | # 64k with enough space to concat truncated_token 69 | @default_max_string_size 64 * 1024 - 11 70 | @default_max_depth 20 71 | 72 | def sanitize(value, opts \\ []) do 73 | base = %{ 74 | max_depth: @default_max_depth, 75 | max_string_size: @default_max_string_size, 76 | filter_keys: Honeybadger.get_env(:filter_keys), 77 | remove_filtered: false 78 | } 79 | 80 | opts = 81 | Enum.into(opts, base) 82 | |> Map.update!(:filter_keys, fn v -> MapSet.new(v, &canonicalize/1) end) 83 | 84 | sanitize_val(value, Map.put(opts, :depth, 0)) 85 | end 86 | 87 | defp sanitize_val(v, %{depth: depth, max_depth: depth}) when is_map(v) or is_list(v) do 88 | @depth_token 89 | end 90 | 91 | defp sanitize_val(%DateTime{} = datetime, _), do: DateTime.to_iso8601(datetime) 92 | defp sanitize_val(%NaiveDateTime{} = naive, _), do: NaiveDateTime.to_iso8601(naive) 93 | defp sanitize_val(%Date{} = date, _), do: Date.to_iso8601(date) 94 | defp sanitize_val(%Time{} = time, _), do: Time.to_iso8601(time) 95 | 96 | defp sanitize_val(%{__struct__: _} = struct, opts) do 97 | sanitize_val(Map.from_struct(struct), opts) 98 | end 99 | 100 | defp sanitize_val(v, opts) when is_map(v) do 101 | %{depth: depth, filter_keys: filter_keys, remove_filtered: remove_filtered} = opts 102 | 103 | Enum.reduce(v, %{}, fn {key, val}, acc -> 104 | if MapSet.member?(filter_keys, canonicalize(key)) do 105 | if remove_filtered do 106 | # Skip this key entirely when remove_filtered is true 107 | acc 108 | else 109 | # Traditional behavior: replace with filtered token 110 | Map.put(acc, key, @filtered_token) 111 | end 112 | else 113 | # If the sanitized value is empty after removal, we don't want to 114 | # include it in the sanitized map. 115 | case sanitize_val(val, Map.put(opts, :depth, depth + 1)) do 116 | v when is_map(v) and map_size(v) == 0 and v != val -> 117 | acc 118 | 119 | v when is_list(v) and length(v) == 0 and v != val -> 120 | acc 121 | 122 | v -> 123 | Map.put(acc, key, v) 124 | end 125 | end 126 | end) 127 | end 128 | 129 | defp sanitize_val(v, %{depth: depth} = opts) when is_list(v) do 130 | Enum.map(v, &sanitize_val(&1, Map.put(opts, :depth, depth + 1))) 131 | end 132 | 133 | defp sanitize_val(v, %{max_string_size: max_string_size}) when is_binary(v) do 134 | if String.valid?(v) and String.length(v) > max_string_size do 135 | String.slice(v, 0, max_string_size) <> @truncated_token 136 | else 137 | v 138 | end 139 | end 140 | 141 | defp sanitize_val(v, _), do: v 142 | end 143 | -------------------------------------------------------------------------------- /lib/mix/tasks/test.ex: -------------------------------------------------------------------------------- 1 | defmodule HoneybadgerTestingException do 2 | defexception message: """ 3 | Testing honeybadger via `mix honeybadger.test`. If you can see this, it works. 4 | """ 5 | end 6 | 7 | defmodule Mix.Tasks.Honeybadger.Test do 8 | use Mix.Task 9 | 10 | @shortdoc "Verify your hex package installation by sending a test exception to the honeybadger service" 11 | 12 | def run(_) do 13 | with :ok <- assert_env() do 14 | send_notice() 15 | end 16 | end 17 | 18 | defp send_notice do 19 | # mute excluded envs 20 | Application.put_env(:honeybadger, :exclude_envs, []) 21 | 22 | {:ok, _started} = Application.ensure_all_started(:honeybadger) 23 | 24 | # send the notification 25 | Honeybadger.notify(%HoneybadgerTestingException{}) 26 | 27 | # this will block the mix task from stopping before 28 | # the genserver sends the notification to honeybadger 29 | Honeybadger.Client |> Process.whereis() |> GenServer.stop() 30 | 31 | # if there is no error till this point, we should assume that our notice succeeded 32 | 33 | Mix.shell().info(""" 34 | Raising 'HoneybadgerTestingException' to simulate application failure. 35 | ⚡ --- Honeybadger is installed! ----------------------------------------------- 36 | 37 | Good news: You're one deploy away from seeing all of your exceptions in 38 | Honeybadger. For now, we've generated a test exception for you. 39 | 40 | If you ever need help: 41 | 42 | - Check out our documentation: https://hexdocs.pm/honeybadger 43 | - Email the founders: support@honeybadger.io 44 | 45 | Most people don't realize that Honeybadger is a small, bootstrapped company. We 46 | really couldn't do this without you. Thank you for allowing us to do what we 47 | love: making developers awesome. 48 | 49 | Happy 'badgering! 50 | 51 | Sincerely, 52 | Ben, Josh and Starr 53 | https://www.honeybadger.io/about/ 54 | 55 | ⚡ --- End -------------------------------------------------------------------- 56 | """) 57 | end 58 | 59 | defp assert_env do 60 | try do 61 | # to be able to read the env 62 | Mix.Task.run("app.start") 63 | Honeybadger.get_env(:api_key) 64 | :ok 65 | rescue 66 | _ -> 67 | Mix.shell().error(""" 68 | Your api_key is not set 69 | Set it either in your config file or using the HONEYBADGER_API_KEY environment variable 70 | 71 | For more info visit: https://github.com/honeybadger-io/honeybadger-elixir#2-set-your-api-key-and-environment-name 72 | """) 73 | 74 | :error 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.Mixfile do 2 | use Mix.Project 3 | 4 | @source_url "https://github.com/honeybadger-io/honeybadger-elixir" 5 | @version "0.24.0" 6 | 7 | def project do 8 | [ 9 | app: :honeybadger, 10 | version: @version, 11 | elixir: "~> 1.11", 12 | consolidate_protocols: Mix.env() != :test, 13 | build_embedded: Mix.env() == :prod, 14 | start_permanent: Mix.env() == :prod, 15 | aliases: aliases(), 16 | deps: deps(), 17 | elixirc_paths: elixirc_paths(Mix.env()), 18 | preferred_cli_env: ["test.ci": :test], 19 | 20 | # Hex 21 | package: package(), 22 | name: "Honeybadger", 23 | homepage_url: "https://honeybadger.io", 24 | description: """ 25 | Elixir client, Plug and error_logger for integrating with the 26 | Honeybadger.io exception tracker" 27 | """, 28 | 29 | # Dialyzer 30 | dialyzer: [ 31 | plt_add_apps: [:plug, :mix, :ecto], 32 | flags: [:error_handling, :race_conditions, :underspecs] 33 | ], 34 | 35 | # Xref 36 | xref: [exclude: [Plug.Conn, Ecto.Changeset]], 37 | 38 | # Docs 39 | docs: [ 40 | main: "readme", 41 | source_ref: "v#{@version}", 42 | source_url: @source_url, 43 | extras: ["README.md", "CHANGELOG.md"] 44 | ] 45 | ] 46 | end 47 | 48 | # We use a non standard location for mix tasks as we don't want them to leak 49 | # into the host apps mix tasks. This way our release task is shown only in 50 | # our mix tasks 51 | defp elixirc_paths(:dev), do: ["lib", "mix"] 52 | defp elixirc_paths(:test), do: ["lib", "test/support"] 53 | defp elixirc_paths(_), do: ["lib"] 54 | 55 | def application do 56 | [ 57 | extra_applications: [:logger, :public_key], 58 | env: [ 59 | api_key: {:system, "HONEYBADGER_API_KEY"}, 60 | app: nil, 61 | breadcrumbs_enabled: true, 62 | ecto_repos: [], 63 | environment_name: Mix.env(), 64 | exclude_envs: [:dev, :test], 65 | sasl_logging_only: true, 66 | origin: "https://api.honeybadger.io", 67 | proxy: nil, 68 | proxy_auth: {nil, nil}, 69 | hackney_opts: [], 70 | use_logger: true, 71 | ignored_domains: [:cowboy], 72 | exclude_errors: [], 73 | 74 | # Filters 75 | notice_filter: Honeybadger.NoticeFilter.Default, 76 | event_filter: Honeybadger.EventFilter.Default, 77 | filter: Honeybadger.Filter.Default, 78 | filter_keys: [:password, :credit_card, :__changed__, :flash, :_csrf_token], 79 | filter_args: false, 80 | filter_disable_url: false, 81 | filter_disable_params: false, 82 | filter_disable_assigns: true, 83 | filter_disable_session: false, 84 | 85 | # Insights 86 | insights_enabled: false, 87 | insights_sample_rate: 100.0, 88 | insights_config: %{}, 89 | 90 | # Events 91 | events_worker_enabled: true, 92 | events_max_batch_retries: 3, 93 | events_batch_size: 1000, 94 | events_max_queue_size: 10000, 95 | events_timeout: 5000, 96 | events_throttle_wait: 60000 97 | ], 98 | mod: {Honeybadger, []} 99 | ] 100 | end 101 | 102 | defp deps do 103 | [ 104 | {:hackney, "~> 1.1", optional: true}, 105 | {:req, "~> 0.5.0", optional: true}, 106 | {:jason, "~> 1.0"}, 107 | {:plug, ">= 1.0.0 and < 2.0.0", optional: true}, 108 | {:ecto, ">= 2.0.0", optional: true}, 109 | {:phoenix, ">= 1.0.0 and < 2.0.0", optional: true}, 110 | {:telemetry, "~> 0.4 or ~> 1.0"}, 111 | {:process_tree, "~> 0.2.1"}, 112 | 113 | # Dev dependencies 114 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, 115 | {:dialyxir, "~> 1.0", only: :dev, runtime: false}, 116 | {:expublish, "~> 2.5", only: [:dev], runtime: false}, 117 | 118 | # Test dependencies 119 | {:plug_cowboy, ">= 2.0.0 and < 3.0.0", only: :test}, 120 | {:test_server, "~> 0.1.18", only: [:test]} 121 | ] 122 | end 123 | 124 | defp package do 125 | [ 126 | licenses: ["MIT"], 127 | maintainers: ["Joshua Wood"], 128 | links: %{ 129 | "Changelog" => "#{@source_url}/blob/master/CHANGELOG.md", 130 | "GitHub" => @source_url 131 | } 132 | ] 133 | end 134 | 135 | defp aliases do 136 | [ 137 | "deps.ci": [ 138 | "deps.get --only test", 139 | "cmd --cd dummy/mixapp mix deps.get --only test" 140 | ], 141 | "test.ci": [ 142 | "test --raise", 143 | "cmd --cd dummy/mixapp mix test --raise" 144 | ] 145 | ] 146 | end 147 | end 148 | -------------------------------------------------------------------------------- /mix/tasks/hex_release.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.HexRelease do 2 | use Mix.Task 3 | 4 | @shortdoc "Publish package to hex.pm, create a git tag and push it to GitHub" 5 | 6 | @moduledoc """ 7 | release uses shipit. 8 | It performs many sanity checks before pushing the hex package. 9 | Check out https://github.com/wojtekmach/shipit for more details 10 | """ 11 | 12 | def run([]) do 13 | ensure_shipit_installed!() 14 | Mix.Task.run("shipit", ["master", current_version()]) 15 | end 16 | 17 | def run(_) do 18 | Mix.raise(""" 19 | Invalid args. 20 | 21 | Usage: 22 | 23 | mix release 24 | """) 25 | end 26 | 27 | defp ensure_shipit_installed! do 28 | loadpaths!() 29 | Mix.Task.load_all() 30 | if !Mix.Task.get("shipit") do 31 | Mix.raise(""" 32 | You don't seem to have the shipit mix task installed on your computer. 33 | Install it using: 34 | 35 | mix archive.install hex shipit 36 | 37 | Fore more info go to: https://github.com/wojtekmach/shipit 38 | """) 39 | end 40 | end 41 | 42 | defp current_version do 43 | Mix.Project.config[:version] 44 | end 45 | 46 | # Copied from Mix.Tasks.Help 47 | # Loadpaths without checks because tasks may be defined in deps. 48 | defp loadpaths! do 49 | Mix.Task.run "loadpaths", ["--no-elixir-version-check", "--no-deps-check", "--no-archives-check"] 50 | Mix.Task.reenable "loadpaths" 51 | Mix.Task.reenable "deps.loadpaths" 52 | end 53 | 54 | end 55 | -------------------------------------------------------------------------------- /test/honeybadger/backtrace_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.BacktraceTest do 2 | use Honeybadger.Case, async: true 3 | 4 | alias Honeybadger.Backtrace 5 | 6 | test "converting a stacktrace to the format Honeybadger expects" do 7 | stacktrace = [ 8 | {:erlang, :some_func, [{:ok, 123}], []}, 9 | {Honeybadger, :notify, [%RuntimeError{message: "error"}, %{a: 1}, [:a, :b]], 10 | [file: ~c"lib/honeybadger.ex", line: 38]}, 11 | {Honeybadger.Backtrace, :from_stacktrace, 1, 12 | [file: ~c"lib/honeybadger/backtrace.ex", line: 4, error_info: %{module: :erl_erts_errors}]} 13 | ] 14 | 15 | with_config([filter_args: false], fn -> 16 | assert [entry_1, entry_2, entry_3] = Backtrace.from_stacktrace(stacktrace) 17 | 18 | assert entry_1 == %{ 19 | file: nil, 20 | number: nil, 21 | method: "some_func/1", 22 | args: ["{:ok, 123}"], 23 | context: "all" 24 | } 25 | 26 | assert entry_2 == %{ 27 | file: "lib/honeybadger.ex", 28 | number: 38, 29 | method: "notify/3", 30 | args: ["%RuntimeError{message: \"error\"}", "%{a: 1}", "[:a, :b]"], 31 | context: "all" 32 | } 33 | 34 | assert entry_3 == %{ 35 | file: "lib/honeybadger/backtrace.ex", 36 | number: 4, 37 | method: "from_stacktrace/1", 38 | args: [], 39 | context: "all" 40 | } 41 | end) 42 | end 43 | 44 | test "including args can be disabled" do 45 | stacktrace = [{Honeybadger, :something, [1, 2, 3], []}] 46 | 47 | with_config([filter_args: true], fn -> 48 | assert [entry_1] = Backtrace.from_stacktrace(stacktrace) 49 | assert match?(%{method: "something/3", args: []}, entry_1) 50 | end) 51 | end 52 | 53 | test "args are included by default" do 54 | stacktrace = [{Honeybadger, :something, [1, 2, 3], []}] 55 | 56 | [ 57 | %{ 58 | args: ["1", "2", "3"], 59 | context: "all", 60 | file: nil, 61 | method: "something/3", 62 | number: nil 63 | } 64 | ] = Backtrace.from_stacktrace(stacktrace) 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /test/honeybadger/breadcrumbs/collector_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.Breadcrumbs.CollectorTest do 2 | use Honeybadger.Case 3 | 4 | alias Honeybadger.Breadcrumbs.{Collector, Breadcrumb} 5 | 6 | test "stores and outputs data" do 7 | bc1 = Breadcrumb.new("test1", []) 8 | bc2 = Breadcrumb.new("test2", []) 9 | Collector.add(bc1) 10 | Collector.add(bc2) 11 | 12 | assert Collector.output() == %{enabled: true, trail: [bc1, bc2]} 13 | end 14 | 15 | test "runs metadata through sanitizer" do 16 | bc1 = Breadcrumb.new("test1", metadata: %{key1: %{key2: 12}}) 17 | 18 | Collector.add(bc1) 19 | 20 | assert List.first(Collector.output()[:trail]).metadata == %{key1: "[DEPTH]"} 21 | end 22 | 23 | test "ignores when breadcrumbs are disabled" do 24 | with_config([breadcrumbs_enabled: false], fn -> 25 | Collector.add("test1") 26 | Collector.add("test2") 27 | 28 | assert Collector.output() == %{enabled: false, trail: []} 29 | end) 30 | end 31 | 32 | test "clearing data" do 33 | Collector.add(Breadcrumb.new("test1", [])) 34 | Collector.clear() 35 | 36 | assert Collector.output()[:trail] == [] 37 | end 38 | 39 | test "allows put operation on supplied breadcrumb buffer" do 40 | bc = Breadcrumb.new("test1", []) 41 | 42 | breadcrumbs = 43 | Collector.breadcrumbs() 44 | |> Collector.put(bc) 45 | 46 | assert Collector.output(breadcrumbs)[:trail] == [bc] 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/honeybadger/breadcrumbs/ring_buffer_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.Breadcrumbs.RingBufferTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Honeybadger.Breadcrumbs.RingBuffer 5 | 6 | test "adds items" do 7 | buffer = RingBuffer.new(2) |> RingBuffer.add(:item) |> RingBuffer.to_list() 8 | assert buffer == [:item] 9 | end 10 | 11 | test "shifts when limit is hit" do 12 | buffer = 13 | RingBuffer.new(2) 14 | |> RingBuffer.add(:a) 15 | |> RingBuffer.add(:b) 16 | |> RingBuffer.add(:c) 17 | |> RingBuffer.to_list() 18 | 19 | assert buffer == [:b, :c] 20 | end 21 | 22 | test "implements Jason.Encoder" do 23 | json = 24 | 2 25 | |> RingBuffer.new() 26 | |> RingBuffer.add(123) 27 | |> Jason.encode!() 28 | 29 | assert json == "[123]" 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/honeybadger/breadcrumbs/telemetry_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.Breadcrumbs.TelemetryTest do 2 | use Honeybadger.Case, async: true 3 | 4 | alias Honeybadger.Breadcrumbs.{Telemetry, Collector} 5 | 6 | defmodule Test.MockEctoConfig do 7 | def config() do 8 | [telemetry_prefix: [:a, :b]] 9 | end 10 | end 11 | 12 | test "inserts ecto telemetry events" do 13 | with_config([ecto_repos: [Test.MockEctoConfig]], fn -> 14 | assert Telemetry.telemetry_events() == [ 15 | [:a, :b, :query], 16 | [:phoenix, :router_dispatch, :start] 17 | ] 18 | end) 19 | end 20 | 21 | test "works without ecto" do 22 | assert Telemetry.telemetry_events() == [[:phoenix, :router_dispatch, :start]] 23 | end 24 | 25 | test "produces merged sql breadcrumb" do 26 | with_config([breadcrumbs_enabled: true], fn -> 27 | query = "SELECT * from table" 28 | 29 | Telemetry.handle_telemetry( 30 | [], 31 | %{decode_time: 66_000_000}, 32 | %{query: query, source: "here"}, 33 | nil 34 | ) 35 | 36 | bc = latest_breadcrumb() 37 | assert bc.message == "Ecto SQL Query (here)" 38 | assert bc.metadata[:decode_time] == "66.0ms" 39 | end) 40 | end 41 | 42 | test "produces sql breadcrumb without telemetry measurements" do 43 | with_config([breadcrumbs_enabled: true], fn -> 44 | query = "SELECT * from table" 45 | 46 | Telemetry.handle_telemetry( 47 | [], 48 | 4000, 49 | %{query: query, source: "table", query_time: 4_000_000}, 50 | nil 51 | ) 52 | 53 | bc = latest_breadcrumb() 54 | assert bc.message == "Ecto SQL Query (table)" 55 | assert bc.metadata[:query_time] == "4.0ms" 56 | end) 57 | end 58 | 59 | test "produces phoenix router breadcrumb" do 60 | with_config([breadcrumbs_enabled: true], fn -> 61 | Telemetry.handle_telemetry( 62 | [:phoenix, :router_dispatch, :start], 63 | 4000, 64 | %{plug: "Test.Controller", pipe_through: [:a, :b]}, 65 | nil 66 | ) 67 | 68 | bc = latest_breadcrumb() 69 | assert bc.message == "Phoenix Router Dispatch" 70 | assert bc.metadata[:plug] == "Test.Controller" 71 | assert bc.metadata[:pipe_through] == "[:a, :b]" 72 | end) 73 | end 74 | 75 | defp latest_breadcrumb() do 76 | hd(Collector.breadcrumbs().buffer) 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /test/honeybadger/client_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.ClientTest do 2 | use ExUnit.Case, async: true 3 | 4 | doctest Honeybadger.Client 5 | end 6 | -------------------------------------------------------------------------------- /test/honeybadger/event_context_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.EventContextTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Honeybadger.EventContext 5 | 6 | setup do 7 | # Clear event context before each test 8 | Process.delete(Honeybadger.EventContext) 9 | :ok 10 | end 11 | 12 | describe "get/0" do 13 | test "returns an empty map when no context exists" do 14 | assert EventContext.get() == %{} 15 | end 16 | 17 | test "returns the stored context" do 18 | context = %{user_id: 123, action: "test"} 19 | EventContext.replace(context) 20 | assert EventContext.get() == context 21 | end 22 | end 23 | 24 | describe "get/1" do 25 | test "returns nil when no context exists" do 26 | assert EventContext.get(:user_id) == nil 27 | end 28 | 29 | test "returns nil when key doesn't exist in context" do 30 | EventContext.replace(%{action: "test"}) 31 | assert EventContext.get(:user_id) == nil 32 | end 33 | 34 | test "returns the value for the given key" do 35 | EventContext.replace(%{user_id: 123, action: "test"}) 36 | assert EventContext.get(:user_id) == 123 37 | end 38 | end 39 | 40 | describe "merge/1" do 41 | test "with keyword list" do 42 | result = EventContext.merge(user_id: 123, action: "test") 43 | assert result == %{user_id: 123, action: "test"} 44 | assert EventContext.get() == %{user_id: 123, action: "test"} 45 | end 46 | 47 | test "with map" do 48 | result = EventContext.merge(%{user_id: 123, action: "test"}) 49 | assert result == %{user_id: 123, action: "test"} 50 | assert EventContext.get() == %{user_id: 123, action: "test"} 51 | end 52 | 53 | test "merges with existing context" do 54 | Process.put(Honeybadger.EventContext, %{user_id: 123}) 55 | result = EventContext.merge(%{action: "test"}) 56 | assert result == %{user_id: 123, action: "test"} 57 | assert EventContext.get() == %{user_id: 123, action: "test"} 58 | end 59 | 60 | test "overwrites existing keys" do 61 | Process.put(Honeybadger.EventContext, %{user_id: 123, action: "old"}) 62 | result = EventContext.merge(%{action: "new"}) 63 | assert result == %{user_id: 123, action: "new"} 64 | assert EventContext.get() == %{user_id: 123, action: "new"} 65 | end 66 | end 67 | 68 | describe "replace/1" do 69 | test "with keyword list" do 70 | result = EventContext.replace(user_id: 123, action: "test") 71 | assert result == %{user_id: 123, action: "test"} 72 | assert EventContext.get() == %{user_id: 123, action: "test"} 73 | end 74 | 75 | test "with map" do 76 | result = EventContext.replace(%{user_id: 123, action: "test"}) 77 | assert result == %{user_id: 123, action: "test"} 78 | assert EventContext.get() == %{user_id: 123, action: "test"} 79 | end 80 | 81 | test "replaces existing context" do 82 | Process.put(Honeybadger.EventContext, %{user_id: 123, other: "value"}) 83 | result = EventContext.replace(%{action: "test"}) 84 | assert result == %{action: "test"} 85 | assert EventContext.get() == %{action: "test"} 86 | end 87 | end 88 | 89 | describe "put_new/2" do 90 | test "adds key when it doesn't exist" do 91 | result = EventContext.put_new(:user_id, 123) 92 | assert result == %{user_id: 123} 93 | assert EventContext.get() == %{user_id: 123} 94 | end 95 | 96 | test "doesn't overwrite existing key" do 97 | Process.put(Honeybadger.EventContext, %{user_id: 123}) 98 | result = EventContext.put_new(:user_id, 456) 99 | assert result == %{user_id: 123} 100 | assert EventContext.get() == %{user_id: 123} 101 | end 102 | 103 | test "with function that returns value" do 104 | expensive_function = fn -> 123 end 105 | result = EventContext.put_new(:user_id, expensive_function) 106 | assert result == %{user_id: 123} 107 | assert EventContext.get() == %{user_id: 123} 108 | end 109 | 110 | test "function is not called when key exists" do 111 | EventContext.replace(%{user_id: 123}) 112 | 113 | # Use a reference to track if the function was called 114 | test_pid = self() 115 | 116 | expensive_function = fn -> 117 | send(test_pid, :function_was_called) 118 | 456 119 | end 120 | 121 | result = EventContext.put_new(:user_id, expensive_function) 122 | assert result == %{user_id: 123} 123 | assert EventContext.get() == %{user_id: 123} 124 | 125 | # Verify the function was never called 126 | refute_received :function_was_called 127 | end 128 | end 129 | 130 | describe "inherit/0" do 131 | test "returns :not_found when no parent context exists" do 132 | # ProcessTree should return nil when there's no parent context 133 | # This is testing the default case when inherit() is called 134 | assert EventContext.inherit() == :not_found 135 | assert EventContext.get() == %{} 136 | end 137 | 138 | test "returns :already_initialized when context already exists" do 139 | EventContext.replace(%{user_id: 123}) 140 | assert EventContext.inherit() == :already_initialized 141 | end 142 | 143 | test "inherits parent context" do 144 | parent_context = Honeybadger.EventContext.merge(%{user_id: 123, action: "test"}) 145 | test_pid = self() 146 | 147 | Task.async(fn -> 148 | # Simulate a child process inheriting the context 149 | EventContext.inherit() 150 | send(test_pid, {:context, EventContext.get()}) 151 | end) 152 | 153 | receive do 154 | {:context, spawn_context} -> 155 | assert parent_context == spawn_context 156 | after 157 | 1000 -> flunk("Test timed out") 158 | end 159 | end 160 | end 161 | 162 | # Test integration with Honeybadger module 163 | describe "integration with Honeybadger module" do 164 | test "Honeybadger.event_context/0 calls EventContext.get/0" do 165 | context = %{user_id: 123, action: "test"} 166 | Process.put(Honeybadger.EventContext, context) 167 | assert Honeybadger.event_context() == context 168 | end 169 | 170 | test "Honeybadger.event_context/1 calls EventContext.merge/1" do 171 | result = Honeybadger.event_context(%{user_id: 123}) 172 | assert result == %{user_id: 123} 173 | assert EventContext.get() == %{user_id: 123} 174 | end 175 | 176 | test "Honeybadger.clear_event_context/0 clears the context" do 177 | Honeybadger.event_context(%{user_id: 123}) 178 | assert Honeybadger.event_context() == %{user_id: 123} 179 | 180 | Honeybadger.clear_event_context() 181 | assert Honeybadger.event_context() == %{} 182 | end 183 | end 184 | end 185 | -------------------------------------------------------------------------------- /test/honeybadger/events_sampler_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.EventsSamplerTest do 2 | use Honeybadger.Case, async: true 3 | require Logger 4 | 5 | alias Honeybadger.EventsSampler 6 | 7 | defp start_sampler(config \\ []) do 8 | name = 9 | "test_events_sampler_#{System.unique_integer([:positive])}" 10 | |> String.to_atom() 11 | 12 | EventsSampler.start_link(config ++ [name: name]) 13 | end 14 | 15 | test "returns true immediately if default sample rate is 100" do 16 | assert EventsSampler.sample?(hash_value: :foo) 17 | assert EventsSampler.sample?() 18 | end 19 | 20 | test "returns true immediately if passed in sample rate is 100" do 21 | with_config([insights_sample_rate: 0], fn -> 22 | assert EventsSampler.sample?(sample_rate: 100) 23 | end) 24 | end 25 | 26 | test "samples for hashed values" do 27 | with_config([insights_sample_rate: 50], fn -> 28 | {:ok, sampler} = start_sampler(sampled_log_interval: 100) 29 | 30 | log = 31 | capture_log(fn -> 32 | EventsSampler.sample?(hash_value: "trace-1", server: sampler) 33 | EventsSampler.sample?(hash_value: "trace-2", server: sampler) 34 | Process.sleep(1000) 35 | end) 36 | 37 | assert log =~ ~r/\[Honeybadger\] Sampled \d events \(of 2 total events\)/ 38 | end) 39 | end 40 | 41 | test "samples for un-hashed values" do 42 | with_config([insights_sample_rate: 50], fn -> 43 | {:ok, sampler} = start_sampler(sampled_log_interval: 100) 44 | 45 | log = 46 | capture_log(fn -> 47 | EventsSampler.sample?(server: sampler) 48 | EventsSampler.sample?(server: sampler) 49 | EventsSampler.sample?(server: sampler) 50 | Process.sleep(1000) 51 | end) 52 | 53 | assert log =~ ~r/\[Honeybadger\] Sampled \d events \(of 3 total events\)/ 54 | end) 55 | end 56 | 57 | test "handles nil sample_rate" do 58 | with_config([insights_sample_rate: 0], fn -> 59 | {:ok, sampler} = start_sampler() 60 | refute EventsSampler.sample?(sample_rate: nil, server: sampler) 61 | refute EventsSampler.sample?(hash_value: "asdf", sample_rate: nil, server: sampler) 62 | end) 63 | end 64 | 65 | test "respects custom sample rate in opts" do 66 | with_config([insights_sample_rate: 50], fn -> 67 | {:ok, sampler} = start_sampler() 68 | 69 | # Force sampling to occur with 100% sample rate 70 | assert EventsSampler.sample?(hash_value: "trace-1", sample_rate: 100, server: sampler) 71 | 72 | # Force sampling to not occur with 0% sample rate 73 | refute EventsSampler.sample?(hash_value: "trace-1", sample_rate: 0, server: sampler) 74 | end) 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /test/honeybadger/events_worker_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.EventsWorkerTest do 2 | use Honeybadger.Case, async: true 3 | require Logger 4 | 5 | alias Honeybadger.EventsWorker 6 | 7 | defp start_worker(config) do 8 | name = 9 | "test_events_worker_#{System.unique_integer([:positive])}" 10 | |> String.to_atom() 11 | 12 | EventsWorker.start_link(config ++ [name: name]) 13 | end 14 | 15 | setup do 16 | {:ok, behavior_agent} = Agent.start_link(fn -> :ok end) 17 | 18 | test_pid = self() 19 | 20 | send_events_fn = fn events -> 21 | send(test_pid, {:events_sent, events}) 22 | 23 | case Agent.get(behavior_agent, & &1) do 24 | :ok -> :ok 25 | :throttle -> {:error, :throttled} 26 | :error -> {:error, "Other error"} 27 | end 28 | end 29 | 30 | change_behavior = fn new_behavior -> 31 | Agent.update(behavior_agent, fn _ -> new_behavior end) 32 | end 33 | 34 | # Common test configuration 35 | config = [ 36 | send_events_fn: send_events_fn, 37 | batch_size: 3, 38 | max_batch_retries: 2, 39 | max_queue_size: 10, 40 | timeout: 100 41 | ] 42 | 43 | {:ok, config: config, change_behavior: change_behavior} 44 | end 45 | 46 | describe "batch size triggering" do 47 | test "sends events when batch size is reached", %{config: config} do 48 | {:ok, pid} = start_worker(config) 49 | events = [%{id: 1}, %{id: 2}, %{id: 3}] 50 | Enum.each(events, &EventsWorker.push(&1, pid)) 51 | GenServer.stop(pid) 52 | 53 | assert_receive {:events_sent, ^events}, 50 54 | end 55 | 56 | test "queues events when under batch size", %{config: config} do 57 | {:ok, pid} = start_worker(config) 58 | 59 | events = [%{id: 1}, %{id: 2}] 60 | Enum.each(events, &EventsWorker.push(&1, pid)) 61 | refute_receive {:events_sent, _}, 50 62 | 63 | GenServer.stop(pid) 64 | end 65 | end 66 | 67 | describe "max_queue_size" do 68 | test "drops events from queue", %{config: config, change_behavior: change_behavior} do 69 | {:ok, pid} = 70 | start_worker(Keyword.merge(config, timeout: 5000, max_queue_size: 4)) 71 | 72 | change_behavior.(:error) 73 | events = [%{id: 1}, %{id: 2}, %{id: 3}, %{id: 4}, %{id: 5}, %{id: 6}] 74 | Enum.each(events, &EventsWorker.push(&1, pid)) 75 | 76 | state = EventsWorker.state(pid) 77 | 78 | assert state.dropped_events == 2 79 | assert state.queue == [%{id: 4}] 80 | 81 | assert :queue.to_list(state.batches) == [ 82 | %{attempts: 1, batch: [%{id: 1}, %{id: 2}, %{id: 3}]} 83 | ] 84 | 85 | GenServer.stop(pid) 86 | end 87 | end 88 | 89 | describe "timer triggering" do 90 | test "flushes events when timer expires", %{config: config} do 91 | {:ok, pid} = start_worker(config) 92 | events = [%{id: 1}, %{id: 2}] 93 | Enum.each(events, &EventsWorker.push(&1, pid)) 94 | assert_receive {:events_sent, ^events}, config[:timeout] + 50 95 | GenServer.stop(pid) 96 | end 97 | 98 | test "resets timer when batch is sent", %{config: config} do 99 | {:ok, pid} = start_worker(config) 100 | 101 | first_batch = [%{id: 1}, %{id: 2}, %{id: 3}] 102 | Enum.each(first_batch, &EventsWorker.push(&1, pid)) 103 | assert_receive {:events_sent, ^first_batch}, 50 104 | 105 | second_batch = [%{id: 4}, %{id: 5}] 106 | Enum.each(second_batch, &EventsWorker.push(&1, pid)) 107 | refute_receive {:events_sent, _}, 50 108 | assert_receive {:events_sent, ^second_batch}, 100 109 | 110 | GenServer.stop(pid) 111 | end 112 | end 113 | 114 | describe "error handling" do 115 | setup %{config: config} do 116 | config = 117 | Keyword.merge(config, 118 | batch_size: 2, 119 | timeout: 100, 120 | throttle_wait: 300, 121 | max_queue_size: 10000 122 | ) 123 | 124 | {:ok, config: config} 125 | end 126 | 127 | test "retries and drops after max attempts", %{ 128 | config: config, 129 | change_behavior: change_behavior 130 | } do 131 | config = Keyword.merge(config, max_batch_retries: 3) 132 | 133 | {:ok, pid} = start_worker(config) 134 | 135 | # Start with error behavior 136 | change_behavior.(:error) 137 | 138 | # Send enough events to trigger a batch 139 | events = [%{id: 1}, %{id: 2}] 140 | Enum.each(events, &EventsWorker.push(&1, pid)) 141 | 142 | # Wait for first attempt 143 | assert_receive {:events_sent, ^events}, 50 144 | 145 | # Should retry after timeout 146 | assert_receive {:events_sent, ^events}, 150 147 | 148 | # Should retry one more time and then drop 149 | assert_receive {:events_sent, ^events}, 250 150 | 151 | # Check final state 152 | state = EventsWorker.state(pid) 153 | # Batch should be dropped 154 | assert :queue.to_list(state.batches) == [] 155 | 156 | GenServer.stop(pid) 157 | end 158 | 159 | test "queues new events during retry attempts", %{ 160 | config: config, 161 | change_behavior: change_behavior 162 | } do 163 | {:ok, pid} = start_worker(config) 164 | 165 | change_behavior.(:error) 166 | 167 | # Send first batch 168 | first_batch = [%{id: 1}, %{id: 2}] 169 | Enum.each(first_batch, &EventsWorker.push(&1, pid)) 170 | 171 | # Wait for first attempt 172 | assert_receive {:events_sent, ^first_batch}, 50 173 | 174 | # Send new events during retry period 175 | second_batch = [%{id: 3}, %{id: 4}] 176 | Enum.each(second_batch, &EventsWorker.push(&1, pid)) 177 | 178 | # Switch to success before max retries 179 | change_behavior.(:ok) 180 | 181 | # Should eventually send both batches 182 | assert_receive {:events_sent, ^first_batch}, 150 183 | assert_receive {:events_sent, ^second_batch}, 50 184 | 185 | GenServer.stop(pid) 186 | end 187 | 188 | test "does not reset flush timer on subsequent pushes", %{config: config} do 189 | {:ok, pid} = 190 | start_worker(Keyword.merge(config, timeout: 100, batch_size: 1000)) 191 | 192 | EventsWorker.push(%{id: 1}, pid) 193 | :timer.sleep(60) 194 | EventsWorker.push(%{id: 2}, pid) 195 | :timer.sleep(60) 196 | EventsWorker.push(%{id: 3}, pid) 197 | 198 | assert_receive {:events_sent, [%{id: 1}, %{id: 2}]} 199 | assert_receive {:events_sent, [%{id: 3}]}, 100 200 | 201 | GenServer.stop(pid) 202 | end 203 | 204 | test "works with pushes after a flush", %{config: config} do 205 | {:ok, pid} = 206 | start_worker(Keyword.merge(config, timeout: 50, batch_size: 1000)) 207 | 208 | EventsWorker.push(%{id: 1}, pid) 209 | :timer.sleep(300) 210 | EventsWorker.push(%{id: 2}, pid) 211 | 212 | assert_receive {:events_sent, [%{id: 1}]}, 0 213 | # Make sure we don't get the second event before the timeout 214 | refute_receive {:events_sent, [%{id: 2}]}, 0 215 | assert_receive {:events_sent, [%{id: 2}]}, 100 216 | 217 | GenServer.stop(pid) 218 | end 219 | 220 | test "handles throttling and resumes after wait period", %{ 221 | config: config, 222 | change_behavior: change_behavior 223 | } do 224 | {:ok, pid} = start_worker(config) 225 | 226 | # Start with throttle behavior 227 | change_behavior.(:throttle) 228 | 229 | # Send first batch 230 | first_batch = [%{id: 1}, %{id: 2}] 231 | Enum.each(first_batch, &EventsWorker.push(&1, pid)) 232 | 233 | # Should get throttled 234 | assert_receive {:events_sent, ^first_batch}, 50 235 | 236 | # Send second batch while throttled 237 | second_batch = [%{id: 3}, %{id: 4}] 238 | Enum.each(second_batch, &EventsWorker.push(&1, pid)) 239 | 240 | # Switch to success after throttle period 241 | change_behavior.(:ok) 242 | 243 | # Should send both batches after throttle period 244 | assert_receive {:events_sent, ^first_batch}, 500 245 | assert_receive {:events_sent, ^second_batch}, 50 246 | 247 | GenServer.stop(pid) 248 | end 249 | 250 | test "flush delay respects throttle_wait when throttled", %{ 251 | config: config, 252 | change_behavior: change_behavior 253 | } do 254 | # Set small values for quick testing. 255 | config = Keyword.merge(config, batch_size: 2, timeout: 100, throttle_wait: 300) 256 | {:ok, pid} = start_worker(config) 257 | 258 | change_behavior.(:throttle) 259 | 260 | # Push a batch to trigger throttling. 261 | events = [%{id: 1}, %{id: 2}] 262 | Enum.each(events, &EventsWorker.push(&1, pid)) 263 | assert_receive {:events_sent, ^events}, 50 264 | 265 | # Verify throttling is active. 266 | state = EventsWorker.state(pid) 267 | assert state.throttling == true 268 | 269 | start_time = System.monotonic_time(:millisecond) 270 | 271 | # Wait less than throttle_wait to ensure no flush occurs. 272 | refute_receive {:events_sent, _}, 250 273 | 274 | # Switch backend to success so the retry flush can go through. 275 | change_behavior.(:ok) 276 | 277 | # The retry flush should occur after throttle_wait (300ms). 278 | assert_receive {:events_sent, ^events}, 150 279 | 280 | elapsed = System.monotonic_time(:millisecond) - start_time 281 | assert elapsed >= 300 282 | 283 | GenServer.stop(pid) 284 | end 285 | end 286 | 287 | describe "termination" do 288 | test "sends remaining events on termination", %{config: config} do 289 | {:ok, pid} = start_worker(config) 290 | events = [%{id: 1}, %{id: 2}] 291 | Enum.each(events, &EventsWorker.push(&1, pid)) 292 | assert_receive {:events_sent, ^events}, 500 293 | 294 | GenServer.stop(pid) 295 | end 296 | end 297 | end 298 | -------------------------------------------------------------------------------- /test/honeybadger/fingerprint_adapter_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.FingerprintAdapterTest do 2 | use Honeybadger.Case 3 | 4 | setup do 5 | {:ok, _} = Honeybadger.API.start(self()) 6 | 7 | on_exit(&Honeybadger.API.stop/0) 8 | end 9 | 10 | describe "fingerprint adapter" do 11 | test "sending a notice with fingerprint adapter" do 12 | restart_with_config(exclude_envs: [], fingerprint_adapter: Honeybadger.CustomFingerprint) 13 | 14 | Honeybadger.notify("Custom error") 15 | 16 | assert_receive {:api_request, %{"error" => error}} 17 | assert error["fingerprint"] == "elixir-honeybadger-elixir" 18 | end 19 | 20 | test "notifying with fingerprint overrides the fingerprint adapter" do 21 | restart_with_config(exclude_envs: [], fingerprint_adapter: Honeybadger.CustomFingerprint) 22 | 23 | Honeybadger.notify("Custom error", fingerprint: "my-fingerprint") 24 | 25 | assert_receive {:api_request, %{"error" => error}} 26 | assert error["fingerprint"] == "my-fingerprint" 27 | end 28 | 29 | test "sending a notice without fingerprint adapter" do 30 | restart_with_config(exclude_envs: [], fingerprint_adapter: nil) 31 | 32 | Honeybadger.notify("Custom error") 33 | 34 | assert_receive {:api_request, %{"error" => error}} 35 | assert error["fingerprint"] == "" 36 | end 37 | end 38 | end 39 | 40 | defmodule Honeybadger.CustomFingerprint do 41 | @behaviour Honeybadger.FingerprintAdapter 42 | 43 | def parse(notice) do 44 | notice.notifier.language <> "-" <> notice.notifier.name 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /test/honeybadger/http_adapter/hackney_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.HTTPAdapter.HackneyTest do 2 | use Honeybadger.Case 3 | doctest Honeybadger.HTTPAdapter.Hackney 4 | 5 | alias Honeybadger.HTTPAdapter.{Hackney, HTTPResponse} 6 | 7 | describe "request/4" do 8 | test "handles unreachable host" do 9 | TestServer.start() 10 | url = TestServer.url() 11 | TestServer.stop() 12 | 13 | assert {:error, :econnrefused} = Hackney.request(:get, url, nil, []) 14 | end 15 | 16 | test "handles query in URL" do 17 | TestServer.add("/get", 18 | via: :get, 19 | to: fn conn -> 20 | assert conn.query_string == "a=1" 21 | 22 | Plug.Conn.send_resp(conn, 200, "") 23 | end 24 | ) 25 | 26 | assert {:ok, %HTTPResponse{status: 200}} = 27 | Hackney.request(:get, TestServer.url("/get?a=1"), nil, []) 28 | end 29 | 30 | test "handles POST" do 31 | TestServer.add("/post", 32 | via: :post, 33 | to: fn conn -> 34 | {:ok, body, conn} = Plug.Conn.read_body(conn, []) 35 | params = URI.decode_query(body) 36 | 37 | assert params["a"] == "1" 38 | assert params["b"] == "2" 39 | 40 | assert Plug.Conn.get_req_header(conn, "content-type") == [ 41 | "application/x-www-form-urlencoded" 42 | ] 43 | 44 | assert Plug.Conn.get_req_header(conn, "content-length") == ["7"] 45 | 46 | Plug.Conn.send_resp(conn, 200, "") 47 | end 48 | ) 49 | 50 | assert {:ok, %HTTPResponse{status: 200}} = 51 | Hackney.request(:post, TestServer.url("/post"), "a=1&b=2", [ 52 | {"content-type", "application/x-www-form-urlencoded"} 53 | ]) 54 | end 55 | end 56 | 57 | @body %{"a" => "1", "b" => "2"} 58 | @headers [{"content-type", "application/json"}] 59 | @json_library (Code.ensure_loaded?(JSON) && JSON) || Jason 60 | @json_encoded_body @json_library.encode!(@body) 61 | @uri_encoded_body URI.encode_query(@body) 62 | 63 | test "decode_response_body/2" do 64 | assert {:ok, response} = 65 | Hackney.decode_response_body( 66 | %HTTPResponse{body: @json_encoded_body, headers: @headers}, 67 | [] 68 | ) 69 | 70 | assert response.body == @body 71 | 72 | assert {:ok, response} = 73 | Hackney.decode_response_body( 74 | %HTTPResponse{ 75 | body: @json_encoded_body, 76 | headers: [{"content-type", "application/json; charset=utf-8"}] 77 | }, 78 | [] 79 | ) 80 | 81 | assert response.body == @body 82 | 83 | assert {:ok, response} = 84 | Hackney.decode_response_body( 85 | %HTTPResponse{ 86 | body: @json_encoded_body, 87 | headers: [{"Content-Type", "application/json"}] 88 | }, 89 | [] 90 | ) 91 | 92 | assert response.body == @body 93 | 94 | assert {:ok, response} = 95 | Hackney.decode_response_body( 96 | %HTTPResponse{ 97 | body: @json_encoded_body, 98 | headers: [{"content-type", "text/javascript"}] 99 | }, 100 | [] 101 | ) 102 | 103 | assert response.body == @body 104 | 105 | assert {:ok, response} = 106 | Hackney.decode_response_body( 107 | %HTTPResponse{ 108 | body: @uri_encoded_body, 109 | headers: [{"content-type", "application/x-www-form-urlencoded"}] 110 | }, 111 | [] 112 | ) 113 | 114 | assert response.body == @body 115 | 116 | assert {:ok, response} = 117 | Hackney.decode_response_body(%HTTPResponse{body: @body, headers: []}, []) 118 | 119 | assert response.body == @body 120 | 121 | assert {:error, %Honeybadger.InvalidResponseError{} = error} = 122 | Hackney.decode_response_body(%HTTPResponse{body: "%", headers: @headers}, []) 123 | 124 | assert error.response.body == "%" 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /test/honeybadger/http_adapter/req_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.HTTPAdapter.ReqTest do 2 | use Honeybadger.Case 3 | doctest Honeybadger.HTTPAdapter.Req 4 | 5 | alias Req.TransportError 6 | alias Honeybadger.HTTPAdapter.{HTTPResponse, Req} 7 | 8 | # Test retries quickly 9 | @req_opts [retry_delay: 0] 10 | 11 | describe "request/4" do 12 | test "handles unreachable host" do 13 | TestServer.start() 14 | url = TestServer.url() 15 | TestServer.stop() 16 | 17 | assert {:error, %TransportError{reason: :econnrefused}} = 18 | Req.request(:get, url, nil, [], retry: false) 19 | end 20 | 21 | test "handles query in URL" do 22 | TestServer.add("/get", 23 | via: :get, 24 | to: fn conn -> 25 | assert conn.query_string == "a=1" 26 | 27 | Plug.Conn.send_resp(conn, 200, "") 28 | end 29 | ) 30 | 31 | assert {:ok, %HTTPResponse{status: 200}} = 32 | Req.request(:get, TestServer.url("/get?a=1"), nil, [], @req_opts) 33 | end 34 | 35 | test "handles POST" do 36 | TestServer.add("/post", 37 | via: :post, 38 | to: fn conn -> 39 | {:ok, body, conn} = Plug.Conn.read_body(conn, []) 40 | params = URI.decode_query(body) 41 | 42 | assert params["a"] == "1" 43 | assert params["b"] == "2" 44 | 45 | assert Plug.Conn.get_req_header(conn, "content-type") == [ 46 | "application/x-www-form-urlencoded" 47 | ] 48 | 49 | Plug.Conn.send_resp(conn, 200, "") 50 | end 51 | ) 52 | 53 | assert {:ok, %HTTPResponse{status: 200}} = 54 | Req.request( 55 | :post, 56 | TestServer.url("/post"), 57 | "a=1&b=2", 58 | [ 59 | {"content-type", "application/x-www-form-urlencoded"} 60 | ], 61 | @req_opts 62 | ) 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /test/honeybadger/http_adapter_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.HTTPAdapterTest do 2 | use Honeybadger.Case 3 | # doctest Honeybadger.Strategy 4 | 5 | alias Honeybadger.{HTTPAdapter, HTTPAdapter.HTTPResponse, InvalidResponseError} 6 | 7 | defmodule HTTPMock do 8 | @json_library (Code.ensure_loaded?(JSON) && JSON) || Jason 9 | 10 | def request(:get, "http-adapter", nil, [], nil) do 11 | {:ok, %HTTPResponse{status: 200, headers: [], body: nil}} 12 | end 13 | 14 | def request(:get, "http-adapter-with-opts", nil, [], opts) do 15 | {:ok, %HTTPResponse{status: 200, headers: [], body: opts}} 16 | end 17 | 18 | def request(:get, "json-encoded-body", nil, [], nil) do 19 | {:ok, 20 | %HTTPResponse{ 21 | status: 200, 22 | headers: [{"content-type", "application/json"}], 23 | body: @json_library.encode!(%{"a" => 1}) 24 | }} 25 | end 26 | 27 | def request(:get, "json-encoded-body-already-decoded", nil, [], nil) do 28 | {:ok, 29 | %HTTPResponse{ 30 | status: 200, 31 | headers: [{"content-type", "application/json"}], 32 | body: %{"a" => 1} 33 | }} 34 | end 35 | 36 | def request(:get, "json-encoded-body-text/javascript-header", nil, [], nil) do 37 | {:ok, 38 | %HTTPResponse{ 39 | status: 200, 40 | headers: [{"content-type", "text/javascript"}], 41 | body: @json_library.encode!(%{"a" => 1}) 42 | }} 43 | end 44 | 45 | def request(:get, "invalid-json-body", nil, [], nil) do 46 | {:ok, 47 | %HTTPResponse{status: 200, headers: [{"content-type", "application/json"}], body: "%"}} 48 | end 49 | 50 | def request(:get, "json-no-headers", nil, [], nil) do 51 | {:ok, %HTTPResponse{status: 200, headers: [], body: @json_library.encode!(%{"a" => 1})}} 52 | end 53 | 54 | def request(:get, "form-data-body", nil, [], nil) do 55 | {:ok, 56 | %HTTPResponse{ 57 | status: 200, 58 | headers: [{"content-type", "application/x-www-form-urlencoded"}], 59 | body: URI.encode_query(%{"a" => 1}) 60 | }} 61 | end 62 | 63 | def request(:get, "form-data-body-already-decoded", nil, [], nil) do 64 | {:ok, 65 | %HTTPResponse{ 66 | status: 200, 67 | headers: [{"content-type", "application/x-www-form-urlencoded"}], 68 | body: %{"a" => 1} 69 | }} 70 | end 71 | 72 | def decode_response_body(response, opts) do 73 | case decode(response.headers, response.body, opts) do 74 | {:ok, body} -> {:ok, %{response | body: body}} 75 | {:error, _error} -> {:error, InvalidResponseError.exception(response: response)} 76 | end 77 | end 78 | 79 | defp decode(headers, body, opts) when is_binary(body) do 80 | case List.keyfind(headers, "content-type", 0) do 81 | {"content-type", "application/json" <> _rest} -> 82 | Jason.decode(body, opts) 83 | 84 | {"content-type", "text/javascript" <> _rest} -> 85 | Jason.decode(body, opts) 86 | 87 | {"content-type", "application/x-www-form-urlencoded" <> _rest} -> 88 | {:ok, URI.decode_query(body)} 89 | 90 | _any -> 91 | {:ok, body} 92 | end 93 | end 94 | 95 | defp decode(_headers, body, _opts), do: {:ok, body} 96 | end 97 | 98 | test "request/5" do 99 | assert HTTPAdapter.request(:get, "http-adapter", nil, [], http_adapter: HTTPMock) == 100 | {:ok, 101 | %HTTPResponse{ 102 | status: 200, 103 | headers: [], 104 | body: nil, 105 | http_adapter: HTTPMock, 106 | request_url: "http-adapter" 107 | }} 108 | 109 | assert HTTPAdapter.request(:get, "http-adapter-with-opts", nil, [], 110 | http_adapter: {HTTPMock, a: 1} 111 | ) == 112 | {:ok, 113 | %HTTPResponse{ 114 | status: 200, 115 | headers: [], 116 | body: [a: 1], 117 | http_adapter: HTTPMock, 118 | request_url: "http-adapter-with-opts" 119 | }} 120 | 121 | assert HTTPAdapter.request(:get, "json-encoded-body", nil, [], http_adapter: HTTPMock) == 122 | {:ok, 123 | %HTTPResponse{ 124 | status: 200, 125 | headers: [{"content-type", "application/json"}], 126 | body: %{"a" => 1}, 127 | http_adapter: HTTPMock, 128 | request_url: "json-encoded-body" 129 | }} 130 | 131 | assert HTTPAdapter.request(:get, "json-encoded-body-already-decoded", nil, [], 132 | http_adapter: HTTPMock 133 | ) == 134 | {:ok, 135 | %HTTPResponse{ 136 | status: 200, 137 | headers: [{"content-type", "application/json"}], 138 | body: %{"a" => 1}, 139 | http_adapter: HTTPMock, 140 | request_url: "json-encoded-body-already-decoded" 141 | }} 142 | 143 | assert HTTPAdapter.request(:get, "json-encoded-body-text/javascript-header", nil, [], 144 | http_adapter: HTTPMock 145 | ) == 146 | {:ok, 147 | %HTTPResponse{ 148 | status: 200, 149 | headers: [{"content-type", "text/javascript"}], 150 | body: %{"a" => 1}, 151 | http_adapter: HTTPMock, 152 | request_url: "json-encoded-body-text/javascript-header" 153 | }} 154 | 155 | assert {:error, %InvalidResponseError{}} = 156 | HTTPAdapter.request(:get, "invalid-json-body", nil, [], http_adapter: HTTPMock) 157 | 158 | assert HTTPAdapter.request(:get, "json-no-headers", nil, [], http_adapter: HTTPMock) == 159 | {:ok, 160 | %HTTPResponse{ 161 | status: 200, 162 | headers: [], 163 | body: Jason.encode!(%{"a" => 1}), 164 | http_adapter: HTTPMock, 165 | request_url: "json-no-headers" 166 | }} 167 | 168 | assert HTTPAdapter.request(:get, "form-data-body", nil, [], http_adapter: HTTPMock) == 169 | {:ok, 170 | %HTTPResponse{ 171 | status: 200, 172 | headers: [{"content-type", "application/x-www-form-urlencoded"}], 173 | body: %{"a" => "1"}, 174 | http_adapter: HTTPMock, 175 | request_url: "form-data-body" 176 | }} 177 | 178 | assert HTTPAdapter.request(:get, "form-data-body-already-decoded", nil, [], 179 | http_adapter: HTTPMock 180 | ) == 181 | {:ok, 182 | %HTTPResponse{ 183 | status: 200, 184 | headers: [{"content-type", "application/x-www-form-urlencoded"}], 185 | body: %{"a" => 1}, 186 | http_adapter: HTTPMock, 187 | request_url: "form-data-body-already-decoded" 188 | }} 189 | end 190 | end 191 | -------------------------------------------------------------------------------- /test/honeybadger/insights/absinthe_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.Insights.AbsintheTest do 2 | use Honeybadger.Case, async: false 3 | use Honeybadger.InsightsCase 4 | 5 | # Define mock module for testing 6 | defmodule Absinthe do 7 | end 8 | 9 | describe "Absinthe instrumentation" do 10 | test "extracts metadata from operation stop event" do 11 | operation = %{ 12 | name: "GetUser", 13 | type: :query, 14 | current: true, 15 | selections: [ 16 | %{name: "user"}, 17 | %{name: "profile"} 18 | ] 19 | } 20 | 21 | blueprint = %{ 22 | schema: MyApp.Schema, 23 | operations: [operation], 24 | result: %{ 25 | errors: nil 26 | } 27 | } 28 | 29 | event = 30 | send_and_receive( 31 | [:absinthe, :execute, :operation, :stop], 32 | %{duration: System.convert_time_unit(25, :microsecond, :native)}, 33 | %{blueprint: blueprint} 34 | ) 35 | 36 | assert event["event_type"] == "absinthe.execute.operation.stop" 37 | assert event["operation_name"] == "GetUser" 38 | assert event["operation_type"] == "query" 39 | assert event["selections"] == ["user", "profile"] 40 | assert event["schema"] == "Elixir.MyApp.Schema" 41 | assert event["errors"] == nil 42 | assert event["duration"] == 25 43 | end 44 | 45 | test "extracts metadata from operation exception event" do 46 | operation = %{ 47 | name: "GetUser", 48 | type: :query, 49 | current: true, 50 | selections: [ 51 | %{name: "user"}, 52 | %{name: "profile"} 53 | ] 54 | } 55 | 56 | blueprint = %{ 57 | schema: MyApp.Schema, 58 | operations: [operation], 59 | result: %{ 60 | errors: [%{message: "Field 'user' not found"}] 61 | } 62 | } 63 | 64 | event = 65 | send_and_receive( 66 | [:absinthe, :execute, :operation, :exception], 67 | %{duration: System.convert_time_unit(15, :microsecond, :native)}, 68 | %{blueprint: blueprint} 69 | ) 70 | 71 | assert event["event_type"] == "absinthe.execute.operation.exception" 72 | assert event["operation_name"] == "GetUser" 73 | assert event["operation_type"] == "query" 74 | assert event["selections"] == ["user", "profile"] 75 | assert event["schema"] == "Elixir.MyApp.Schema" 76 | assert event["errors"] == [%{"message" => "Field 'user' not found"}] 77 | assert event["duration"] == 15 78 | end 79 | 80 | test "extracts metadata from resolve field stop event" do 81 | restart_with_config( 82 | insights_config: %{absinthe: %{telemetry_events: [[:absinthe, :resolve, :field, :stop]]}} 83 | ) 84 | 85 | resolution = %{ 86 | definition: %{ 87 | name: "name" 88 | }, 89 | parent_type: %{ 90 | name: "User" 91 | }, 92 | state: :resolved 93 | } 94 | 95 | event = 96 | send_and_receive( 97 | [:absinthe, :resolve, :field, :stop], 98 | %{duration: System.convert_time_unit(5, :microsecond, :native)}, 99 | %{resolution: resolution} 100 | ) 101 | 102 | assert event["event_type"] == "absinthe.resolve.field.stop" 103 | assert event["field_name"] == "name" 104 | assert event["parent_type"] == "User" 105 | assert event["state"] == "resolved" 106 | assert event["duration"] == 5 107 | end 108 | 109 | test "handles missing blueprint data gracefully" do 110 | event = 111 | send_and_receive( 112 | [:absinthe, :execute, :operation, :stop], 113 | %{duration: System.convert_time_unit(10, :microsecond, :native)}, 114 | # No blueprint data 115 | %{} 116 | ) 117 | 118 | assert event["event_type"] == "absinthe.execute.operation.stop" 119 | assert event["operation_name"] == nil 120 | assert event["operation_type"] == nil 121 | assert event["selections"] == [] 122 | assert event["schema"] == nil 123 | assert event["errors"] == nil 124 | assert event["duration"] == 10 125 | end 126 | 127 | test "handles operations without selections" do 128 | operation = %{ 129 | name: "GetUser", 130 | type: :query, 131 | current: true 132 | # No selections key 133 | } 134 | 135 | blueprint = %{ 136 | schema: MyApp.Schema, 137 | operations: [operation] 138 | } 139 | 140 | event = 141 | send_and_receive( 142 | [:absinthe, :execute, :operation, :stop], 143 | %{duration: System.convert_time_unit(8, :microsecond, :native)}, 144 | %{blueprint: blueprint} 145 | ) 146 | 147 | assert event["event_type"] == "absinthe.execute.operation.stop" 148 | assert event["operation_name"] == "GetUser" 149 | assert event["operation_type"] == "query" 150 | # Should handle missing selections 151 | assert event["selections"] == [] 152 | assert event["schema"] == "Elixir.MyApp.Schema" 153 | assert event["duration"] == 8 154 | end 155 | end 156 | end 157 | -------------------------------------------------------------------------------- /test/honeybadger/insights/base_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.Insights.BaseTest do 2 | use Honeybadger.Case, async: false 3 | 4 | defmodule TestEventFilter do 5 | use Honeybadger.EventFilter.Mixin 6 | 7 | def filter_telemetry_event(data, _raw, _name) do 8 | Map.put(data, :was_filtered, true) 9 | end 10 | end 11 | 12 | defmodule TestInsights do 13 | use Honeybadger.Insights.Base 14 | 15 | @telemetry_events [ 16 | [:test, :event, :one], 17 | [:test, :event, :two] 18 | ] 19 | 20 | # Minimal extraction to verify it's called 21 | def extract_metadata(meta, _event) do 22 | Map.put(meta, :was_processed, true) 23 | end 24 | 25 | # Override process_event to simply send the message to test process 26 | def process_event(event_data) do 27 | send(self(), {:event_processed, event_data}) 28 | end 29 | 30 | # We need to make sure to detach so each run is clean 31 | def detach() do 32 | Enum.each(@telemetry_events, fn e -> 33 | :telemetry.detach(Honeybadger.Utils.dotify(e)) 34 | end) 35 | end 36 | end 37 | 38 | test "attaches to and processes telemetry events" do 39 | with_config([event_filter: TestEventFilter], fn -> 40 | TestInsights.attach() 41 | 42 | # Send test events 43 | :telemetry.execute([:test, :event, :one], %{value: 1}, %{data: "test"}) 44 | :telemetry.execute([:test, :event, :two], %{value: 2}, %{data: "other"}) 45 | 46 | # Just verify we got both events and they were processed 47 | assert_received {:event_processed, event1}, 50 48 | assert event1.event_type == "test.event.one" 49 | assert event1.was_processed 50 | assert event1.was_filtered 51 | 52 | assert_received {:event_processed, event2} 53 | assert event2.event_type == "test.event.two" 54 | assert event2.was_processed 55 | assert event1.was_filtered 56 | 57 | TestInsights.detach() 58 | end) 59 | end 60 | 61 | test "process measurement data" do 62 | TestInsights.attach() 63 | 64 | measurements = %{ 65 | duration: 1_000_000, 66 | total_time: 2_000_000, 67 | decode_time: 3_000_000, 68 | query_time: 4_000_000, 69 | queue_time: 5_000_000, 70 | idle_time: 6_000_000, 71 | monotonic_time: 10_000_000 72 | } 73 | 74 | :telemetry.execute([:test, :event, :two], measurements, %{data: "other"}) 75 | 76 | assert_received {:event_processed, event}, 50 77 | 78 | # Hardcoded expected values after converting native to microseconds 79 | # Assuming 1000 native time units equal 1 microsecond: 80 | assert event[:duration] == 1000 81 | assert event[:total_time] == 2000 82 | assert event[:decode_time] == 3000 83 | assert event[:query_time] == 4000 84 | assert event[:queue_time] == 5000 85 | assert event[:idle_time] == 6000 86 | 87 | refute Map.has_key?(event, :monotonic_time) 88 | 89 | TestInsights.detach() 90 | end 91 | 92 | test "sanitizes nested data" do 93 | TestInsights.attach() 94 | 95 | :telemetry.execute([:test, :event, :one], %{value: 1}, %{ 96 | data: "test", 97 | nested: %{ 98 | __changed__: "changed", 99 | more: %{ 100 | flash: "bang" 101 | } 102 | } 103 | }) 104 | 105 | assert_received {:event_processed, event}, 50 106 | refute get_in(event, [:nested, :more, :flash]) 107 | refute get_in(event, [:nested, :__changed__]) 108 | 109 | TestInsights.detach() 110 | end 111 | 112 | test "removes params" do 113 | with_config( 114 | [filter_disable_params: true, filter_disable_assigns: true, filter_disable_session: true], 115 | fn -> 116 | TestInsights.attach() 117 | 118 | :telemetry.execute([:test, :event, :two], %{value: 1}, %{ 119 | data: "test", 120 | session: %{user_id: 123}, 121 | assigns: %{other: "value"}, 122 | params: %{password: "secret"} 123 | }) 124 | 125 | assert_received {:event_processed, event}, 50 126 | refute event[:params] 127 | refute event[:assigns] 128 | refute event[:assigns] 129 | 130 | TestInsights.detach() 131 | end 132 | ) 133 | end 134 | 135 | test "limits telemetry events" do 136 | with_config( 137 | [insights_config: %{test_insights: %{telemetry_events: [[:test, :event, :one]]}}], 138 | fn -> 139 | TestInsights.attach() 140 | 141 | :telemetry.execute([:test, :event, :one], %{value: 1}, %{data: "test"}) 142 | :telemetry.execute([:test, :event, :two], %{value: 2}, %{data: "other"}) 143 | 144 | assert_received {:event_processed, _event1}, 50 145 | refute_received {:event_processed, _event2} 146 | 147 | TestInsights.detach() 148 | end 149 | ) 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /test/honeybadger/insights/ecto_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.Insights.EctoTest do 2 | use Honeybadger.Case, async: false 3 | use Honeybadger.InsightsCase 4 | 5 | alias Honeybadger.Insights 6 | 7 | defmodule Ecto.Repo do 8 | end 9 | 10 | defmodule Adapter.Postgres do 11 | end 12 | 13 | defmodule Test.MockEctoConfig do 14 | def config() do 15 | [telemetry_prefix: [:a, :b]] 16 | end 17 | end 18 | 19 | setup do 20 | restart_with_config(ecto_repos: [Test.MockEctoConfig]) 21 | end 22 | 23 | describe "Ecto instrumentation" do 24 | test "extracts metadata from query event" do 25 | event = 26 | send_and_receive( 27 | [:a, :b, :query], 28 | %{ 29 | total_time: System.convert_time_unit(25, :microsecond, :native), 30 | decode_time: System.convert_time_unit(4, :microsecond, :native), 31 | query_time: System.convert_time_unit(15, :microsecond, :native), 32 | queue_time: System.convert_time_unit(5, :millisecond, :native) 33 | }, 34 | %{ 35 | query: "SELECT u0.id, u0.name FROM users u0 WHERE u0.id = ?", 36 | source: "users", 37 | repo: %{__adapter__: Adapter.Postgres} 38 | } 39 | ) 40 | 41 | assert event["query"] =~ "SELECT u0.id, u0.name FROM users u0 WHERE u0.id = ?" 42 | assert event["source"] == "users" 43 | assert event["total_time"] == 25 44 | assert event["decode_time"] == 4 45 | assert event["query_time"] == 15 46 | assert event["queue_time"] == 5000 47 | end 48 | 49 | test "ignores excluded sources" do 50 | with_config([insights_config: %{ecto: %{excluded_sources: ["users"]}}], fn -> 51 | :telemetry.execute( 52 | [:a, :b, :query], 53 | %{}, 54 | %{ 55 | query: "SELECT u0.id, u0.name FROM users u0 WHERE u0.id = ?", 56 | source: "users", 57 | repo: %{__adapter__: Adapter.Postgres} 58 | } 59 | ) 60 | 61 | refute_receive {:api_request, _} 62 | end) 63 | end 64 | 65 | test "ignores excluded queries" do 66 | with_config([insights_config: %{ecto: %{excluded_queries: [~r/FROM colors/]}}], fn -> 67 | :telemetry.execute( 68 | [:a, :b, :query], 69 | %{}, 70 | %{ 71 | query: "SELECT a, b FROM colors WHERE a = ?", 72 | source: "users", 73 | repo: %{__adapter__: Adapter.Postgres} 74 | } 75 | ) 76 | 77 | refute_receive {:api_request, _} 78 | end) 79 | end 80 | 81 | test "includes stacktrace" do 82 | with_config([insights_config: %{ecto: %{include_stacktrace: true}}], fn -> 83 | :telemetry.execute( 84 | [:a, :b, :query], 85 | %{}, 86 | %{ 87 | query: "SELECT u0.id, u0.name FROM users u0 WHERE u0.id = ?", 88 | source: "users", 89 | stacktrace: [ 90 | {Test.MockEctoConfig, :query, 1, [file: "test.ex", line: 1]} 91 | ], 92 | repo: %{__adapter__: Adapter.Postgres} 93 | } 94 | ) 95 | 96 | assert_receive {:api_request, event} 97 | 98 | assert event["stacktrace"] == [ 99 | ["test.ex:1", "Honeybadger.Insights.EctoTest.Test.MockEctoConfig.query/1"] 100 | ] 101 | end) 102 | end 103 | 104 | test "excludes stacktrace for other sources" do 105 | with_config([insights_config: %{ecto: %{include_stacktrace: ["orders"]}}], fn -> 106 | :telemetry.execute( 107 | [:a, :b, :query], 108 | %{}, 109 | %{ 110 | query: "SELECT u0.id, u0.name FROM users u0 WHERE u0.id = ?", 111 | source: "users", 112 | stacktrace: [ 113 | {Test.MockEctoConfig, :query, 1, [file: "test.ex", line: 1]} 114 | ], 115 | repo: %{__adapter__: Adapter.Postgres} 116 | } 117 | ) 118 | 119 | assert_receive {:api_request, event} 120 | refute event["stacktrace"] 121 | end) 122 | end 123 | 124 | test "includes all params" do 125 | with_config([insights_config: %{ecto: %{include_params: true}}], fn -> 126 | :telemetry.execute( 127 | [:a, :b, :query], 128 | %{}, 129 | %{ 130 | query: "SELECT u0.id, u0.name FROM users u0 WHERE u0.id = ?", 131 | source: "users", 132 | params: [1], 133 | repo: %{__adapter__: Adapter.Postgres} 134 | } 135 | ) 136 | 137 | assert_receive {:api_request, event} 138 | assert event["params"] == [1] 139 | end) 140 | end 141 | 142 | test "includes params for specific sources" do 143 | with_config([insights_config: %{ecto: %{include_params: ["users"]}}], fn -> 144 | :telemetry.execute( 145 | [:a, :b, :query], 146 | %{}, 147 | %{ 148 | query: "SELECT u0.id, u0.name FROM users u0 WHERE u0.id = ?", 149 | source: "users", 150 | params: [1], 151 | repo: %{__adapter__: Adapter.Postgres} 152 | } 153 | ) 154 | 155 | assert_receive {:api_request, event} 156 | assert event["params"] == [1] 157 | end) 158 | end 159 | 160 | test "excludes params for other sources" do 161 | with_config([insights_config: %{ecto: %{include_params: ["orders"]}}], fn -> 162 | :telemetry.execute( 163 | [:a, :b, :query], 164 | %{}, 165 | %{ 166 | query: "SELECT u0.id, u0.name FROM users u0 WHERE u0.id = ?", 167 | source: "users", 168 | params: [1], 169 | repo: %{__adapter__: Adapter.Postgres} 170 | } 171 | ) 172 | 173 | assert_receive {:api_request, event} 174 | refute event["params"] 175 | end) 176 | end 177 | end 178 | 179 | describe "obfuscate/2" do 180 | test "replaces single quoted strings and numbers for non-Postgres adapter" do 181 | sql = " SELECT * FROM users WHERE name = 'John' AND age = 42 " 182 | expected = "SELECT * FROM users WHERE name = '?' AND age = ?" 183 | assert Insights.Ecto.obfuscate(sql, "Ecto.Adapters.MySQL") == expected 184 | end 185 | 186 | test "replaces double quoted strings for non-Postgres adapter" do 187 | sql = "SELECT * FROM items WHERE category = \"books\"" 188 | expected = "SELECT * FROM items WHERE category = \"?\"" 189 | assert Insights.Ecto.obfuscate(sql, "Ecto.Adapters.MySQL") == expected 190 | end 191 | 192 | test "leaves double quoted strings intact for Postgres adapter" do 193 | sql = "SELECT * FROM items WHERE category = \"books\"" 194 | expected = "SELECT * FROM items WHERE category = \"books\"" 195 | assert Insights.Ecto.obfuscate(sql, "Ecto.Adapters.Postgres") == expected 196 | end 197 | 198 | test "combines multiple replacements" do 199 | sql = "INSERT INTO users (name, age, token) VALUES ('Alice', 30, 'secret')" 200 | expected = "INSERT INTO users (name, age, token) VALUES ('?', ?, '?')" 201 | assert Insights.Ecto.obfuscate(sql, "Ecto.Adapters.MySQL") == expected 202 | end 203 | end 204 | end 205 | -------------------------------------------------------------------------------- /test/honeybadger/insights/finch_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.Insights.FinchTest do 2 | use Honeybadger.Case, async: false 3 | use Honeybadger.InsightsCase 4 | 5 | describe "Finch instrumentation" do 6 | test "captures Finch request events" do 7 | event = 8 | send_and_receive( 9 | [:finch, :request, :stop], 10 | %{duration: System.convert_time_unit(10, :microsecond, :native)}, 11 | %{ 12 | name: :my_client, 13 | request: %{ 14 | method: "GET", 15 | scheme: :https, 16 | host: "api.example.com", 17 | port: nil, 18 | path: "/users" 19 | }, 20 | result: {:ok, %Finch.Response{status: 200}} 21 | } 22 | ) 23 | 24 | assert event["event_type"] == "finch.request.stop" 25 | assert event["method"] == "GET" 26 | assert event["host"] == "api.example.com" 27 | assert event["status"] == 200 28 | assert event["duration"] == 10 29 | 30 | refute event["url"] 31 | end 32 | 33 | test "captures error responses" do 34 | error = RuntimeError.exception("connection refused") 35 | 36 | event = 37 | send_and_receive( 38 | [:finch, :request, :stop], 39 | %{duration: System.convert_time_unit(25, :microsecond, :native)}, 40 | %{ 41 | name: :my_client, 42 | request: %{ 43 | method: "POST", 44 | scheme: :https, 45 | host: "api.example.com", 46 | port: 443, 47 | path: "/users" 48 | }, 49 | result: {:error, error} 50 | } 51 | ) 52 | 53 | assert event["event_type"] == "finch.request.stop" 54 | assert event["method"] == "POST" 55 | assert event["host"] == "api.example.com" 56 | assert event["error"] == "connection refused" 57 | assert event["duration"] == 25 58 | end 59 | 60 | test "captures streaming responses" do 61 | event = 62 | send_and_receive( 63 | [:finch, :request, :stop], 64 | %{duration: System.convert_time_unit(15, :microsecond, :native)}, 65 | %{ 66 | name: :my_client, 67 | request: %{ 68 | method: "GET", 69 | scheme: :https, 70 | host: "api.example.com", 71 | port: 443, 72 | path: "/stream" 73 | }, 74 | # Simulating a streaming accumulator 75 | result: {:ok, []} 76 | } 77 | ) 78 | 79 | assert event["event_type"] == "finch.request.stop" 80 | assert event["method"] == "GET" 81 | assert event["host"] == "api.example.com" 82 | assert event["streaming"] == true 83 | assert event["duration"] == 15 84 | end 85 | 86 | test "captures full url when configured" do 87 | with_config([insights_config: %{finch: %{full_url: true}}], fn -> 88 | event = 89 | send_and_receive( 90 | [:finch, :request, :stop], 91 | %{duration: System.convert_time_unit(8, :microsecond, :native)}, 92 | %{ 93 | name: :my_client, 94 | request: %{ 95 | method: "GET", 96 | scheme: :https, 97 | host: "api.example.com", 98 | port: 443, 99 | path: "/users" 100 | }, 101 | result: {:ok, %Finch.Response{status: 200}} 102 | } 103 | ) 104 | 105 | assert event["event_type"] == "finch.request.stop" 106 | assert event["method"] == "GET" 107 | assert event["host"] == "api.example.com" 108 | assert event["url"] == "https://api.example.com/users" 109 | assert event["status"] == 200 110 | assert event["duration"] == 8 111 | end) 112 | end 113 | 114 | test "handles non-standard ports correctly" do 115 | event = 116 | send_and_receive( 117 | [:finch, :request, :stop], 118 | %{duration: System.convert_time_unit(12, :microsecond, :native)}, 119 | %{ 120 | name: :my_client, 121 | request: %{ 122 | method: "GET", 123 | scheme: :http, 124 | host: "localhost", 125 | port: 8080, 126 | path: "/api" 127 | }, 128 | result: {:ok, %Finch.Response{status: 200}} 129 | } 130 | ) 131 | 132 | assert event["event_type"] == "finch.request.stop" 133 | assert event["method"] == "GET" 134 | assert event["host"] == "localhost" 135 | assert event["status"] == 200 136 | assert event["duration"] == 12 137 | 138 | # Test with full_url config 139 | with_config([insights_config: %{finch: %{full_url: true}}], fn -> 140 | event = 141 | send_and_receive( 142 | [:finch, :request, :stop], 143 | %{duration: System.convert_time_unit(12, :microsecond, :native)}, 144 | %{ 145 | name: :my_client, 146 | request: %{ 147 | method: "GET", 148 | scheme: :http, 149 | host: "localhost", 150 | port: 8080, 151 | path: "/api" 152 | }, 153 | result: {:ok, %Finch.Response{status: 200}} 154 | } 155 | ) 156 | 157 | assert event["url"] == "http://localhost:8080/api" 158 | end) 159 | end 160 | end 161 | end 162 | -------------------------------------------------------------------------------- /test/honeybadger/insights/live_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.Insights.LiveViewTest do 2 | use Honeybadger.Case, async: false 3 | use Honeybadger.InsightsCase 4 | 5 | # Define mock module for testing 6 | defmodule Phoenix.LiveView do 7 | end 8 | 9 | describe "LiveView instrumentation" do 10 | test "extracts metadata from mount event" do 11 | restart_with_config(filter_disable_assigns: false) 12 | 13 | event = 14 | send_and_receive( 15 | [:phoenix, :live_view, :mount, :stop], 16 | %{duration: System.convert_time_unit(15, :microsecond, :native)}, 17 | %{ 18 | uri: "/dashboard", 19 | socket: %{ 20 | id: "phx-Fxyz123", 21 | view: MyApp.DashboardLive, 22 | assigns: %{ 23 | page_title: "Dashboard", 24 | user_id: 123 25 | } 26 | }, 27 | params: %{"tab" => "overview"} 28 | } 29 | ) 30 | 31 | assert event["url"] == "/dashboard" 32 | assert event["socket_id"] == "phx-Fxyz123" 33 | assert event["view"] == "MyApp.DashboardLive" 34 | assert event["params"] == %{"tab" => "overview"} 35 | assert event["assigns"]["page_title"] == "Dashboard" 36 | assert event["assigns"]["user_id"] == 123 37 | end 38 | 39 | test "handles missing socket data gracefully" do 40 | event = 41 | send_and_receive( 42 | [:phoenix, :live_view, :mount, :stop], 43 | %{duration: System.convert_time_unit(10, :microsecond, :native)}, 44 | %{ 45 | uri: "/dashboard", 46 | socket_id: "phx-Ghi012", 47 | params: %{"id" => "123"} 48 | # No socket data provided 49 | } 50 | ) 51 | 52 | assert event["url"] == "/dashboard" 53 | assert event["socket_id"] == "phx-Ghi012" 54 | assert event["params"] == %{"id" => "123"} 55 | assert event["view"] == nil 56 | assert event["assigns"] == nil 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /test/honeybadger/insights/oban_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.Insights.ObanTest do 2 | use Honeybadger.Case, async: false 3 | use Honeybadger.InsightsCase 4 | 5 | # Define mock module for testing 6 | defmodule Oban do 7 | end 8 | 9 | describe "Oban instrumentation" do 10 | test "extracts metadata from job stop event" do 11 | event = 12 | send_and_receive( 13 | [:oban, :job, :stop], 14 | %{duration: System.convert_time_unit(150, :microsecond, :native)}, 15 | %{ 16 | conf: %{prefix: "oban_jobs"}, 17 | job: %{ 18 | id: 123, 19 | args: %{"user_id" => 456, "action" => "send_welcome_email"}, 20 | attempt: 1, 21 | queue: "mailer", 22 | worker: "MyApp.Mailers.WelcomeMailer", 23 | tags: ["email", "onboarding"] 24 | }, 25 | state: :success 26 | } 27 | ) 28 | 29 | assert event["event_type"] == "oban.job.stop" 30 | assert event["id"] == 123 31 | assert event["args"] == %{"user_id" => 456, "action" => "send_welcome_email"} 32 | assert event["attempt"] == 1 33 | assert event["queue"] == "mailer" 34 | assert event["worker"] == "MyApp.Mailers.WelcomeMailer" 35 | assert event["tags"] == ["email", "onboarding"] 36 | assert event["prefix"] == "oban_jobs" 37 | assert event["state"] == "success" 38 | assert event["duration"] == 150 39 | end 40 | 41 | test "extracts metadata from job exception event" do 42 | event = 43 | send_and_receive( 44 | [:oban, :job, :exception], 45 | %{duration: System.convert_time_unit(75, :microsecond, :native)}, 46 | %{ 47 | conf: %{prefix: "oban_jobs"}, 48 | job: %{ 49 | id: 456, 50 | args: %{"post_id" => 789, "action" => "process_image"}, 51 | attempt: 2, 52 | queue: "media", 53 | worker: "MyApp.Media.ImageProcessor", 54 | tags: ["image", "processing"] 55 | }, 56 | state: :failure 57 | } 58 | ) 59 | 60 | assert event["event_type"] == "oban.job.exception" 61 | assert event["id"] == 456 62 | assert event["args"] == %{"post_id" => 789, "action" => "process_image"} 63 | assert event["attempt"] == 2 64 | assert event["queue"] == "media" 65 | assert event["worker"] == "MyApp.Media.ImageProcessor" 66 | assert event["tags"] == ["image", "processing"] 67 | assert event["prefix"] == "oban_jobs" 68 | assert event["state"] == "failure" 69 | assert event["duration"] == 75 70 | end 71 | 72 | test "sets event_context if in metadata" do 73 | :telemetry.execute( 74 | [:oban, :job, :start], 75 | %{}, 76 | %{job: %{meta: %{"hb_event_context" => %{user_id: 123, action: "generate_report"}}}} 77 | ) 78 | 79 | event = 80 | send_and_receive( 81 | [:oban, :job, :stop], 82 | %{duration: System.convert_time_unit(200, :microsecond, :native)}, 83 | %{ 84 | conf: %{prefix: "oban_jobs"}, 85 | job: %{ 86 | id: 789, 87 | args: %{"user_id" => 123, "action" => "generate_report"}, 88 | attempt: 1, 89 | queue: "reports", 90 | worker: "MyApp.Reports.ReportGenerator", 91 | tags: ["report", "generation"] 92 | }, 93 | state: :success 94 | } 95 | ) 96 | 97 | assert event["event_type"] == "oban.job.stop" 98 | assert event["user_id"] == 123 99 | assert event["action"] == "generate_report" 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /test/honeybadger/insights/plug_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.Insights.PlugTest do 2 | use Honeybadger.Case, async: false 3 | use Honeybadger.InsightsCase 4 | 5 | def mock_conn(attrs \\ %{}) do 6 | conn = %Plug.Conn{ 7 | method: "GET", 8 | request_path: "/users/123", 9 | params: %{"id" => "123"}, 10 | status: 200, 11 | assigns: %{}, 12 | req_headers: %{}, 13 | adapter: {nil, nil}, 14 | owner: self(), 15 | remote_ip: {127, 0, 0, 1} 16 | } 17 | 18 | Map.merge(conn, attrs) 19 | end 20 | 21 | describe "Plug instrumentation" do 22 | test "returns start events" do 23 | events = Honeybadger.Insights.Plug.get_telemetry_events() 24 | 25 | assert events == [ 26 | [:phoenix, :endpoint, :stop], 27 | [:phoenix, :endpoint, :start] 28 | ] 29 | end 30 | 31 | test "extracts metadata from plug event with request_id in assigns" do 32 | event = 33 | send_and_receive( 34 | [:phoenix, :endpoint, :stop], 35 | %{duration: System.convert_time_unit(15, :microsecond, :native)}, 36 | %{ 37 | conn: 38 | mock_conn(%{ 39 | assigns: %{request_id: "abc-xyz-123"} 40 | }) 41 | } 42 | ) 43 | 44 | assert event["method"] == "GET" 45 | assert event["request_path"] == "/users/123" 46 | assert event["params"] == %{"id" => "123"} 47 | assert event["status"] == 200 48 | assert event["duration"] == 15 49 | end 50 | 51 | test "extracts request_id from headers when not in assigns" do 52 | event = 53 | send_and_receive( 54 | [:phoenix, :endpoint, :stop], 55 | %{duration: System.convert_time_unit(10, :microsecond, :native)}, 56 | %{ 57 | conn: 58 | mock_conn(%{ 59 | method: "POST", 60 | request_path: "/api/items", 61 | params: %{"title" => "New Item"}, 62 | status: 201, 63 | assigns: %{}, 64 | resp_headers: [{"x-request-id", "req-123-456"}] 65 | }) 66 | } 67 | ) 68 | 69 | assert event["method"] == "POST" 70 | assert event["request_path"] == "/api/items" 71 | assert event["params"] == %{"title" => "New Item"} 72 | assert event["status"] == 201 73 | assert event["duration"] == 10 74 | end 75 | 76 | test "handles missing request_id gracefully" do 77 | event = 78 | send_and_receive( 79 | [:phoenix, :endpoint, :stop], 80 | %{duration: System.convert_time_unit(5, :microsecond, :native)}, 81 | %{ 82 | conn: 83 | mock_conn(%{ 84 | method: "DELETE", 85 | request_path: "/api/items/456", 86 | params: %{"id" => "456"}, 87 | status: 204 88 | }) 89 | } 90 | ) 91 | 92 | assert event["method"] == "DELETE" 93 | assert event["request_path"] == "/api/items/456" 94 | assert event["params"] == %{"id" => "456"} 95 | assert event["status"] == 204 96 | assert event["duration"] == 5 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /test/honeybadger/insights/tesla_test.exs: -------------------------------------------------------------------------------- 1 | # Define a mock Tesla module just for testing 2 | defmodule Tesla do 3 | defmodule Adapter do 4 | defmodule Finch do 5 | end 6 | end 7 | end 8 | 9 | defmodule Honeybadger.Insights.TeslaTest do 10 | use Honeybadger.Case, async: false 11 | use Honeybadger.InsightsCase 12 | 13 | describe "Tesla instrumentation" do 14 | test "captures Tesla request events" do 15 | event = 16 | send_and_receive( 17 | [:tesla, :request, :stop], 18 | %{duration: System.convert_time_unit(5, :microsecond, :native)}, 19 | %{ 20 | env: %{ 21 | method: :get, 22 | url: "https://api.example.com", 23 | status: 200 24 | } 25 | } 26 | ) 27 | 28 | assert event["event_type"] == "tesla.request.stop" 29 | assert event["method"] == "GET" 30 | assert event["host"] == "api.example.com" 31 | assert event["status_code"] == 200 32 | assert event["duration"] == 5 33 | 34 | refute event["url"] 35 | end 36 | 37 | test "captures Tesla request exceptions" do 38 | event = 39 | send_and_receive( 40 | [:tesla, :request, :exception], 41 | %{duration: System.convert_time_unit(30, :microsecond, :native)}, 42 | %{ 43 | env: %{ 44 | method: :post, 45 | url: "https://a.example.net/users", 46 | status: 500 47 | } 48 | } 49 | ) 50 | 51 | # Assert the correct data was included 52 | assert event["event_type"] == "tesla.request.exception" 53 | assert event["method"] == "POST" 54 | assert event["host"] == "a.example.net" 55 | assert event["status_code"] == 500 56 | assert event["duration"] == 30 57 | end 58 | 59 | test "Ignores telemetry events from Finch adapters" do 60 | :telemetry.execute( 61 | [:tesla, :request, :stop], 62 | %{}, 63 | %{ 64 | env: %{ 65 | method: :post, 66 | url: "https://a.example.net/users", 67 | status: 500, 68 | __client__: %{adapter: {Tesla.Adapter.Finch, :a, :b}} 69 | } 70 | } 71 | ) 72 | 73 | refute_receive {:api_request, _} 74 | end 75 | 76 | test "captures full url" do 77 | with_config([insights_config: %{tesla: %{full_url: true}}], fn -> 78 | event = 79 | send_and_receive( 80 | [:tesla, :request, :stop], 81 | %{duration: System.convert_time_unit(5, :microsecond, :native)}, 82 | %{ 83 | env: %{ 84 | method: :get, 85 | url: "https://api.example.com", 86 | status: 200 87 | } 88 | } 89 | ) 90 | 91 | assert event["event_type"] == "tesla.request.stop" 92 | assert event["method"] == "GET" 93 | assert event["url"] == "https://api.example.com" 94 | assert event["status_code"] == 200 95 | assert event["duration"] == 5 96 | end) 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /test/honeybadger/json_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.JSONTest do 2 | use Honeybadger.Case 3 | 4 | alias Honeybadger.{Notice, JSON} 5 | 6 | defmodule Request do 7 | defstruct [:ip] 8 | end 9 | 10 | describe "encode/1" do 11 | test "encodes notice" do 12 | notice = Notice.new(%RuntimeError{message: "oops"}, %{}, []) 13 | 14 | assert {:ok, encoded} = JSON.encode(notice) 15 | 16 | assert encoded =~ ~s|"notifier"| 17 | assert encoded =~ ~s|"server"| 18 | assert encoded =~ ~s|"error"| 19 | assert encoded =~ ~s|"request"| 20 | assert encoded =~ ~s|"breadcrumbs"| 21 | end 22 | 23 | test "encodes notice when context has structs" do 24 | error = %RuntimeError{message: "oops"} 25 | struct = %Request{ip: "0.0.0.0"} 26 | map = Map.from_struct(struct) 27 | 28 | {:ok, custom_encoded} = 29 | error 30 | |> Notice.new(%{context: %{a: struct, b: [struct], c: {struct, struct}}}, []) 31 | |> JSON.encode() 32 | 33 | {:ok, jason_encoded} = 34 | error 35 | |> Notice.new(%{context: %{a: map, b: [map], c: [map, map]}}, []) 36 | |> Jason.encode() 37 | 38 | assert Jason.decode!(custom_encoded) == Jason.decode!(jason_encoded) 39 | end 40 | 41 | test "handles values requring inspection" do 42 | {:ok, ~s("&Honeybadger.JSON.encode/1")} = JSON.encode(&Honeybadger.JSON.encode/1) 43 | {:ok, ~s("#PID<0.250.0>")} = JSON.encode(:c.pid(0, 250, 0)) 44 | 45 | ref = make_ref() 46 | {:ok, encoded_ref} = JSON.encode(ref) 47 | assert "\"#{inspect(ref)}\"" == encoded_ref 48 | 49 | port = Port.open({:spawn, "false"}, [:binary]) 50 | {:ok, encoded_port} = JSON.encode(port) 51 | assert "\"#{inspect(port)}\"" == encoded_port 52 | end 53 | 54 | test "safely handling binaries with invalid bytes" do 55 | {:ok, ~s("honeybadger")} = JSON.encode(<<"honeybadger", 241>>) 56 | {:ok, ~s("honeybadger")} = JSON.encode(<<"honeybadger", 241, "yo">>) 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /test/honeybadger/logger_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.LoggerTest do 2 | use Honeybadger.Case 3 | 4 | require Logger 5 | 6 | setup do 7 | {:ok, _} = Honeybadger.API.start(self()) 8 | 9 | restart_with_config(exclude_envs: [], breadcrumbs_enabled: true) 10 | 11 | on_exit(&Honeybadger.API.stop/0) 12 | end 13 | 14 | test "GenServer terminating with an error" do 15 | defmodule MyGenServer do 16 | use GenServer 17 | 18 | def start_link(_opts) do 19 | GenServer.start(__MODULE__, %{}, name: Elixir.MyGenServer) 20 | end 21 | 22 | def init(opts), do: {:ok, opts} 23 | 24 | def handle_cast(:raise_error, state) do 25 | _ = Map.fetch!(state, :bad_key) 26 | 27 | {:noreply, state} 28 | end 29 | end 30 | 31 | {:ok, pid} = start_supervised(MyGenServer) 32 | 33 | GenServer.cast(pid, :raise_error) 34 | 35 | assert_receive {:api_request, 36 | %{"breadcrumbs" => breadcrumbs, "error" => error, "request" => request}} 37 | 38 | assert List.first(breadcrumbs["trail"])["message"] == "KeyError" 39 | 40 | assert error["class"] == "KeyError" 41 | 42 | assert request["context"]["registered_name"] == "Elixir.MyGenServer" 43 | assert request["context"]["last_message"] =~ "$gen_cast" 44 | assert request["context"]["state"] == "%{}" 45 | end 46 | 47 | test "GenEvent terminating with an error" do 48 | defmodule MyEventHandler do 49 | @behaviour :gen_event 50 | 51 | def init(state), do: {:ok, state} 52 | def terminate(_reason, _state), do: :ok 53 | def code_change(_old_vsn, state, _extra), do: {:ok, state} 54 | def handle_call(_request, state), do: {:ok, :ok, state} 55 | def handle_info(_message, state), do: {:ok, state} 56 | 57 | def handle_event(:raise_error, state) do 58 | raise "Oops" 59 | 60 | {:ok, state} 61 | end 62 | end 63 | 64 | {:ok, manager} = :gen_event.start() 65 | :ok = :gen_event.add_handler(manager, MyEventHandler, {}) 66 | 67 | :gen_event.notify(manager, :raise_error) 68 | 69 | assert_receive {:api_request, 70 | %{"breadcrumbs" => breadcrumbs, "error" => error, "request" => request}} 71 | 72 | assert List.first(breadcrumbs["trail"])["message"] == "RuntimeError" 73 | 74 | assert error["class"] == "RuntimeError" 75 | 76 | assert request["context"]["name"] == "Honeybadger.LoggerTest.MyEventHandler" 77 | assert request["context"]["last_message"] =~ ":raise_error" 78 | assert request["context"]["state"] == "{}" 79 | end 80 | 81 | test "process raising an error" do 82 | pid = spawn(fn -> raise "Oops" end) 83 | 84 | assert_receive {:api_request, 85 | %{"breadcrumbs" => breadcrumbs, "error" => error, "request" => request}} 86 | 87 | assert List.first(breadcrumbs["trail"])["message"] == "RuntimeError" 88 | 89 | assert error["class"] == "RuntimeError" 90 | 91 | assert request["context"]["name"] == inspect(pid) 92 | end 93 | 94 | test "task with anonymous function raising an error" do 95 | Task.start(fn -> raise "Oops" end) 96 | 97 | assert_receive {:api_request, 98 | %{"breadcrumbs" => breadcrumbs, "error" => error, "request" => request}} 99 | 100 | assert List.first(breadcrumbs["trail"])["message"] == "RuntimeError" 101 | 102 | assert error["class"] == "RuntimeError" 103 | assert error["message"] == "Oops" 104 | 105 | assert request["context"]["function"] =~ ~r/\A#Function<.* in Honeybadger\.LoggerTest/ 106 | assert request["context"]["args"] == "[]" 107 | end 108 | 109 | test "task with mfa raising an error" do 110 | defmodule MyModule do 111 | def raise_error(message), do: raise(message) 112 | end 113 | 114 | Task.start(MyModule, :raise_error, ["my message"]) 115 | 116 | assert_receive {:api_request, 117 | %{"breadcrumbs" => breadcrumbs, "error" => _, "request" => request}} 118 | 119 | assert List.first(breadcrumbs["trail"])["metadata"]["message"] == "my message" 120 | 121 | assert request["context"]["function"] =~ "&Honeybadger.LoggerTest.MyModule.raise_error/1" 122 | assert request["context"]["args"] == ~s(["my message"]) 123 | end 124 | 125 | test "includes additional logger metadata as context" do 126 | Task.start(fn -> 127 | Logger.metadata(age: 2, name: "Danny", user_id: 3) 128 | 129 | raise "Oops" 130 | end) 131 | 132 | assert_receive {:api_request, %{"breadcrumbs" => breadcrumbs, "request" => request}} 133 | 134 | assert List.first(breadcrumbs["trail"])["metadata"]["message"] == "Oops" 135 | 136 | assert request["context"]["age"] == 2 137 | assert request["context"]["name"] == "Danny" 138 | assert request["context"]["user_id"] == 3 139 | end 140 | 141 | test "log levels lower than :error are ignored" do 142 | Logger.metadata(crash_reason: {%RuntimeError{}, []}) 143 | 144 | Logger.info(fn -> "This is not a real error" end) 145 | 146 | refute_receive {:api_request, _} 147 | end 148 | 149 | test "handles error-level log when enabled" do 150 | with_config([sasl_logging_only: false], fn -> 151 | Logger.error("Error-level log") 152 | 153 | assert_receive {:api_request, %{"breadcrumbs" => breadcrumbs}} 154 | assert List.first(breadcrumbs["trail"])["metadata"]["message"] == "Error-level log" 155 | end) 156 | end 157 | 158 | test "ignores error-level log when disabled" do 159 | with_config([sasl_logging_only: true], fn -> 160 | Logger.error("Error-level log") 161 | 162 | refute_receive {:api_request, _} 163 | end) 164 | end 165 | 166 | test "ignores specific logger domains" do 167 | with_config([ignored_domains: [:neat]], fn -> 168 | Task.start(fn -> 169 | Logger.error("what", domain: [:neat]) 170 | end) 171 | 172 | refute_receive {:api_request, _} 173 | end) 174 | end 175 | 176 | test "ignores internal error" do 177 | with_config([sasl_logging_only: false], fn -> 178 | Logger.error("ignores internal error") 179 | assert_receive {:api_request, _} 180 | 181 | Logger.error("ignores internal error", application: :honeybadger) 182 | refute_receive {:api_request, _} 183 | end) 184 | end 185 | end 186 | -------------------------------------------------------------------------------- /test/honeybadger/plug_data_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.PlugDataTest do 2 | use Honeybadger.Case, async: true 3 | use Plug.Test 4 | 5 | alias Honeybadger.PlugData 6 | 7 | describe "build_plug_env/2" do 8 | test "building outside of a phoenix app" do 9 | conn = conn(:get, "/bang?foo=bar") 10 | 11 | assert match?( 12 | %{component: "PlugApp", params: %{"foo" => "bar"}, url: "/bang"}, 13 | PlugData.build_plug_env(conn, PlugApp) 14 | ) 15 | end 16 | 17 | test "building inside of a phoenix app" do 18 | conn = 19 | :get 20 | |> conn("/bang") 21 | |> put_private(:phoenix_controller, DanController) 22 | |> put_private(:phoenix_action, :fight) 23 | 24 | assert match?( 25 | %{action: "fight", component: "DanController"}, 26 | PlugData.build_plug_env(conn, PlugApp) 27 | ) 28 | end 29 | end 30 | 31 | describe "build_cgi_data/1" do 32 | test "general CGI data is extracted" do 33 | conn = conn(:get, "/bang") 34 | %{port: remote_port} = get_peer_data(conn) 35 | 36 | cgi_data = %{ 37 | "CONTENT_LENGTH" => [], 38 | "ORIGINAL_FULLPATH" => "/bang", 39 | "PATH_INFO" => "bang", 40 | "QUERY_STRING" => "", 41 | "REMOTE_ADDR" => "127.0.0.1", 42 | "REMOTE_PORT" => remote_port, 43 | "REQUEST_METHOD" => "GET", 44 | "SCRIPT_NAME" => "", 45 | "SERVER_ADDR" => "127.0.0.1", 46 | "SERVER_NAME" => Application.get_env(:honeybadger, :hostname), 47 | "SERVER_PORT" => 80 48 | } 49 | 50 | assert cgi_data == PlugData.build_cgi_data(conn) 51 | end 52 | 53 | test "formatted headers are included" do 54 | headers = [ 55 | {"content-type", "application/json"}, 56 | {"origin", "somewhere"} 57 | ] 58 | 59 | conn = %{conn(:get, "/bang") | req_headers: headers} 60 | 61 | assert match?( 62 | %{"HTTP_CONTENT_TYPE" => "application/json", "HTTP_ORIGIN" => "somewhere"}, 63 | PlugData.build_cgi_data(conn) 64 | ) 65 | end 66 | 67 | test "handles invalid remote ip" do 68 | conn = %{conn(:get, "/bang") | remote_ip: nil} 69 | %{port: remote_port} = get_peer_data(conn) 70 | 71 | assert PlugData.build_cgi_data(conn) == %{ 72 | "CONTENT_LENGTH" => [], 73 | "ORIGINAL_FULLPATH" => "/bang", 74 | "PATH_INFO" => "bang", 75 | "QUERY_STRING" => "", 76 | "REMOTE_ADDR" => "", 77 | "REMOTE_PORT" => remote_port, 78 | "REQUEST_METHOD" => "GET", 79 | "SCRIPT_NAME" => "", 80 | "SERVER_ADDR" => "127.0.0.1", 81 | "SERVER_NAME" => Application.get_env(:honeybadger, :hostname), 82 | "SERVER_PORT" => 80 83 | } 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /test/honeybadger/plug_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.PlugTest do 2 | use Honeybadger.Case 3 | use Plug.Test 4 | 5 | alias Plug.Conn.WrapperError 6 | 7 | defmodule CustomNotFound do 8 | defexception [:message] 9 | end 10 | 11 | defimpl Plug.Exception, for: CustomNotFound do 12 | def actions(_), do: [] 13 | def status(_), do: 404 14 | end 15 | 16 | defmodule PlugApp do 17 | use Plug.Router 18 | use Honeybadger.Plug 19 | 20 | alias Honeybadger.PlugTest.CustomNotFound 21 | 22 | plug(:match) 23 | plug(:dispatch) 24 | 25 | get "/bang" do 26 | _ = conn 27 | raise RuntimeError, "Oops" 28 | end 29 | 30 | get "/404_exception" do 31 | _ = conn 32 | raise CustomNotFound, "Oops" 33 | end 34 | end 35 | 36 | describe "handle_errors/2" do 37 | setup do 38 | {:ok, _} = Honeybadger.API.start(self()) 39 | 40 | on_exit(&Honeybadger.API.stop/0) 41 | 42 | restart_with_config(exclude_envs: [], breadcrumbs_enabled: true) 43 | end 44 | 45 | test "errors are reported" do 46 | conn = conn(:get, "/bang") 47 | 48 | assert %WrapperError{reason: reason} = catch_error(PlugApp.call(conn, [])) 49 | assert %RuntimeError{message: "Oops"} = reason 50 | 51 | assert_receive {:api_request, %{"breadcrumbs" => breadcrumbs}} 52 | 53 | assert List.first(breadcrumbs["trail"])["metadata"]["message"] == "Oops" 54 | end 55 | 56 | test "not found errors for plug are ignored" do 57 | conn = conn(:get, "/not_found") 58 | 59 | assert :function_clause == catch_error(PlugApp.call(conn, [])) 60 | 61 | refute_receive {:api_request, _} 62 | end 63 | 64 | test "exceptions that implement Plug.Exception and return a 404 are ignored" do 65 | conn = conn(:get, "/404_exception") 66 | 67 | assert %WrapperError{reason: reason} = catch_error(PlugApp.call(conn, [])) 68 | assert %CustomNotFound{message: "Oops"} = reason 69 | 70 | refute_receive {:api_request, _} 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /test/honeybadger/utils_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.UtilsTest do 2 | use ExUnit.Case, async: true 3 | 4 | doctest Honeybadger.Utils 5 | alias Honeybadger.Utils 6 | 7 | test "sanitize drops nested hash based on depth" do 8 | item = %{ 9 | a: %{ 10 | b: 12, 11 | m: %{ 12 | j: 3 13 | } 14 | }, 15 | c: "string" 16 | } 17 | 18 | assert Utils.sanitize(item, max_depth: 2) == %{ 19 | a: %{ 20 | b: 12, 21 | m: "[DEPTH]" 22 | }, 23 | c: "string" 24 | } 25 | end 26 | 27 | test "sanitize drops nested lists based on depth" do 28 | item = [[[a: 12]], 1, 2, 3] 29 | 30 | assert Utils.sanitize(item, max_depth: 2) == [["[DEPTH]"], 1, 2, 3] 31 | end 32 | 33 | test "converts dates to ISO8601" do 34 | item = %{ 35 | date: ~D[2023-10-01], 36 | datetime: ~U[2023-10-01 12:00:00Z], 37 | naive_datetime: ~N[2023-10-01 12:00:00] 38 | } 39 | 40 | assert Utils.sanitize(item) == %{ 41 | date: "2023-10-01", 42 | datetime: "2023-10-01T12:00:00Z", 43 | naive_datetime: "2023-10-01T12:00:00" 44 | } 45 | end 46 | 47 | test "sanitize truncates strings" do 48 | item = "123456789" 49 | 50 | assert Utils.sanitize(item, max_string_size: 3) == "123[TRUNCATED]" 51 | end 52 | 53 | test "sanitize removes filtered_keys" do 54 | item = %{ 55 | filter_me: "secret stuff", 56 | okay: "not a secret at all" 57 | } 58 | 59 | assert Utils.sanitize(item, filter_keys: [:filter_me]) == %{ 60 | filter_me: "[FILTERED]", 61 | okay: "not a secret at all" 62 | } 63 | end 64 | 65 | test "sanitize removes nested keys" do 66 | item = %{ 67 | key1: "val1", 68 | key2: %{ 69 | __remove__: "val2" 70 | } 71 | } 72 | 73 | assert Utils.sanitize(item, filter_keys: [:__remove__], remove_filtered: true) == %{ 74 | key1: "val1" 75 | } 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /test/support/insights_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Honeybadger.InsightsCase do 2 | @moduledoc """ 3 | This module defines helpers for testing 4 | Honeybadger.Insights instrumentation. 5 | """ 6 | 7 | use ExUnit.CaseTemplate 8 | 9 | using do 10 | quote do 11 | # Import helpers from this module 12 | import Honeybadger.InsightsCase 13 | 14 | setup do 15 | {:ok, _} = Honeybadger.API.start(self()) 16 | 17 | restart_with_config( 18 | insights_enabled: true, 19 | events_worker_enabled: false, 20 | exclude_envs: [] 21 | ) 22 | 23 | on_exit(fn -> 24 | Honeybadger.API.stop() 25 | end) 26 | 27 | :ok 28 | end 29 | end 30 | end 31 | 32 | @doc """ 33 | Sends a telemetry event and waits to receive the resulting API request. 34 | Returns the event data from the request. 35 | """ 36 | def send_and_receive(event, measurements, metadata) do 37 | :telemetry.execute(event, measurements, metadata) 38 | assert_receive {:api_request, request} 39 | request 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Logger.remove_backend(:console) 2 | 3 | Application.put_all_env( 4 | ex_unit: [ 5 | assert_receive_timeout: 400, 6 | refute_receive_timeout: 200 7 | ], 8 | honeybadger: [ 9 | environment_name: :test, 10 | api_key: "abc123", 11 | origin: "http://localhost:4444", 12 | insights_enabled: true 13 | ] 14 | ) 15 | 16 | {:ok, _} = Application.ensure_all_started(:req) 17 | {:ok, _} = Application.ensure_all_started(:hackney) 18 | 19 | ExUnit.start(assert_receive_timeout: 1000, refute_receive_timeout: 1000) 20 | 21 | _ = ~w(live_component live_view live_socket phoenix operation)a 22 | 23 | defmodule Honeybadger.Case do 24 | use ExUnit.CaseTemplate 25 | 26 | using(_) do 27 | quote do 28 | import unquote(__MODULE__) 29 | end 30 | end 31 | 32 | def with_config(opts, fun) when is_function(fun) do 33 | original = take_original_env(opts) 34 | 35 | try do 36 | put_all_env(opts) 37 | 38 | fun.() 39 | after 40 | put_all_env(original) 41 | end 42 | end 43 | 44 | def restart_with_config(opts) do 45 | :ok = Application.stop(:honeybadger) 46 | original = take_original_env(opts) 47 | 48 | put_all_env(opts) 49 | 50 | on_exit(fn -> 51 | put_all_env(original) 52 | end) 53 | 54 | :ok = Application.ensure_started(:honeybadger) 55 | end 56 | 57 | def capture_log(fun, device \\ :user) do 58 | Logger.add_backend(:console, flush: true) 59 | 60 | on_exit(fn -> 61 | Logger.remove_backend(:console) 62 | end) 63 | 64 | ExUnit.CaptureIO.capture_io(device, fn -> 65 | fun.() 66 | :timer.sleep(100) 67 | Logger.flush() 68 | end) 69 | end 70 | 71 | defp take_original_env(opts) do 72 | Keyword.take(Application.get_all_env(:honeybadger), Keyword.keys(opts)) 73 | end 74 | 75 | defp put_all_env(opts) do 76 | Enum.each(opts, fn {key, val} -> 77 | Application.put_env(:honeybadger, key, val) 78 | end) 79 | end 80 | end 81 | 82 | defmodule Honeybadger.API do 83 | import Plug.Conn 84 | 85 | alias Plug.Conn 86 | alias Plug.Cowboy 87 | 88 | def start(pid) do 89 | Cowboy.http(__MODULE__, [test: pid], port: 4444) 90 | end 91 | 92 | def stop do 93 | :timer.sleep(100) 94 | Cowboy.shutdown(__MODULE__.HTTP) 95 | :timer.sleep(100) 96 | end 97 | 98 | def init(opts) do 99 | Keyword.fetch!(opts, :test) 100 | end 101 | 102 | def call(%Conn{method: "POST"} = conn, test) do 103 | {:ok, body, conn} = read_body(conn) 104 | content_type = List.first(get_req_header(conn, "content-type")) || "application/json" 105 | send(test, {:api_request, decode_payload(body, content_type)}) 106 | send_resp(conn, 200, "{}") 107 | end 108 | 109 | def call(conn, _test), do: send_resp(conn, 404, "Not Found") 110 | 111 | defp decode_payload(body, "application/x-ndjson") do 112 | body 113 | |> String.split("\n", trim: true) 114 | |> Enum.map(&Jason.decode!/1) 115 | end 116 | 117 | defp decode_payload(body, _), do: Jason.decode!(body) 118 | end 119 | --------------------------------------------------------------------------------