├── test ├── test_helper.exs ├── support │ ├── mocks.ex │ ├── case.ex │ └── discord │ │ └── api │ │ └── stub.ex ├── disco_log │ ├── config_test.exs │ ├── context_test.exs │ ├── application_test.exs │ ├── integrations │ │ ├── oban_test.exs │ │ └── plug_test.exs │ ├── error_test.exs │ ├── storage_test.exs │ ├── discord │ │ └── prepare_test.exs │ ├── presence_test.exs │ └── logger_handler_test.exs └── disco_log_test.exs ├── .tool-versions ├── assets ├── 001.png ├── 002.png ├── 003.png ├── 004.png ├── 005.png ├── 006.png ├── 007.png ├── 008.png ├── 009.png ├── 010.png ├── 011.png ├── 012.png ├── 013.png ├── 014.png ├── 015.png ├── 016.png ├── 017.png ├── 018.png ├── 019.png ├── presence.png └── go-to-code.png ├── config ├── config.exs ├── test.exs └── dev.examples.exs ├── .formatter.exs ├── .github ├── dependabot.yml ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── tests.yml ├── lib ├── disco_log │ ├── registry.ex │ ├── application.ex │ ├── discord │ │ ├── api │ │ │ └── client.ex │ │ ├── api.ex │ │ └── prepare.ex │ ├── context.ex │ ├── supervisor.ex │ ├── discord.ex │ ├── websocket_client │ │ └── impl.ex │ ├── logger_handler.ex │ ├── websocket_client.ex │ ├── integrations │ │ ├── plug.ex │ │ └── oban.ex │ ├── storage.ex │ ├── error.ex │ ├── config.ex │ └── presence.ex ├── mix │ └── tasks │ │ ├── disco_log.sample.ex │ │ ├── disco_log.drop.ex │ │ ├── disco_log.cleanup.ex │ │ └── disco_log.create.ex └── disco_log.ex ├── .gitignore ├── LICENSE ├── README.md ├── guides ├── standalone-presence.md ├── advanced-configuration.md └── getting-started.md ├── mix.exs ├── CHANGELOG.md ├── .credo.exs ├── dev.exs └── mix.lock /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.18.4-otp-27 2 | erlang 27.3.4.2 3 | -------------------------------------------------------------------------------- /assets/001.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrdotb/disco-log/HEAD/assets/001.png -------------------------------------------------------------------------------- /assets/002.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrdotb/disco-log/HEAD/assets/002.png -------------------------------------------------------------------------------- /assets/003.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrdotb/disco-log/HEAD/assets/003.png -------------------------------------------------------------------------------- /assets/004.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrdotb/disco-log/HEAD/assets/004.png -------------------------------------------------------------------------------- /assets/005.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrdotb/disco-log/HEAD/assets/005.png -------------------------------------------------------------------------------- /assets/006.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrdotb/disco-log/HEAD/assets/006.png -------------------------------------------------------------------------------- /assets/007.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrdotb/disco-log/HEAD/assets/007.png -------------------------------------------------------------------------------- /assets/008.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrdotb/disco-log/HEAD/assets/008.png -------------------------------------------------------------------------------- /assets/009.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrdotb/disco-log/HEAD/assets/009.png -------------------------------------------------------------------------------- /assets/010.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrdotb/disco-log/HEAD/assets/010.png -------------------------------------------------------------------------------- /assets/011.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrdotb/disco-log/HEAD/assets/011.png -------------------------------------------------------------------------------- /assets/012.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrdotb/disco-log/HEAD/assets/012.png -------------------------------------------------------------------------------- /assets/013.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrdotb/disco-log/HEAD/assets/013.png -------------------------------------------------------------------------------- /assets/014.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrdotb/disco-log/HEAD/assets/014.png -------------------------------------------------------------------------------- /assets/015.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrdotb/disco-log/HEAD/assets/015.png -------------------------------------------------------------------------------- /assets/016.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrdotb/disco-log/HEAD/assets/016.png -------------------------------------------------------------------------------- /assets/017.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrdotb/disco-log/HEAD/assets/017.png -------------------------------------------------------------------------------- /assets/018.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrdotb/disco-log/HEAD/assets/018.png -------------------------------------------------------------------------------- /assets/019.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrdotb/disco-log/HEAD/assets/019.png -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | import_config "#{config_env()}.exs" 4 | -------------------------------------------------------------------------------- /assets/presence.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrdotb/disco-log/HEAD/assets/presence.png -------------------------------------------------------------------------------- /assets/go-to-code.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrdotb/disco-log/HEAD/assets/go-to-code.png -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "mix" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /lib/disco_log/registry.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscoLog.Registry do 2 | @moduledoc false 3 | def registry_name(supervisor_name), do: Module.concat(supervisor_name, Registry) 4 | 5 | def via(supervisor_name, server_name), 6 | do: {:via, Registry, {registry_name(supervisor_name), server_name}} 7 | end 8 | -------------------------------------------------------------------------------- /test/support/mocks.ex: -------------------------------------------------------------------------------- 1 | Mox.defmock(DiscoLog.Discord.API.Mock, for: DiscoLog.Discord.API) 2 | Mox.defmock(DiscoLog.WebsocketClient.Mock, for: DiscoLog.WebsocketClient) 3 | 4 | defmodule Env do 5 | @moduledoc false 6 | defstruct [:method, :url, :status, :response_headers, :request_headers] 7 | end 8 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Contributor checklist 2 | - [ ] My commit messages follow the [Conventional Commit Message Format](https://gist.github.com/stephenparish/9941e89d80e2bc58a153#format-of-the-commit-message) 3 | For example: `fix: Multiply by appropriate coefficient`, or 4 | `feat(Calculator): Correctly preserve history` 5 | Any explanation or long form information in your commit message should be 6 | in a separate paragraph, separated by a blank line from the primary message 7 | - [ ] Bug fixes include regression tests 8 | - [ ] Features include unit/acceptance tests 9 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :disco_log, 4 | enable: false, 5 | otp_app: :disco_log, 6 | token: "", 7 | guild_id: "", 8 | category_id: "", 9 | occurrences_channel_id: "", 10 | info_channel_id: "", 11 | error_channel_id: "", 12 | discord_client_module: DiscoLog.Discord.API.Mock, 13 | websocket_adapter: DiscoLog.WebsocketClient.Mock, 14 | enable_presence: true 15 | 16 | config :logger, 17 | backends: [], 18 | # Usefull when debugging logger 19 | # backends: [:console], 20 | compile_time_purge_matching: [ 21 | # Usefull for debugging purposes when doing real discord request 22 | # [module: DiscoLog.Discord.Client, level_lower_than: :info] 23 | ], 24 | truncate: :infinity 25 | -------------------------------------------------------------------------------- /lib/mix/tasks/disco_log.sample.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.DiscoLog.Sample do 2 | @moduledoc """ 3 | Creates some sample logs and errors. 4 | """ 5 | use Mix.Task 6 | 7 | require Logger 8 | 9 | @impl Mix.Task 10 | def run(_args) do 11 | # Ensure disco_log is started 12 | {:ok, _} = Application.ensure_all_started(:disco_log) 13 | 14 | Logger.info("✨ DiscoLog Hello") 15 | Logger.error("🔥 DiscoLog error test") 16 | Logger.info(%{id: 1, username: "Bob"}) 17 | 18 | try do 19 | raise "🚨 DiscoLog is raising an error !" 20 | rescue 21 | exception -> 22 | Exception.format(:error, exception, __STACKTRACE__) 23 | |> Logger.error(crash_reason: {exception, __STACKTRACE__}) 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/mix/tasks/disco_log.drop.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.DiscoLog.Drop do 2 | @moduledoc """ 3 | Delete the discord channels used by DiscoLog if they exist. 4 | """ 5 | use Mix.Task 6 | 7 | alias DiscoLog.Config 8 | alias DiscoLog.Discord.API 9 | 10 | @impl Mix.Task 11 | def run(_args) do 12 | # Ensure req is started 13 | {:ok, _} = Application.ensure_all_started(:req) 14 | 15 | config = Config.read!() 16 | 17 | for channel_id <- [ 18 | config.category_id, 19 | config.occurrences_channel_id, 20 | config.info_channel_id, 21 | config.error_channel_id 22 | ] do 23 | API.delete_channel(config.discord_client, channel_id) 24 | end 25 | 26 | Mix.shell().info("Discord channels for DiscoLog were deleted successfully!") 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /.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 third-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 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | disco_log-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | 28 | # LSP 29 | .elixir_ls/ 30 | .elixir-tools 31 | 32 | # Config 33 | /config/dev.exs 34 | 35 | # Database 36 | /dev.db* -------------------------------------------------------------------------------- /config/dev.examples.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :disco_log, 4 | otp_app: :disco_log, 5 | token: "", 6 | guild_id: "", 7 | category_id: "", 8 | occurrences_channel_id: "", 9 | info_channel_id: "", 10 | error_channel_id: "", 11 | enable_logger: true, 12 | instrument_oban: true, 13 | metadata: [:extra], 14 | enable_go_to_repo: true, 15 | repo_url: "https://github.com/mrdotb/disco-log/blob", 16 | # a real git sha is better but for testing purposes you can use a branch 17 | git_sha: "main" 18 | 19 | config :logger, 20 | # backends: [], 21 | # Usefull when debugging logger 22 | backends: [:console], 23 | compile_time_purge_matching: [ 24 | # Usefull for debugging purposes when doing real discord request and it's too verbose 25 | # [module: DiscoLog.Discord.Client, level_lower_than: :info] 26 | ], 27 | truncate: :infinity 28 | -------------------------------------------------------------------------------- /lib/mix/tasks/disco_log.cleanup.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.DiscoLog.Cleanup do 2 | @moduledoc """ 3 | Delete all threads and messages from channels. 4 | """ 5 | use Mix.Task 6 | 7 | alias DiscoLog.Config 8 | alias DiscoLog.Discord 9 | 10 | @impl Mix.Task 11 | def run(_args) do 12 | # Ensure req is started 13 | {:ok, _} = Application.ensure_all_started(:req) 14 | config = Config.read!() 15 | 16 | # Delete all threads from occurrences channel 17 | Discord.delete_threads(config.discord_client, config.guild_id, config.occurrences_channel_id) 18 | 19 | # Delete all messages from info and error channels 20 | for channel_id <- [config.info_channel_id, config.error_channel_id] do 21 | Discord.delete_channel_messages(config.discord_client, channel_id) 22 | end 23 | 24 | Mix.shell().info("Messages from DiscoLog Discord channels were deleted successfully!") 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/disco_log/application.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscoLog.Application do 2 | @moduledoc false 3 | 4 | use Application 5 | 6 | alias DiscoLog.Config 7 | alias DiscoLog.Integrations 8 | alias DiscoLog.LoggerHandler 9 | 10 | def start(_type, _args) do 11 | config = Config.read!() 12 | 13 | if config.enable do 14 | start_integration(config) 15 | 16 | children = [ 17 | {DiscoLog.Supervisor, config} 18 | ] 19 | 20 | Supervisor.start_link(children, strategy: :one_for_one, name: __MODULE__) 21 | else 22 | Supervisor.start_link([], strategy: :one_for_one, name: __MODULE__) 23 | end 24 | end 25 | 26 | defp start_integration(config) do 27 | if config.enable_logger do 28 | :logger.add_handler(__MODULE__, LoggerHandler, %{config: config}) 29 | end 30 | 31 | if config.instrument_oban do 32 | Integrations.Oban.attach(config) 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/disco_log/config_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DiscoLog.ConfigTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias DiscoLog.Config 5 | 6 | @example_config [ 7 | otp_app: :logger, 8 | token: "discord_token", 9 | guild_id: "guild_id", 10 | category_id: "category_id", 11 | occurrences_channel_id: "occurrences_channel_id", 12 | info_channel_id: "info_channel_id", 13 | error_channel_id: "error_channel_id", 14 | enable: true, 15 | enable_logger: true, 16 | instrument_oban: true, 17 | metadata: [:foo], 18 | excluded_domains: [:cowboy], 19 | go_to_repo_top_modules: ["DemoWeb"] 20 | ] 21 | 22 | describe inspect(&Config.validate/1) do 23 | test "returns map" do 24 | assert %{} = Config.validate!(@example_config) 25 | end 26 | 27 | test "adds discord_client" do 28 | assert %{discord_client: %DiscoLog.Discord.API{}} = Config.validate!(@example_config) 29 | end 30 | 31 | test "adds in_app_modules" do 32 | assert %{in_app_modules: modules} = Config.validate!(@example_config) 33 | assert Logger in modules 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Baptiste Chaleil 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 | -------------------------------------------------------------------------------- /lib/disco_log/discord/api/client.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscoLog.Discord.API.Client do 2 | @moduledoc """ 3 | Default `DiscoLog.Discord.API` implementation. 4 | """ 5 | @behaviour DiscoLog.Discord.API 6 | 7 | @version DiscoLog.MixProject.project()[:version] 8 | 9 | @impl DiscoLog.Discord.API 10 | def client(token) do 11 | client = 12 | Req.new( 13 | base_url: "https://discord.com/api/v10", 14 | headers: [ 15 | {"User-Agent", "DiscoLog (https://github.com/mrdotb/disco-log, #{@version}"} 16 | ], 17 | auth: "Bot #{token}" 18 | ) 19 | 20 | %DiscoLog.Discord.API{client: client, module: __MODULE__} 21 | end 22 | 23 | @impl DiscoLog.Discord.API 24 | def request(client, method, url, opts) do 25 | client 26 | |> Req.merge( 27 | method: method, 28 | url: url 29 | ) 30 | |> Req.merge(opts) 31 | |> then(fn request -> 32 | request 33 | |> Req.Request.fetch_option(:form_multipart) 34 | |> case do 35 | {:ok, fields} -> 36 | Req.merge(request, 37 | form_multipart: Keyword.update!(fields, :payload_json, &Jason.encode_to_iodata!/1) 38 | ) 39 | 40 | :error -> 41 | request 42 | end 43 | end) 44 | |> Req.request() 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/disco_log/context.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscoLog.Context do 2 | @moduledoc """ 3 | Context is a map of any terms that DiscoLog attaches to reported occurrences. 4 | The context is stored in [logger metadata](`m:Logger#module-metadata`). 5 | 6 | > #### Using context {: .tip} 7 | > 8 | > `DiscoLog.Context` is mostly used by DiscoLog itself, but you might find it 9 | > useful if you want to add metadata to occurrences and not normal log 10 | > messages. Context is always exported, regardless of the `:metadata` configuration 11 | > option. 12 | """ 13 | 14 | @type t() :: map() 15 | 16 | @logger_metadata_key :__disco_log__ 17 | 18 | def __logger_metadata_key__ do 19 | @logger_metadata_key 20 | end 21 | 22 | @doc """ 23 | Set context for the current process. 24 | """ 25 | def set(key, value) do 26 | disco_log_metadata = 27 | case :logger.get_process_metadata() do 28 | %{@logger_metadata_key => context} -> Map.put(context, key, value) 29 | _ -> %{key => value} 30 | end 31 | 32 | :logger.update_process_metadata(%{@logger_metadata_key => disco_log_metadata}) 33 | end 34 | 35 | @doc """ 36 | Obtain the context of the current process. 37 | """ 38 | def get do 39 | case :logger.get_process_metadata() do 40 | %{@logger_metadata_key => config} -> config 41 | %{} -> %{} 42 | :undefined -> %{} 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /test/disco_log/context_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DiscoLog.ContextTest do 2 | use DiscoLog.Test.Case, async: true 3 | 4 | alias DiscoLog.Context 5 | 6 | describe inspect(&Context.set/2) do 7 | test "puts values under special key" do 8 | assert :logger.get_process_metadata() == :undefined 9 | 10 | Context.set(:foo, "bar") 11 | 12 | assert :logger.get_process_metadata() == %{ 13 | __disco_log__: %{foo: "bar"} 14 | } 15 | end 16 | 17 | test "can be set multiple times" do 18 | Context.set(:foo, "bar") 19 | Context.set(:bar, "baz") 20 | 21 | assert :logger.get_process_metadata() == %{ 22 | __disco_log__: %{foo: "bar", bar: "baz"} 23 | } 24 | end 25 | 26 | test "completely overwrites key if it exists" do 27 | Context.set(:foo, %{bar: "baz"}) 28 | Context.set(:foo, %{hello: "world"}) 29 | 30 | assert :logger.get_process_metadata() == %{ 31 | __disco_log__: %{foo: %{hello: "world"}} 32 | } 33 | end 34 | end 35 | 36 | describe inspect(&Context.get/1) do 37 | test "empty map if undefined" do 38 | assert Context.get() == %{} 39 | end 40 | 41 | test "returns previously set values" do 42 | Context.set(:foo, "bar") 43 | Context.set(:hello, "world") 44 | 45 | assert Context.get() == %{foo: "bar", hello: "world"} 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 📜 DiscoLog 2 | 3 | GitHub CI 4 | Latest release 5 | View documentation 6 | 7 | > Use Discord as a logging service and error tracking solution 8 | 9 | [video demo](https://youtu.be/gYFN15aHP6o) 10 | 11 | [Disco Log](https://discord.gg/ReqNqU7Nde) 12 | 13 | Demo 14 | 15 | ## Configuration 16 | 17 | Take a look at the [Getting Started guide](/guides/getting-started.md) 18 | 19 | ## Development 20 | 21 | You will need a Discord server and a bot to develop on DiscoLog. 22 | 23 | ```bash 24 | cp config/dev.examples.exs config/dev.exs 25 | ``` 26 | 27 | We have a `dev.exs` script that you can use to test DiscoLog locally. 28 | 29 | ## Credits 30 | 31 | Big thanks to the following projects for inspiration and ideas: 32 | - [error-tracker](https://github.com/elixir-error-tracker/error-tracker) 33 | - [sentry-elixir](https://github.com/getsentry/sentry-elixir) 34 | - [appsignal-elixir](https://github.com/appsignal/appsignal-elixir) 35 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Tests 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | env: 13 | MIX_ENV: test 14 | 15 | jobs: 16 | code_quality_and_tests: 17 | runs-on: ubuntu-latest 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | include: 22 | - elixir: 1.18.4 23 | erlang: 27.3.4.2 24 | name: Elixir v${{ matrix.elixir }}, Erlang v${{ matrix.erlang }} 25 | steps: 26 | - uses: actions/checkout@v4 27 | 28 | - uses: erlef/setup-beam@v1 29 | with: 30 | otp-version: ${{ matrix.erlang }} 31 | elixir-version: ${{ matrix.elixir }} 32 | 33 | - name: Retrieve Dependencies Cache 34 | uses: actions/cache@v4 35 | id: mix-cache 36 | with: 37 | path: | 38 | deps 39 | _build 40 | key: ${{ runner.os }}-${{ matrix.erlang }}-${{ matrix.elixir }}-mix-${{ hashFiles('**/mix.lock') }} 41 | 42 | - name: Install Mix Dependencies 43 | run: mix deps.get 44 | 45 | - name: Check unused dependencies 46 | run: mix deps.unlock --check-unused 47 | 48 | - name: Compile dependencies 49 | run: mix deps.compile 50 | 51 | - name: Check format 52 | run: mix format --check-formatted 53 | 54 | - name: Check application compile warnings 55 | run: mix compile --force --warnings-as-errors 56 | 57 | - name: Check Credo warnings 58 | run: mix credo 59 | 60 | - name: Run tests 61 | run: mix test 62 | -------------------------------------------------------------------------------- /test/disco_log/application_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DiscoLog.ApplicationTest do 2 | alias DiscoLog.WebsocketClient 3 | # We test through global handler here which other test may interfere with 4 | use DiscoLog.Test.Case, async: false 5 | 6 | import Mox 7 | 8 | setup_all :set_mox_global 9 | 10 | setup_all do 11 | stub_with(DiscoLog.Discord.API.Mock, DiscoLog.Discord.API.Stub) 12 | stub(DiscoLog.WebsocketClient.Mock, :connect, fn _, _, _ -> {:ok, %WebsocketClient{}} end) 13 | 14 | Application.put_env(:disco_log, :enable, true) 15 | Application.stop(:disco_log) 16 | Application.start(:disco_log) 17 | 18 | on_exit(fn -> 19 | :logger.remove_handler(DiscoLog.Application) 20 | Application.put_env(:disco_log, :enable, false) 21 | Application.stop(:disco_log) 22 | Application.start(:disco_log) 23 | end) 24 | end 25 | 26 | test "starts default supervisor under application name" do 27 | assert [{_id, pid, :supervisor, [DiscoLog.Supervisor]}] = 28 | Supervisor.which_children(Process.whereis(DiscoLog.Application)) 29 | 30 | assert [ 31 | {DiscoLog.Presence, presence_pid, :worker, _}, 32 | {DiscoLog.Storage, storage_pid, :worker, _}, 33 | {DiscoLog.Registry, registry_pid, :supervisor, _} 34 | ] = Supervisor.which_children(pid) 35 | 36 | for pid <- [storage_pid, registry_pid, presence_pid] do 37 | assert :sys.get_status(pid) 38 | end 39 | end 40 | 41 | test "attaches logger handler" do 42 | assert {:ok, %{module: DiscoLog.LoggerHandler}} = 43 | :logger.get_handler_config(DiscoLog.Application) 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /guides/standalone-presence.md: -------------------------------------------------------------------------------- 1 | # Presence Without Logging 2 | 3 | You can use the Disco Log Presence feature independently of logging. This is 4 | especially useful for apps running on a single node, in which case presence can 5 | serve as a simple way to display application status. 6 | 7 | To set up Presence, first make sure you have disabled the default DiscoLog logger: 8 | 9 | ```elixir 10 | config :disco_log, 11 | enable: false 12 | ``` 13 | 14 | Then, start a `DiscoLog.Presence` worker under your app's supervision tree. It's 15 | probably a good idea to only start it in the `prod` environment: 16 | 17 | ```elixir 18 | defmodule MyApp.Application do 19 | use Application 20 | 21 | @impl true 22 | def start(_type, _args) do 23 | children = [ 24 | # Presence should start after workers required by the HTTP client (e.g. Finch pool) 25 | ... 26 | presence(), 27 | ... 28 | MyAppWeb.Endpoint 29 | ] 30 | 31 | opts = [strategy: :one_for_one, name: MyApp.Supervisor] 32 | Supervisor.start_link(children, opts) 33 | end 34 | 35 | if Mix.env() in [:prod] do 36 | defp presence() do 37 | token = Application.fetch_env!(:disco_log, :token) 38 | client = DiscoLog.Discord.API.Client.client(token) 39 | 40 | opts = [ 41 | bot_token: token, 42 | discord_client: client, 43 | presence_status: "I'm online!" 44 | ] 45 | 46 | %{ 47 | id: MyApp.Presence, 48 | start: 49 | {GenServer, :start_link, 50 | [DiscoLog.Presence, {opts, Process.get(:"$callers", [])}, [name: MyApp.Presence]]} 51 | } 52 | end 53 | else 54 | defp presence(), do: %{id: DiscoLog.Presence, start: {Function, :identity, [:ignore]}} 55 | end 56 | end 57 | ``` -------------------------------------------------------------------------------- /lib/disco_log/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscoLog.Supervisor do 2 | @moduledoc """ 3 | Supervisor that manages all processes required for logging. By default, 4 | `DiscoLog` starts it automatically, unless you use [advanced configuration](guides/advanced-configuration.md) 5 | """ 6 | use Supervisor 7 | 8 | alias DiscoLog.Storage 9 | alias DiscoLog.Presence 10 | 11 | def child_spec(config) do 12 | Supervisor.child_spec( 13 | %{ 14 | id: config.supervisor_name, 15 | start: {__MODULE__, :start_link, [config]}, 16 | type: :supervisor 17 | }, 18 | [] 19 | ) 20 | end 21 | 22 | @spec start_link(DiscoLog.Config.t()) :: Supervisor.on_start() 23 | def start_link(config) do 24 | callers = Process.get(:"$callers", []) 25 | Supervisor.start_link(__MODULE__, {config, callers}, name: config.supervisor_name) 26 | end 27 | 28 | @impl Supervisor 29 | def init({config, callers}) do 30 | children = 31 | [ 32 | {Registry, keys: :unique, name: DiscoLog.Registry.registry_name(config.supervisor_name)}, 33 | {Storage, 34 | supervisor_name: config.supervisor_name, 35 | discord_client: config.discord_client, 36 | guild_id: config.guild_id, 37 | occurrences_channel_id: config.occurrences_channel_id} 38 | ] ++ maybe_presence(config) 39 | 40 | Process.put(:"$callers", callers) 41 | 42 | Supervisor.init(children, strategy: :one_for_one) 43 | end 44 | 45 | defp maybe_presence(config) do 46 | if config[:enable_presence] do 47 | [ 48 | {Presence, 49 | supervisor_name: config.supervisor_name, 50 | bot_token: config.token, 51 | discord_client: config.discord_client, 52 | presence_status: config.presence_status} 53 | ] 54 | else 55 | [] 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /test/disco_log_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DiscoLogTest do 2 | use DiscoLog.Test.Case, async: true 3 | 4 | import Mox 5 | 6 | alias DiscoLog.Discord.API 7 | 8 | @moduletag config: [supervisor_name: __MODULE__] 9 | 10 | setup :setup_supervisor 11 | setup :verify_on_exit! 12 | 13 | describe inspect(&DiscoLog.report/5) do 14 | test "sends error to occurrences channel", %{config: config} do 15 | pid = self() 16 | 17 | expect(API.Mock, :request, fn client, method, url, opts -> 18 | send(pid, opts) 19 | API.Stub.request(client, method, url, opts) 20 | end) 21 | 22 | try do 23 | raise "Foo" 24 | catch 25 | kind, reason -> 26 | DiscoLog.report(kind, reason, __STACKTRACE__, %{}, config) 27 | end 28 | 29 | assert_receive path_params: [channel_id: "occurrences_channel_id"], 30 | form_multipart: [ 31 | payload_json: %{name: <<_::binary-size(7)>> <> "** (RuntimeError) Foo"} 32 | ] 33 | end 34 | 35 | test "uses context for metadata and tags", %{config: config} do 36 | pid = self() 37 | 38 | API.Mock 39 | |> expect(:request, fn client, method, url, opts -> 40 | send(pid, opts) 41 | API.Stub.request(client, method, url, opts) 42 | end) 43 | 44 | DiscoLog.Context.set(:hello, "world") 45 | DiscoLog.Context.set(:live_view, "foo") 46 | 47 | try do 48 | raise "Foo" 49 | catch 50 | kind, reason -> 51 | DiscoLog.report(kind, reason, __STACKTRACE__, %{}, config) 52 | end 53 | 54 | assert_receive path_params: [channel_id: "occurrences_channel_id"], 55 | form_multipart: [ 56 | payload_json: %{ 57 | applied_tags: ["stub_live_view_tag_id"], 58 | message: %{ 59 | components: [ 60 | _, 61 | _, 62 | %{content: "```elixir\n%{hello: \"world\", live_view: \"foo\"}\n```"} 63 | ] 64 | } 65 | } 66 | ] 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/disco_log/discord.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscoLog.Discord do 2 | @moduledoc false 3 | 4 | alias DiscoLog.Discord.API 5 | alias DiscoLog.Discord.Prepare 6 | 7 | def list_occurrence_threads(discord_client, guild_id, occurrences_channel_id) do 8 | case API.list_active_threads(discord_client, guild_id) do 9 | {:ok, %{status: 200, body: %{"threads" => threads}}} -> 10 | active_threads = 11 | threads 12 | |> Enum.filter(&(&1["parent_id"] == occurrences_channel_id)) 13 | |> Enum.map(&{Prepare.fingerprint_from_thread_name(&1["name"]), &1["id"]}) 14 | |> Enum.reject(fn {fingerprint, _} -> is_nil(fingerprint) end) 15 | |> Map.new() 16 | 17 | {:ok, active_threads} 18 | 19 | {:ok, response} -> 20 | {:error, response} 21 | 22 | other -> 23 | other 24 | end 25 | end 26 | 27 | def list_occurrence_tags(discord_client, occurrences_channel_id) do 28 | case API.get_channel(discord_client, occurrences_channel_id) do 29 | {:ok, %{status: 200, body: %{"available_tags" => available_tags}}} -> 30 | tags = for %{"id" => id, "name" => name} <- available_tags, into: %{}, do: {name, id} 31 | {:ok, tags} 32 | 33 | {:ok, response} -> 34 | {:error, response} 35 | 36 | error -> 37 | error 38 | end 39 | end 40 | 41 | def get_gateway(discord_client) do 42 | case API.get_gateway(discord_client) do 43 | {:ok, %{status: 200, body: %{"url" => raw_uri}}} -> URI.new(raw_uri) 44 | {:ok, response} -> {:error, response} 45 | error -> error 46 | end 47 | end 48 | 49 | def delete_threads(discord_client, guild_id, channel_id) do 50 | {:ok, %{status: 200, body: %{"threads" => threads}}} = 51 | API.list_active_threads(discord_client, guild_id) 52 | 53 | threads 54 | |> Enum.filter(&(&1["parent_id"] == channel_id)) 55 | |> Enum.map(fn %{"id" => thread_id} -> 56 | {:ok, %{status: 200}} = API.delete_thread(discord_client, thread_id) 57 | end) 58 | end 59 | 60 | def delete_channel_messages(discord_client, channel_id) do 61 | {:ok, %{status: 200, body: messages}} = API.get_channel_messages(discord_client, channel_id) 62 | 63 | for %{"id" => message_id} <- messages do 64 | API.delete_message(discord_client, channel_id, message_id) 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/disco_log/websocket_client/impl.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Mint.WebSocket) do 2 | defmodule DiscoLog.WebsocketClient.Impl do 3 | @moduledoc false 4 | alias DiscoLog.WebsocketClient 5 | 6 | @behaviour WebsocketClient 7 | 8 | @impl WebsocketClient 9 | def connect(host, port, path) do 10 | with {:ok, conn} <- Mint.HTTP.connect(:https, host, port, protocols: [:http1]), 11 | {:ok, conn, ref} <- Mint.WebSocket.upgrade(:wss, conn, path, []) do 12 | {:ok, %WebsocketClient{conn: conn, ref: ref, state: :open}} 13 | end 14 | end 15 | 16 | @impl WebsocketClient 17 | def boil_message_to_frames(%WebsocketClient{conn: conn} = client, message) do 18 | with {:ok, conn, tcp_message} <- Mint.WebSocket.stream(conn, message) do 19 | handle_tcp_message(%{client | conn: conn}, tcp_message) 20 | end 21 | end 22 | 23 | @impl WebsocketClient 24 | def send_frame(%WebsocketClient{conn: conn, websocket: websocket, ref: ref} = client, frame) do 25 | with {:ok, websocket, data} <- 26 | Mint.WebSocket.encode(websocket, frame), 27 | {:ok, conn} <- Mint.WebSocket.stream_request_body(conn, ref, data) do 28 | {:ok, %{client | conn: conn, websocket: websocket}} 29 | end 30 | end 31 | 32 | @impl WebsocketClient 33 | def close(%WebsocketClient{conn: conn}), do: Mint.HTTP.close(conn) 34 | 35 | defp handle_tcp_message(%WebsocketClient{conn: conn, ref: ref} = client, [ 36 | {:status, ref, status}, 37 | {:headers, ref, headers} | frames 38 | ]) do 39 | with {:ok, conn, websocket} <- Mint.WebSocket.new(conn, ref, status, headers) do 40 | client = %{client | conn: conn, websocket: websocket} 41 | handle_tcp_message(client, frames) 42 | end 43 | end 44 | 45 | defp handle_tcp_message(%WebsocketClient{ref: ref} = client, frames) do 46 | for {:data, ^ref, data} <- frames, reduce: {:ok, client, []} do 47 | {:ok, client, frames} -> 48 | case Mint.WebSocket.decode(client.websocket, data) do 49 | {:ok, websocket, new_frames} -> 50 | {:ok, %{client | websocket: websocket}, frames ++ new_frames} 51 | 52 | {:error, websocket, reason} -> 53 | {:error, %{client | websocket: websocket}, reason} 54 | end 55 | 56 | {:error, client, reason} -> 57 | {:error, client, reason} 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /test/support/case.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscoLog.Test.Case do 2 | @moduledoc false 3 | use ExUnit.CaseTemplate 4 | 5 | using do 6 | quote do 7 | import DiscoLog.Test.Case 8 | 9 | setup tags do 10 | if tags[:async] do 11 | Mox.stub_with(DiscoLog.Discord.API.Mock, DiscoLog.Discord.API.Stub) 12 | end 13 | 14 | :ok 15 | end 16 | end 17 | end 18 | 19 | @doc """ 20 | Reports the error produced by the given function. 21 | """ 22 | def report_error(fun) do 23 | occurrence = 24 | try do 25 | fun.() 26 | catch 27 | kind, reason -> 28 | DiscoLog.report(kind, reason, __STACKTRACE__) 29 | end 30 | 31 | occurrence 32 | end 33 | 34 | @doc """ 35 | Asserts that the given telemetry event is attached to the given module. 36 | """ 37 | def event_attached?(event, module) do 38 | event 39 | |> :telemetry.list_handlers() 40 | |> Enum.any?(fn %{id: id} -> id == module end) 41 | end 42 | 43 | @doc """ 44 | Starts DiscoLog supervisor under test supervision tree and makes sure DiscoLog.Storage async init completes successfully with a stubbed response. 45 | """ 46 | def setup_supervisor(context) do 47 | config = 48 | [ 49 | otp_app: :foo, 50 | token: "mytoken", 51 | guild_id: "guild_id", 52 | category_id: "category_id", 53 | occurrences_channel_id: "occurrences_channel_id", 54 | info_channel_id: "info_channel_id", 55 | error_channel_id: "error_channel_id", 56 | discord_client_module: DiscoLog.Discord.API.Mock, 57 | enable_presence: false 58 | ] 59 | |> Keyword.merge(Map.fetch!(context, :config)) 60 | |> DiscoLog.Config.validate!() 61 | 62 | Mox.stub(DiscoLog.WebsocketClient.Mock, :connect, fn _, _, _ -> 63 | {:ok, struct(DiscoLog.WebsocketClient, %{})} 64 | end) 65 | 66 | {:ok, _pid} = start_supervised({DiscoLog.Supervisor, config}) 67 | 68 | # Wait until async init is completed 69 | [{storage_pid, _}] = 70 | Registry.lookup(DiscoLog.Registry.registry_name(config.supervisor_name), DiscoLog.Storage) 71 | 72 | :sys.get_status(storage_pid) 73 | 74 | %{config: config} 75 | end 76 | 77 | @doc """ 78 | Attach individual test logger handler with ownership filter 79 | """ 80 | def setup_logger_handler(%{test: test, config: config} = context) do 81 | big_config_override = Map.take(context, [:handle_otp_reports, :handle_sasl_reports]) 82 | 83 | {context, on_exit} = 84 | LoggerHandlerKit.Arrange.add_handler( 85 | test, 86 | DiscoLog.LoggerHandler, 87 | config, 88 | big_config_override 89 | ) 90 | 91 | on_exit(on_exit) 92 | context 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /test/disco_log/integrations/oban_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DiscoLog.ObanTest do 2 | use DiscoLog.Test.Case, async: true 3 | 4 | import Mox 5 | 6 | @moduletag config: [supervisor_name: __MODULE__] 7 | 8 | alias DiscoLog.Integrations 9 | alias DiscoLog.Discord.API 10 | 11 | setup :setup_supervisor 12 | setup :attach_oban 13 | setup :verify_on_exit! 14 | 15 | test "attaches to Oban events" do 16 | assert event_attached?([:oban, :job, :exception], DiscoLog.Integrations.Oban) 17 | end 18 | 19 | test "send the exception with the oban context" do 20 | pid = self() 21 | 22 | expect(API.Mock, :request, fn client, method, url, opts -> 23 | send(pid, opts) 24 | API.Stub.request(client, method, url, opts) 25 | end) 26 | 27 | execute_job_exception() 28 | 29 | assert_receive [ 30 | {:path_params, [channel_id: "occurrences_channel_id"]}, 31 | {:form_multipart, [payload_json: body]} 32 | ] 33 | 34 | assert %{ 35 | name: <<_::binary-size(7)>> <> "** (RuntimeError) Exception!", 36 | applied_tags: ["stub_oban_tag_id"], 37 | message: %{ 38 | components: [ 39 | %{ 40 | content: "**Kind:** `RuntimeError`\n**Reason:** `Exception!`" <> _ 41 | }, 42 | %{ 43 | content: "```\n** (RuntimeError) Exception!\n" <> _ 44 | }, 45 | %{ 46 | type: 10, 47 | content: 48 | "```elixir\n%{\n \"oban\" => %{\n \"args\" => %{foo: \"bar\"},\n \"attempt\" => 1,\n \"id\" => 123,\n \"priority\" => 1,\n \"queue\" => :default,\n \"state\" => :failure,\n \"worker\" => :\"Test.Worker\"\n }\n}\n```" 49 | } 50 | ] 51 | } 52 | } = body 53 | end 54 | 55 | defp sample_metadata do 56 | %{ 57 | job: %{ 58 | args: %{foo: "bar"}, 59 | attempt: 1, 60 | id: 123, 61 | priority: 1, 62 | queue: :default, 63 | worker: :"Test.Worker" 64 | } 65 | } 66 | end 67 | 68 | defp execute_job_exception(additional_metadata \\ %{}) do 69 | raise "Exception!" 70 | catch 71 | kind, reason -> 72 | metadata = 73 | Map.merge(sample_metadata(), %{ 74 | reason: reason, 75 | kind: kind, 76 | stacktrace: __STACKTRACE__ 77 | }) 78 | 79 | :telemetry.execute( 80 | [:oban, :job, :exception], 81 | %{duration: 123 * 1_000_000}, 82 | Map.merge(metadata, additional_metadata) 83 | ) 84 | end 85 | 86 | defp attach_oban(context) do 87 | Integrations.Oban.attach(context.config, true) 88 | context 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /lib/disco_log/logger_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscoLog.LoggerHandler do 2 | @moduledoc """ 3 | The heart of DiscoLog. 4 | 5 | Logger Handler expects `t:DiscoLog.Config.t/0` as its configuration. 6 | 7 | See [advanced configuration guide](advanced-configuration.md) to learn how to 8 | start logger handler directly. 9 | """ 10 | @behaviour :logger_handler 11 | 12 | alias DiscoLog.Error 13 | alias DiscoLog.Context 14 | 15 | @impl :logger_handler 16 | def log(%{level: log_level, meta: log_meta} = log_event, %{ 17 | config: config 18 | }) do 19 | cond do 20 | excluded_level?(log_level) -> 21 | :ok 22 | 23 | excluded_domain?(Map.get(log_meta, :domain, []), config.excluded_domains) -> 24 | :ok 25 | 26 | log_level == :info and Map.get(log_meta, :application) == :phoenix -> 27 | :ok 28 | 29 | true -> 30 | metadata = Map.take(log_meta, config.metadata) 31 | message = message(log_event) 32 | 33 | case log_event do 34 | %{level: :info} -> 35 | DiscoLog.log_info(message, metadata, config) 36 | 37 | %{meta: %{crash_reason: crash_reason}} -> 38 | error = 39 | Error.new(crash_reason) 40 | |> Error.enrich(config) 41 | |> Map.put(:display_full_error, message) 42 | 43 | context = 44 | Context.get() 45 | |> enrich_context(log_event) 46 | |> Map.merge(metadata) 47 | 48 | DiscoLog.log_occurrence( 49 | error, 50 | context, 51 | config 52 | ) 53 | 54 | _ -> 55 | DiscoLog.log_error(message, metadata, config) 56 | end 57 | end 58 | end 59 | 60 | defp excluded_level?(log_level) do 61 | Logger.compare_levels(log_level, :error) == :lt and log_level != :info 62 | end 63 | 64 | defp excluded_domain?(logged_domains, excluded_domains) do 65 | Enum.any?(logged_domains, &(&1 in excluded_domains)) 66 | end 67 | 68 | defp message(%{msg: {:string, chardata}}), do: IO.iodata_to_binary(chardata) 69 | 70 | defp message(%{msg: {:report, report}, meta: %{report_cb: report_cb}}) 71 | when is_function(report_cb, 1) do 72 | {io_format, data} = report_cb.(report) 73 | io_format |> :io_lib.format(data) |> IO.iodata_to_binary() 74 | end 75 | 76 | defp message(%{msg: {:report, report}}), do: inspect(report, limit: :infinity, pretty: true) 77 | 78 | defp message(%{msg: {io_format, data}}), 79 | do: io_format |> :io_lib.format(data) |> IO.iodata_to_binary() 80 | 81 | defp enrich_context(context, %{meta: %{conn: conn}}) when is_struct(conn, Plug.Conn) do 82 | Map.put_new(context, "plug", DiscoLog.Integrations.Plug.conn_context(conn)) 83 | end 84 | 85 | defp enrich_context(context, _log_event), do: context 86 | end 87 | -------------------------------------------------------------------------------- /lib/disco_log/websocket_client.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscoLog.WebsocketClient do 2 | @moduledoc false 3 | @compile {:no_warn_undefined, __MODULE__.Impl} 4 | @adapter Application.compile_env(:disco_log, :websocket_adapter, __MODULE__.Impl) 5 | 6 | defstruct [:conn, :websocket, :ref, :state] 7 | 8 | @type t :: %__MODULE__{ 9 | conn: Mint.HTTP.t(), 10 | ref: Mint.Types.request_ref(), 11 | websocket: Mint.WebSocket.t(), 12 | state: :open | :closing 13 | } 14 | 15 | @callback connect(host :: Mint.Types.address(), port :: :inet.port_number(), path :: String.t()) :: 16 | {:ok, t()} | {:error, Mint.WebSocket.error()} 17 | defdelegate connect(host, port, path), to: @adapter 18 | 19 | @callback boil_message_to_frames(client :: t(), message :: any()) :: 20 | {:ok, t(), [Mint.WebSocket.frame() | {:error, term()}]} 21 | | {:error, t(), any()} 22 | | {:error, Mint.HTTP.t(), Mint.Types.error(), [Mint.Types.response()]} 23 | | {:error, Mint.HTTP.t(), Mint.WebSocket.error()} 24 | | :unknown 25 | defdelegate boil_message_to_frames(client, message), to: @adapter 26 | 27 | @callback send_frame(client :: t(), frame :: Mint.WebSocket.frame()) :: 28 | {:ok, t()} 29 | | {:error, Mint.WebSocket.t(), any()} 30 | | {:error, Mint.HTTP.t(), Mint.Types.error()} 31 | defdelegate send_frame(client, event), to: @adapter 32 | 33 | @callback close(client :: t()) :: {:ok, Mint.HTTP.t()} 34 | defdelegate close(client), to: @adapter 35 | 36 | def send_event(client, event), do: send_frame(client, {:text, Jason.encode!(event)}) 37 | 38 | def begin_disconnect(client, code, reason) do 39 | with {:ok, client} <- send_frame(client, {:close, code, reason}) do 40 | {:ok, %{client | state: :closing}} 41 | end 42 | end 43 | 44 | def handle_message(client, message) do 45 | with {:ok, client, frames} <- boil_message_to_frames(client, message) do 46 | if frame = Enum.find(frames, &match?({:close, _code, _reason}, &1)) do 47 | handle_close_frame(client, frame) 48 | else 49 | {:ok, client, Enum.map(frames, &handle_frame/1)} 50 | end 51 | end 52 | end 53 | 54 | defp handle_frame({:text, text}), do: Jason.decode!(text) 55 | 56 | defp handle_close_frame(%__MODULE__{state: :closing} = client, {:close, _code, _reason}) do 57 | with {:ok, _conn} <- close(client) do 58 | {:ok, :closed} 59 | end 60 | end 61 | 62 | defp handle_close_frame(client, {:close, _code, reason}) do 63 | with {:ok, _client} <- ack_server_closure(client) do 64 | {:ok, :closed_by_server, reason} 65 | end 66 | end 67 | 68 | defp ack_server_closure(client) do 69 | with {:ok, client} <- send_frame(client, :close), 70 | {:ok, conn} <- close(client) do 71 | {:ok, %{client | conn: conn, websocket: nil}} 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /guides/advanced-configuration.md: -------------------------------------------------------------------------------- 1 | # Advanced Configuration 2 | 3 | By default, `DiscoLog` runs a supervisor with all necessary processes under its 4 | own application. However, you can run everything manually in case you want to log 5 | to multiple servers, take advantage of logger filters and other features, or 6 | simply have better control over how logging is run within your application. 7 | 8 | > #### Mix Tasks {: .warning} 9 | > 10 | > Mix tasks that come with `DiscoLog` always read from the default configuration 11 | and won't work with advanced setup. 12 | 13 | First, disable the `DiscoLog` default application in your `config/config.exs`: 14 | 15 | ```elixir 16 | config :disco_log, enable: false 17 | ``` 18 | 19 | Then, define the `DiscoLog` configuration the way you want it. For example, in 20 | the config under your application's key. Let's say you want to log to not one 21 | but 2 Discord servers at once: 22 | 23 | ```elixir 24 | config :my_app, DiscoLog, 25 | shared: [ 26 | otp_app: :my_app, 27 | metadata: [:extra] 28 | ], 29 | server_a: [ 30 | guild_id: "1234567891011121314", 31 | token: "server_A_secret_token", 32 | category_id: "1234567891011121314", 33 | occurrences_channel_id: "1234567891011121314", 34 | info_channel_id: "1234567891011121314", 35 | error_channel_id: "1234567891011121314", 36 | supervisor_name: MyApp.DiscoLog.ServerA 37 | ], 38 | server_b: [ 39 | guild_id: "9876543210123456789", 40 | token: "server_B_secret_token", 41 | category_id: "9876543210123456789", 42 | occurrences_channel_id: "9876543210123456789", 43 | info_channel_id: "9876543210123456789", 44 | error_channel_id: "9876543210123456789", 45 | supervisor_name: MyApp.DiscoLog.ServerB 46 | ] 47 | ``` 48 | 49 | Finally, at startup, you'll need to start as many `DiscoLog.Supervisor` as you 50 | need and attach the logger handlers. They share the same configuration, which 51 | you can validate with `DiscoLog.Config.validate!/1`. Note the `supervisor_name` 52 | configuration option. This is what tells the logger handler which supervisor 53 | process it should use. 54 | 55 | ```elixir 56 | defmodule MyApp.Application do 57 | use Application 58 | 59 | def start(_type, _args) do 60 | [ 61 | shared: shared, 62 | server_a: config_a, 63 | server_b: config_b 64 | ] = Application.fetch_env!(:my_app, DiscoLog) 65 | 66 | config_a = DiscoLog.Config.validate!(shared ++ config_a) 67 | config_b = DiscoLog.Config.validate!(shared ++ config_b) 68 | 69 | :logger.add_handler(:disco_server_a, DiscoLog.LoggerHandler, %{config: config_a}) 70 | :logger.add_handler(:disco_server_b, DiscoLog.LoggerHandler, %{config: config_b}) 71 | 72 | children = [ 73 | {DiscoLog.Supervisor, config_a}, 74 | {DiscoLog.Supervisor, config_b}, 75 | ] 76 | 77 | Supervisor.start_link(children, strategy: :one_for_one) 78 | end 79 | ``` 80 | 81 | That's it! From here, you can build upon a highly flexible Elixir logging stack 82 | with `DiscoLog`! -------------------------------------------------------------------------------- /lib/mix/tasks/disco_log.create.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.DiscoLog.Create do 2 | @moduledoc """ 3 | Creates the necessary discord channels for DiscoLog. 4 | """ 5 | use Mix.Task 6 | 7 | alias DiscoLog.Config 8 | alias DiscoLog.Discord.API 9 | 10 | @default_tags Enum.map(~w(plug live_view oban), &%{name: &1}) 11 | 12 | @impl Mix.Task 13 | def run(_args) do 14 | # Ensure req is started 15 | {:ok, _} = Application.ensure_all_started(:req) 16 | config = Config.read!() 17 | 18 | with {:ok, %{status: 200, body: channels}} <- 19 | API.list_channels(config.discord_client, config.guild_id), 20 | {:ok, %{body: %{"id" => category_id}}} <- 21 | fetch_or_create_channel( 22 | config.discord_client, 23 | channels, 24 | config.guild_id, 25 | 4, 26 | "disco-log", 27 | nil 28 | ), 29 | {:ok, %{body: occurrence}} <- 30 | fetch_or_create_channel( 31 | config.discord_client, 32 | channels, 33 | config.guild_id, 34 | 15, 35 | "occurrences", 36 | category_id, 37 | %{available_tags: @default_tags} 38 | ), 39 | {:ok, %{body: info}} <- 40 | fetch_or_create_channel( 41 | config.discord_client, 42 | channels, 43 | config.guild_id, 44 | 0, 45 | "info", 46 | category_id 47 | ), 48 | {:ok, %{body: error}} <- 49 | fetch_or_create_channel( 50 | config.discord_client, 51 | channels, 52 | config.guild_id, 53 | 0, 54 | "error", 55 | category_id 56 | ) do 57 | Mix.shell().info("Discord channels for DiscoLog were created successfully!") 58 | Mix.shell().info("Complete the configuration by adding the following to your config") 59 | 60 | Mix.shell().info(""" 61 | config :disco_log, 62 | otp_app: :app_name, 63 | token: "#{config.token}", 64 | guild_id: "#{config.guild_id}", 65 | category_id: "#{category_id}", 66 | occurrences_channel_id: "#{occurrence["id"]}", 67 | info_channel_id: "#{info["id"]}", 68 | error_channel_id: "#{error["id"]}" 69 | """) 70 | end 71 | end 72 | 73 | defp fetch_or_create_channel( 74 | discord_client, 75 | channels, 76 | guild_id, 77 | type, 78 | name, 79 | parent_id, 80 | extra \\ %{} 81 | ) do 82 | channels 83 | |> Enum.find(&match?(%{"type" => ^type, "name" => ^name, "parent_id" => ^parent_id}, &1)) 84 | |> case do 85 | nil -> 86 | API.create_channel( 87 | discord_client, 88 | guild_id, 89 | Map.merge(%{parent_id: parent_id, name: name, type: type}, extra) 90 | ) 91 | 92 | channel when is_map(channel) -> 93 | {:ok, %{body: channel}} 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/disco_log/integrations/plug.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscoLog.Integrations.Plug do 2 | @moduledoc """ 3 | Integration with Plug applications. 4 | 5 | ## How to use it 6 | 7 | ### Plug applications 8 | 9 | The way to use this integration is by adding it to either your `Plug.Builder` 10 | or `Plug.Router`: 11 | 12 | ```elixir 13 | defmodule MyApp.Router do 14 | use Plug.Router 15 | 16 | plug DiscoLog.Integrations.Plug 17 | plug :match 18 | plug :dispatch 19 | 20 | ... 21 | end 22 | ``` 23 | 24 | ### Phoenix applications 25 | 26 | Drop this plug somewhere in your `endpoint.ex`: 27 | 28 | ```elixir 29 | defmodule MyApp.Endpoint do 30 | ... 31 | 32 | plug DiscoLog.Integrations.Plug 33 | 34 | ... 35 | end 36 | ``` 37 | 38 | ### Default context 39 | 40 | By default we store some context for you on errors generated during a Plug 41 | request: 42 | 43 | * `request.host`: the `conn.host` value. 44 | 45 | * `request.ip`: the IP address that initiated the request. It includes parsing 46 | proxy headers 47 | 48 | * `request.method`: the HTTP method of the request. 49 | 50 | * `request.path`: the path of the request. 51 | 52 | * `request.query`: the query string of the request. 53 | 54 | * `request.params`: parsed params of the request (only available if they have 55 | been fetched and parsed as part of the Plug pipeline). 56 | 57 | * `request.headers`: headers received on the request. All headers are included 58 | by default except for the `Cookie` ones, as they may include large and 59 | sensitive content like sessions. 60 | 61 | """ 62 | alias DiscoLog.Context 63 | 64 | def init(opts), do: opts 65 | 66 | def call(conn, _opts) do 67 | set_context(conn) 68 | conn 69 | end 70 | 71 | @doc false 72 | def report_error(conn, reason, stack, config) do 73 | DiscoLog.report(reason, stack, %{"plug" => conn_context(conn)}, config) 74 | end 75 | 76 | @doc false 77 | def set_context(%Plug.Conn{} = conn) do 78 | context = conn_context(conn) 79 | Context.set("plug", context) 80 | end 81 | 82 | def conn_context(%Plug.Conn{} = conn) do 83 | %{ 84 | "request.host" => conn.host, 85 | "request.path" => conn.request_path, 86 | "request.query" => conn.query_string, 87 | "request.method" => conn.method, 88 | "request.ip" => remote_ip(conn), 89 | "request.headers" => conn.req_headers |> Map.new() |> Map.drop(["cookie"]), 90 | # Depending on the error source, the request params may have not been fetched yet 91 | "request.params" => unless(is_struct(conn.params, Plug.Conn.Unfetched), do: conn.params) 92 | } 93 | end 94 | 95 | defp remote_ip(%Plug.Conn{} = conn) do 96 | remote_ip = 97 | case Plug.Conn.get_req_header(conn, "x-forwarded-for") do 98 | [x_forwarded_for | _] -> 99 | x_forwarded_for |> String.split(",", parts: 2) |> List.first() 100 | 101 | [] -> 102 | case :inet.ntoa(conn.remote_ip) do 103 | {:error, _} -> "" 104 | address -> to_string(address) 105 | end 106 | end 107 | 108 | String.trim(remote_ip) 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule DiscoLog.MixProject do 2 | use Mix.Project 3 | 4 | @source_url "https://github.com/mrdotb/disco-log" 5 | @version "2.0.0-rc.1" 6 | 7 | def project do 8 | [ 9 | app: :disco_log, 10 | aliases: aliases(), 11 | version: @version, 12 | elixir: "~> 1.17", 13 | elixirc_paths: elixirc_paths(Mix.env()), 14 | start_permanent: Mix.env() == :prod, 15 | deps: deps(), 16 | package: package(), 17 | description: description(), 18 | source_url: @source_url, 19 | docs: [ 20 | main: "DiscoLog", 21 | formatters: ["html"], 22 | extra_section: "GUIDES", 23 | extras: [ 24 | "CHANGELOG.md", 25 | "guides/getting-started.md", 26 | "guides/advanced-configuration.md", 27 | "guides/standalone-presence.md" 28 | ], 29 | groups_for_modules: [ 30 | Integrations: [ 31 | DiscoLog.Integrations.Oban, 32 | DiscoLog.Integrations.Plug 33 | ], 34 | "Discord Interface": [ 35 | DiscoLog.Discord.API, 36 | DiscoLog.Discord.API.Client 37 | ], 38 | "Supervision Tree": [ 39 | DiscoLog.Supervisor, 40 | DiscoLog.Storage, 41 | DiscoLog.Presence 42 | ] 43 | ], 44 | assets: %{ 45 | "assets" => "assets" 46 | }, 47 | api_reference: false, 48 | main: "getting-started" 49 | ] 50 | ] 51 | end 52 | 53 | # Run "mix help compile.app" to learn about applications. 54 | def application do 55 | [ 56 | mod: {DiscoLog.Application, []}, 57 | extra_applications: [:logger] 58 | ] 59 | end 60 | 61 | defp package do 62 | [ 63 | licenses: ["MIT"], 64 | links: %{ 65 | "Github" => "https://github.com/mrdotb/disco-log" 66 | }, 67 | maintainers: ["mrdotb", "martosaur"], 68 | files: ~w(lib LICENSE mix.exs README.md .formatter.exs)s 69 | ] 70 | end 71 | 72 | defp description, do: "Use Discord as a logging service and error tracking solution" 73 | 74 | defp elixirc_paths(:test), do: ["test/support", "lib"] 75 | defp elixirc_paths(_env), do: ["lib"] 76 | 77 | # Run "mix help deps" to learn about dependencies. 78 | defp deps do 79 | [ 80 | {:jason, "~> 1.1"}, 81 | {:plug, "~> 1.10"}, 82 | {:req, "~> 0.5.6"}, 83 | {:nimble_options, "~> 1.1"}, 84 | {:mint_web_socket, "~> 1.0", optional: true}, 85 | # Dev & test dependencies 86 | {:git_ops, "~> 2.8.0", only: [:dev]}, 87 | {:credo, "~> 1.7", only: [:dev, :test]}, 88 | {:ex_doc, "~> 0.33", only: :dev}, 89 | {:phoenix_live_reload, "~> 1.2", only: :dev}, 90 | {:phoenix_live_view, "~> 0.19 or ~> 1.0", only: [:dev]}, 91 | {:plug_cowboy, "~> 2.0", only: [:dev, :test]}, 92 | {:mox, "~> 1.1", only: :test}, 93 | {:logger_handler_kit, "~> 0.3", only: [:test, :dev]}, 94 | {:oban, "~> 2.19", only: [:dev]}, 95 | {:ecto_sqlite3, "~> 0.21.0", only: [:dev]}, 96 | {:bandit, "~> 1.7", only: [:dev, :test]} 97 | ] 98 | end 99 | 100 | defp aliases do 101 | [ 102 | dev: "run --no-halt dev.exs" 103 | ] 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/disco_log/integrations/oban.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscoLog.Integrations.Oban do 2 | @moduledoc """ 3 | Integration with Oban. 4 | 5 | ## How to use it 6 | 7 | It is a plug and play integration: as long as you have Oban installed the 8 | error will be reported. 9 | 10 | ### How it works 11 | 12 | It works using Oban's Telemetry events, so you don't need to modify anything 13 | on your application. 14 | 15 | ### Default context 16 | 17 | By default we store some context for you on errors generated in an Oban 18 | process: 19 | 20 | * `job.id`: the unique ID of the job. 21 | 22 | * `job.worker`: the name of the worker module. 23 | 24 | * `job.queue`: the name of the queue in which the job was inserted. 25 | 26 | * `job.args`: the arguments of the job being executed. 27 | 28 | * `job.priority`: the priority of the job. 29 | 30 | * `job.attempt`: the number of attempts performed for the job. 31 | 32 | > #### Universal Integration {: .tip} 33 | > 34 | > This integration will report errors directly to Discord, without logging it 35 | to other sources to avoid duplication. If you want to still log errors 36 | normally or if you use other error reporting libraries, you might want to 37 | roll out your own Oban instrumentation module that would work for all loggers. Here's an example: 38 | > 39 | > ```elixir 40 | > defmodule MyApp.ObanReporter do 41 | > require Logger 42 | > 43 | > def attach do 44 | > :telemetry.attach(:oban_errors, [:oban, :job, :exception], &__MODULE__.handle_event/4, []) 45 | > end 46 | > 47 | > def handle_event([:oban, :job, :exception], _measure, meta, _) do 48 | > job_meta = [ 49 | > attempt: meta.job.attempt, 50 | > args: meta.job.args, 51 | > id: meta.job.id, 52 | > priority: meta.job.priority, 53 | > queue: meta.job.queue, 54 | > worker: meta.job.worker, 55 | > state: meta.state, 56 | > result: meta.result 57 | > ] 58 | > 59 | > normalized = Exception.normalize(meta.kind, meta.reason, meta.stacktrace) 60 | > reason = if kind == :throw, do: {:nocatch, reason}, else: normalized 61 | > 62 | > meta.kind 63 | > |> Exception.format(meta.reason, meta.stacktrace) 64 | > |> Logger.error(job_meta ++ [crash_reason: {normalized, meta.stacktrace}) 65 | > end 66 | > end 67 | > ``` 68 | """ 69 | 70 | # https://hexdocs.pm/oban/Oban.Telemetry.html 71 | @events [ 72 | [:oban, :job, :exception] 73 | ] 74 | 75 | @doc false 76 | def attach(config, force_attachment \\ false) do 77 | if Application.spec(:oban) || force_attachment do 78 | :telemetry.attach_many(__MODULE__, @events, &__MODULE__.handle_event/4, config) 79 | end 80 | end 81 | 82 | def handle_event([:oban, :job, :exception], _measurements, metadata, config) do 83 | %{kind: kind, reason: reason, stacktrace: stacktrace, job: job} = metadata 84 | state = Map.get(metadata, :state, :failure) 85 | 86 | context = %{ 87 | "oban" => %{ 88 | "args" => job.args, 89 | "attempt" => job.attempt, 90 | "id" => job.id, 91 | "priority" => job.priority, 92 | "queue" => job.queue, 93 | "worker" => job.worker, 94 | "state" => state 95 | } 96 | } 97 | 98 | DiscoLog.report(kind, reason, stacktrace, context, config) 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /lib/disco_log/storage.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscoLog.Storage do 2 | @moduledoc """ 3 | A GenServer to store the mapping of fingerprint to Discord Thread ID. 4 | """ 5 | use GenServer 6 | 7 | alias DiscoLog.Discord 8 | 9 | defstruct [:registry, :discord_client, :guild_id, :occurrences_channel_id] 10 | 11 | ## Public API 12 | 13 | @doc "Start the Storage" 14 | def start_link(opts) do 15 | name = 16 | opts 17 | |> Keyword.fetch!(:supervisor_name) 18 | |> DiscoLog.Registry.via(__MODULE__) 19 | 20 | callers = Process.get(:"$callers", []) 21 | 22 | GenServer.start_link(__MODULE__, {opts, callers}, name: name) 23 | end 24 | 25 | @doc "Add a new fingerprint -> thread_id mapping" 26 | @spec add_thread_id(DiscoLog.Config.supervisor_name(), fingerprint :: String.t(), String.t()) :: 27 | :ok 28 | def add_thread_id(name, fingerprint, thread_id) do 29 | GenServer.call( 30 | DiscoLog.Registry.via(name, __MODULE__), 31 | {:add_thread_id, fingerprint, thread_id} 32 | ) 33 | end 34 | 35 | @doc "Retrieve the thread_id for a given fingerprint" 36 | @spec get_thread_id(DiscoLog.Config.supervisor_name(), fingerprint :: String.t()) :: 37 | String.t() | nil 38 | def get_thread_id(name, fingerprint) do 39 | name 40 | |> DiscoLog.Registry.registry_name() 41 | |> Registry.lookup({__MODULE__, :threads}) 42 | |> case do 43 | [{_, %{^fingerprint => thread_id}}] -> thread_id 44 | _ -> nil 45 | end 46 | end 47 | 48 | @doc "Retrieve the tag id for a given tag" 49 | @spec get_tags(DiscoLog.Config.supervisor_name()) :: String.t() | nil 50 | def get_tags(name) do 51 | name 52 | |> DiscoLog.Registry.registry_name() 53 | |> Registry.lookup({__MODULE__, :tags}) 54 | |> case do 55 | [{_, tags}] -> tags 56 | _ -> nil 57 | end 58 | end 59 | 60 | ## Callbacks 61 | 62 | @impl GenServer 63 | def init({opts, callers}) do 64 | state = %__MODULE__{ 65 | registry: DiscoLog.Registry.registry_name(opts[:supervisor_name]), 66 | discord_client: Keyword.fetch!(opts, :discord_client), 67 | guild_id: Keyword.fetch!(opts, :guild_id), 68 | occurrences_channel_id: Keyword.fetch!(opts, :occurrences_channel_id) 69 | } 70 | 71 | Process.put(:"$callers", callers) 72 | 73 | {:ok, state, {:continue, :restore}} 74 | end 75 | 76 | @impl GenServer 77 | def handle_continue( 78 | :restore, 79 | %__MODULE__{ 80 | discord_client: discord_client, 81 | guild_id: guild_id, 82 | occurrences_channel_id: occurrences_channel_id, 83 | registry: registry 84 | } = 85 | state 86 | ) do 87 | {:ok, existing_threads} = 88 | Discord.list_occurrence_threads(discord_client, guild_id, occurrences_channel_id) 89 | 90 | {:ok, existing_tags} = Discord.list_occurrence_tags(discord_client, occurrences_channel_id) 91 | 92 | Registry.register(registry, {__MODULE__, :threads}, existing_threads) 93 | Registry.register(registry, {__MODULE__, :tags}, existing_tags) 94 | 95 | {:noreply, state} 96 | end 97 | 98 | @impl GenServer 99 | def handle_call({:add_thread_id, fingerprint, thread_id}, _from, state) do 100 | Registry.update_value(state.registry, {__MODULE__, :threads}, fn threads -> 101 | Map.put(threads, fingerprint, thread_id) 102 | end) 103 | 104 | {:reply, :ok, state} 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /lib/disco_log.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscoLog do 2 | @moduledoc """ 3 | Send messages to Discord 4 | 5 | All functions in this module accept optional `context` and `config` parameters. 6 | * `context` is the last opportunity to assign some metadata that will be 7 | attached to the message or occurrence. Occurrences will additionally be tagged 8 | if context key names match existing channel tags. 9 | * `config` is important if you're running [advanced configuration](advanced-configuration.md) 10 | """ 11 | alias DiscoLog.Error 12 | alias DiscoLog.Config 13 | alias DiscoLog.Context 14 | alias DiscoLog.Storage 15 | alias DiscoLog.Discord.API 16 | alias DiscoLog.Discord.Prepare 17 | 18 | @doc """ 19 | Report catched error to occurrences channel 20 | 21 | This function reports an error directly to the occurrences channel, bypassing logging. 22 | 23 | Example: 24 | 25 | try do 26 | raise "Unexpected!" 27 | catch 28 | kind, reason -> DiscoLog.report(kind, reason, __STACKTRACE__) 29 | end 30 | """ 31 | @spec report(Exception.kind(), any(), Exception.stacktrace(), Context.t(), Config.t() | nil) :: 32 | API.response() 33 | def report(kind, reason, stacktrace, context \\ %{}, config \\ nil) do 34 | config = maybe_read_config(config) 35 | context = Map.merge(DiscoLog.Context.get(), context) 36 | 37 | error = Error.new(kind, reason, stacktrace) |> Error.enrich(config) 38 | log_occurrence(error, context, config) 39 | end 40 | 41 | @doc false 42 | def log_occurrence(error, context, config) do 43 | config.supervisor_name 44 | |> Storage.get_thread_id(error.fingerprint) 45 | |> case do 46 | nil -> 47 | available_tags = Storage.get_tags(config.supervisor_name) || %{} 48 | 49 | applied_tags = 50 | context 51 | |> Map.keys() 52 | |> Enum.map(&to_string/1) 53 | |> Enum.filter(&(&1 in Map.keys(available_tags))) 54 | |> Enum.map(&Map.fetch!(available_tags, &1)) 55 | 56 | message = 57 | Prepare.prepare_occurrence(error, context, applied_tags) 58 | 59 | with {:ok, %{status: 201, body: %{"id" => thread_id}}} = response <- 60 | API.post_thread(config.discord_client, config.occurrences_channel_id, message) do 61 | Storage.add_thread_id(config.supervisor_name, error.fingerprint, thread_id) 62 | response 63 | end 64 | 65 | thread_id -> 66 | message = Prepare.prepare_occurrence_message(error, context) 67 | 68 | API.post_message(config.discord_client, thread_id, message) 69 | end 70 | end 71 | 72 | @doc """ 73 | Sends text message to the channel configured as `info_channel_id` 74 | """ 75 | @spec log_info(String.t(), Context.t(), Config.t() | nil) :: API.response() 76 | def log_info(message, context \\ %{}, config \\ nil) do 77 | config = maybe_read_config(config) 78 | message = Prepare.prepare_message(message, context) 79 | 80 | API.post_message(config.discord_client, config.info_channel_id, message) 81 | end 82 | 83 | @doc """ 84 | Sends text message to the channel configures as `error_channel_id` 85 | """ 86 | @spec log_error(String.t(), Context.t(), Config.t() | nil) :: API.response() 87 | def log_error(message, context \\ %{}, config \\ nil) do 88 | config = maybe_read_config(config) 89 | message = Prepare.prepare_message(message, context) 90 | 91 | API.post_message(config.discord_client, config.error_channel_id, message) 92 | end 93 | 94 | defp maybe_read_config(nil), do: DiscoLog.Config.read!() 95 | defp maybe_read_config(config), do: config 96 | end 97 | -------------------------------------------------------------------------------- /test/disco_log/error_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DiscoLog.ErrorTest do 2 | use DiscoLog.Test.Case, async: true 3 | 4 | alias DiscoLog.Error 5 | 6 | @repo_url "https://github.com/mrdotb/disco-log/blob" 7 | 8 | @config %{ 9 | in_app_modules: [__MODULE__], 10 | enable_go_to_repo: true, 11 | repo_url: @repo_url, 12 | git_sha: "commit_sha", 13 | go_to_repo_top_modules: [] 14 | } 15 | 16 | describe "enrich" do 17 | test "different exit reasons group together" do 18 | fun = fn reason -> exit(reason) end 19 | error1 = build_error(fun, "foo") 20 | error2 = build_error(fun, "bar") 21 | 22 | assert error1.fingerprint_basis == error2.fingerprint_basis 23 | assert @repo_url <> "/commit_sha/test/disco_log/error_test.exs#L18" = error1.source_url 24 | end 25 | 26 | test "different throw values reasons grouped together" do 27 | fun = fn value -> throw(value) end 28 | error1 = build_error(fun, "foo") 29 | error2 = build_error(fun, "bar") 30 | 31 | assert error1.fingerprint_basis == error2.fingerprint_basis 32 | assert @repo_url <> "/commit_sha/test/disco_log/error_test.exs#L27" = error1.source_url 33 | end 34 | 35 | test "same exceptions grouped together" do 36 | fun = fn _ -> raise "Hello" end 37 | error1 = build_error(fun, nil) 38 | error2 = build_error(fun, nil) 39 | 40 | assert error1.fingerprint_basis == error2.fingerprint_basis 41 | assert @repo_url <> "/commit_sha/test/disco_log/error_test.exs#L36" = error1.source_url 42 | end 43 | 44 | test "same exception types are grouped" do 45 | fun = fn arg -> if(arg == 1, do: raise("A"), else: raise("B")) end 46 | 47 | error1 = build_error(fun, 1) 48 | error2 = build_error(fun, 2) 49 | 50 | assert error1.fingerprint_basis == error2.fingerprint_basis 51 | assert @repo_url <> "/commit_sha/test/disco_log/error_test.exs#L45" = error1.source_url 52 | end 53 | 54 | test "same exceptions but arguments part of stacktrace" do 55 | fun = fn nil -> :ok end 56 | error1 = build_error(fun, :foo) 57 | error2 = build_error(fun, :bar) 58 | 59 | assert error1.fingerprint_basis == error2.fingerprint_basis 60 | assert @repo_url <> "/commit_sha/test/disco_log/error_test.exs#L55" = error1.source_url 61 | end 62 | 63 | test "same exceptions but different lines" do 64 | fun = fn 65 | :foo -> raise "Foo" 66 | :bar -> raise "Foo" 67 | end 68 | 69 | error1 = build_error(fun, :foo) 70 | error2 = build_error(fun, :bar) 71 | 72 | refute error1.fingerprint_basis == error2.fingerprint_basis 73 | assert @repo_url <> "/commit_sha/test/disco_log/error_test.exs#L65" = error1.source_url 74 | assert @repo_url <> "/commit_sha/test/disco_log/error_test.exs#L66" = error2.source_url 75 | end 76 | 77 | test "different exceptions but same app path" do 78 | fun = fn 79 | 1 -> String.trim(nil) 80 | 2 -> Enum.sum(nil) 81 | end 82 | 83 | error1 = build_error(fun, 1) 84 | error2 = build_error(fun, 2) 85 | 86 | refute error1.fingerprint_basis == error2.fingerprint_basis 87 | assert @repo_url <> "/commit_sha/test/disco_log/error_test.exs#L99" = error1.source_url 88 | assert @repo_url <> "/commit_sha/test/disco_log/error_test.exs#L99" = error2.source_url 89 | end 90 | 91 | test "no in_app entries" do 92 | error = build_error(fn _ -> Enum.sum(nil) end, nil, %{@config | in_app_modules: []}) 93 | 94 | refute error.source_url 95 | end 96 | end 97 | 98 | defp build_error(fun, arg, config \\ @config) do 99 | fun.(arg) 100 | catch 101 | kind, reason -> 102 | Error.new(kind, reason, __STACKTRACE__) 103 | |> Error.enrich(config) 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /test/disco_log/storage_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DiscoLog.StorageTest do 2 | use DiscoLog.Test.Case, async: true 3 | 4 | import Mox 5 | 6 | alias DiscoLog.Storage 7 | alias DiscoLog.Discord.API 8 | 9 | setup :verify_on_exit! 10 | 11 | setup_all do 12 | Registry.start_link(keys: :unique, name: __MODULE__.Registry) 13 | :ok 14 | end 15 | 16 | describe "start_link" do 17 | test "loads occurrences and tags on startup" do 18 | API.Mock 19 | |> expect(:request, 2, fn 20 | client, :get, "/guilds/:guild_id/threads/active", opts -> 21 | assert [path_params: [guild_id: "guild_id"]] = opts 22 | API.Stub.request(client, :get, "/guilds/:guild_id/threads/active", []) 23 | 24 | client, :get, "/channels/:channel_id", opts -> 25 | assert [path_params: [channel_id: "stub_occurrences_channel_id"]] = opts 26 | API.Stub.request(client, :get, "/channels/:channel_id", []) 27 | end) 28 | 29 | pid = 30 | start_link_supervised!( 31 | {Storage, 32 | [ 33 | supervisor_name: __MODULE__, 34 | occurrences_channel_id: "stub_occurrences_channel_id", 35 | guild_id: "guild_id", 36 | discord_client: %API{module: API.Mock} 37 | ]} 38 | ) 39 | 40 | _ = :sys.get_status(pid) 41 | 42 | assert [{pid, %{"FNGRPT" => "stub_thread_id"}}] == 43 | Registry.lookup(__MODULE__.Registry, {Storage, :threads}) 44 | 45 | assert [ 46 | {pid, 47 | %{ 48 | "oban" => "stub_oban_tag_id", 49 | "live_view" => "stub_live_view_tag_id", 50 | "plug" => "stub_plug_tag_id" 51 | }} 52 | ] == 53 | Registry.lookup(__MODULE__.Registry, {Storage, :tags}) 54 | end 55 | end 56 | 57 | describe inspect(&Storage.get_thread_id/2) do 58 | setup do 59 | pid = 60 | start_link_supervised!( 61 | {Storage, 62 | [ 63 | supervisor_name: __MODULE__, 64 | occurrences_channel_id: "stub_occurrences_channel_id", 65 | guild_id: "guild_id", 66 | discord_client: %API{module: API.Mock} 67 | ]} 68 | ) 69 | 70 | _ = :sys.get_status(pid) 71 | :ok 72 | end 73 | 74 | test "thread id exists" do 75 | assert "stub_thread_id" = Storage.get_thread_id(__MODULE__, "FNGRPT") 76 | end 77 | 78 | test "nil if missing" do 79 | assert nil == Storage.get_thread_id(__MODULE__, "unknown") 80 | end 81 | end 82 | 83 | describe inspect(&Storage.add_thread_id/3) do 84 | setup do 85 | pid = 86 | start_link_supervised!( 87 | {Storage, 88 | [ 89 | supervisor_name: __MODULE__, 90 | occurrences_channel_id: "stub_occurrences_channel_id", 91 | guild_id: "guild_id", 92 | discord_client: %API{module: API.Mock} 93 | ]} 94 | ) 95 | 96 | _ = :sys.get_status(pid) 97 | :ok 98 | end 99 | 100 | test "puts new thread" do 101 | assert :ok = Storage.add_thread_id(__MODULE__, "foo", "bar") 102 | 103 | assert [{_, %{"foo" => "bar"}}] = 104 | Registry.lookup(__MODULE__.Registry, {Storage, :threads}) 105 | end 106 | 107 | test "overwrites thread_id" do 108 | assert :ok = Storage.add_thread_id(__MODULE__, "foo", "bar") 109 | 110 | assert [{_, %{"foo" => "bar"}}] = 111 | Registry.lookup(__MODULE__.Registry, {Storage, :threads}) 112 | 113 | assert :ok = Storage.add_thread_id(__MODULE__, "foo", "baz") 114 | 115 | assert [{_, %{"foo" => "baz"}}] = 116 | Registry.lookup(__MODULE__.Registry, {Storage, :threads}) 117 | end 118 | end 119 | 120 | describe inspect(&Storage.get_tags/1) do 121 | setup do 122 | pid = 123 | start_link_supervised!( 124 | {Storage, 125 | [ 126 | supervisor_name: __MODULE__, 127 | occurrences_channel_id: "stub_occurrences_channel_id", 128 | guild_id: "guild_id", 129 | discord_client: %API{module: API.Mock} 130 | ]} 131 | ) 132 | 133 | _ = :sys.get_status(pid) 134 | :ok 135 | end 136 | 137 | test "retrieves all tags" do 138 | assert %{ 139 | "oban" => "stub_oban_tag_id", 140 | "live_view" => "stub_live_view_tag_id", 141 | "plug" => "stub_plug_tag_id" 142 | } == Storage.get_tags(__MODULE__) 143 | end 144 | end 145 | end 146 | -------------------------------------------------------------------------------- /lib/disco_log/discord/api.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscoLog.Discord.API do 2 | @moduledoc """ 3 | A module for working with Discord REST API. 4 | https://discord.com/developers/docs/reference 5 | 6 | This module is also a behavior. The default implementation uses the `Req` HTTP client. 7 | If you want to use a different client, you'll need to implement the behavior and 8 | put it under the `discord_client_module` configuration option. 9 | """ 10 | 11 | require Logger 12 | 13 | defstruct [:client, :module, :log?] 14 | 15 | @typedoc """ 16 | The client can be any term. It is passed as a first argument to `c:request/4`. For example, the 17 | default `DiscoLog.Discord.API.Client` client uses `Req.Request.t()` as a client. 18 | """ 19 | @type client() :: any() 20 | @type response() :: {:ok, %{status: non_neg_integer(), body: any()}} | {:error, Exception.t()} 21 | @type t() :: %__MODULE__{client: client(), module: atom()} 22 | 23 | @callback client(token :: String.t()) :: t() 24 | @callback request(client :: client(), method :: atom(), url :: String.t(), opts :: keyword()) :: 25 | response() 26 | 27 | @spec list_active_threads(client(), String.t()) :: response() 28 | def list_active_threads(%__MODULE__{} = client, guild_id) do 29 | with_log(client, :get, "/guilds/:guild_id/threads/active", path_params: [guild_id: guild_id]) 30 | end 31 | 32 | @spec list_channels(client(), String.t()) :: response() 33 | def list_channels(%__MODULE__{} = client, guild_id) do 34 | with_log(client, :get, "/guilds/:guild_id/channels", path_params: [guild_id: guild_id]) 35 | end 36 | 37 | @spec get_channel(client(), String.t()) :: response() 38 | def get_channel(%__MODULE__{} = client, channel_id) do 39 | with_log(client, :get, "/channels/:channel_id", path_params: [channel_id: channel_id]) 40 | end 41 | 42 | @spec get_channel_messages(client(), String.t()) :: response() 43 | def get_channel_messages(%__MODULE__{} = client, channel_id) do 44 | with_log(client, :get, "/channels/:channel_id/messages", 45 | path_params: [channel_id: channel_id] 46 | ) 47 | end 48 | 49 | @spec get_gateway(client()) :: response() 50 | def get_gateway(%__MODULE__{} = client) do 51 | with_log(client, :get, "/gateway/bot", []) 52 | end 53 | 54 | @spec create_channel(client(), String.t(), map()) :: response() 55 | def create_channel(%__MODULE__{} = client, guild_id, body) do 56 | with_log(client, :post, "/guilds/:guild_id/channels", 57 | path_params: [guild_id: guild_id], 58 | json: body 59 | ) 60 | end 61 | 62 | @spec post_message(client(), String.t(), Keyword.t()) :: response() 63 | def post_message(%__MODULE__{} = client, channel_id, fields) do 64 | with_log(client, :post, "/channels/:channel_id/messages", 65 | path_params: [channel_id: channel_id], 66 | form_multipart: fields 67 | ) 68 | end 69 | 70 | @spec post_thread(client(), String.t(), Keyword.t()) :: response() 71 | def post_thread(%__MODULE__{} = client, channel_id, fields) do 72 | with_log(client, :post, "/channels/:channel_id/threads", 73 | path_params: [channel_id: channel_id], 74 | form_multipart: fields 75 | ) 76 | end 77 | 78 | @spec delete_thread(client(), String.t()) :: response() 79 | def delete_thread(%__MODULE__{} = client, thread_id) do 80 | with_log(client, :delete, "/channels/:thread_id", path_params: [thread_id: thread_id]) 81 | end 82 | 83 | @spec delete_message(client(), String.t(), String.t()) :: response() 84 | def delete_message(%__MODULE__{} = client, channel_id, message_id) do 85 | with_log(client, :delete, "/channels/:channel_id/messages/:message_id", 86 | path_params: [channel_id: channel_id, message_id: message_id] 87 | ) 88 | end 89 | 90 | @spec delete_channel(client(), String.t()) :: response() 91 | def delete_channel(%__MODULE__{} = client, channel_id) do 92 | with_log(client, :delete, "/channels/:channel_id", path_params: [channel_id: channel_id]) 93 | end 94 | 95 | defp with_log(client, method, url, opts) do 96 | resp = client.module.request(client.client, method, url, opts) 97 | 98 | if client.log? do 99 | request = "#{method |> to_string() |> String.upcase()} #{to_string(url)}\n" 100 | 101 | response = 102 | case resp do 103 | {:ok, resp} -> 104 | "Status: #{inspect(resp.status)}\nBody: #{inspect(resp.body, pretty: true)}" 105 | 106 | {:error, error} -> 107 | "Error: #{inspect(error, pretty: true)}" 108 | end 109 | 110 | Logger.debug("Request: #{request}\n#{inspect(opts, pretty: true)}\n#{response}") 111 | end 112 | 113 | resp 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /lib/disco_log/error.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscoLog.Error do 2 | @moduledoc false 3 | defstruct [ 4 | :kind, 5 | :reason, 6 | :stacktrace, 7 | :display_title, 8 | :display_kind, 9 | :display_short_error, 10 | :display_full_error, 11 | :display_source, 12 | :fingerprint_basis, 13 | :fingerprint, 14 | :source_url 15 | ] 16 | 17 | @type t() :: %__MODULE__{} 18 | 19 | @title_limit 80 20 | @short_error_limit 60 21 | 22 | def new({{:nocatch, reason}, stacktrace}), do: new(:throw, reason, stacktrace) 23 | def new({reason, stacktrace}) when is_exception(reason), do: new(:error, reason, stacktrace) 24 | def new({reason, stacktrace}), do: new(:exit, reason, stacktrace) 25 | 26 | def new(kind, reason, stacktrace) do 27 | %__MODULE__{ 28 | kind: kind, 29 | reason: Exception.normalize(kind, reason, stacktrace), 30 | stacktrace: stacktrace 31 | } 32 | |> put_display_kind() 33 | |> put_display_short_error() 34 | |> put_display_full_error() 35 | |> put_display_title() 36 | end 37 | 38 | def enrich(%__MODULE__{} = error, config) do 39 | maybe_last_app_entry = 40 | case error.stacktrace do 41 | [first | _] -> 42 | Enum.find(error.stacktrace, first, fn {module, _, _, _} -> 43 | module in config.in_app_modules 44 | end) 45 | 46 | _ -> 47 | nil 48 | end 49 | 50 | display_source = 51 | if maybe_last_app_entry, do: Exception.format_stacktrace_entry(maybe_last_app_entry) 52 | 53 | source_url = compose_source_url(maybe_last_app_entry, config) 54 | 55 | fingeprintable_error = 56 | case error do 57 | %{reason: %module{} = exception} when is_exception(exception) -> 58 | module 59 | 60 | %{kind: kind} -> 61 | kind 62 | end 63 | 64 | fingerprintable_stacktrace_entry = 65 | with {module, function, args, opts} when is_list(args) <- maybe_last_app_entry do 66 | {module, function, length(args), opts} 67 | end 68 | 69 | basis = {fingeprintable_error, fingerprintable_stacktrace_entry} 70 | 71 | %{ 72 | error 73 | | fingerprint_basis: basis, 74 | fingerprint: fingerprint(basis), 75 | source_url: source_url, 76 | display_source: display_source 77 | } 78 | end 79 | 80 | def fingerprint(basis) do 81 | hash = :erlang.phash2(basis, 2 ** 32) 82 | Base.url_encode64(<>, padding: false) 83 | end 84 | 85 | defp compose_source_url({module, _, _, opts}, %{enable_go_to_repo: true} = config) do 86 | with true <- in_app_module?(module, config), 87 | file when not is_nil(file) <- Keyword.get(opts, :file) do 88 | "#{config[:repo_url]}/#{config[:git_sha]}/#{file}#L#{opts[:line]}" 89 | else 90 | _ -> nil 91 | end 92 | end 93 | 94 | defp compose_source_url(_, _), do: nil 95 | 96 | defp in_app_module?(module, config) do 97 | with false <- module in config.in_app_modules, 98 | true <- function_exported?(module, :__info__, 2) do 99 | [top_level | _] = Module.split(module) 100 | top_level in config.go_to_repo_top_modules 101 | end 102 | end 103 | 104 | defp put_display_kind(%__MODULE__{} = error) do 105 | if is_exception(error.reason) do 106 | %{error | display_kind: inspect(error.reason.__struct__)} 107 | else 108 | error 109 | end 110 | end 111 | 112 | defp put_display_short_error(%__MODULE__{} = error) do 113 | formatted = 114 | if is_exception(error.reason) do 115 | Exception.message(error.reason) 116 | else 117 | Exception.format_banner(error.kind, error.reason, error.stacktrace) 118 | end 119 | 120 | %{error | display_short_error: to_oneliner(formatted, @short_error_limit)} 121 | end 122 | 123 | defp put_display_full_error(%__MODULE__{} = error) do 124 | formatted = Exception.format(error.kind, error.reason, error.stacktrace) 125 | %{error | display_full_error: formatted} 126 | end 127 | 128 | defp put_display_title(%__MODULE__{} = error) do 129 | if is_exception(error.reason) do 130 | formatted = Exception.format_banner(error.kind, error.reason, error.stacktrace) 131 | %{error | display_title: to_oneliner(formatted, @title_limit)} 132 | else 133 | %{error | display_title: error.display_short_error} 134 | end 135 | end 136 | 137 | defp to_oneliner(text, limit) do 138 | line = 139 | text 140 | |> String.split("\n", parts: 2) 141 | |> hd() 142 | 143 | truncated = 144 | if String.length(line) <= limit do 145 | line 146 | else 147 | line 148 | |> String.split(": ", parts: 2) 149 | |> hd() 150 | |> String.slice(0, limit) 151 | end 152 | 153 | if String.length(truncated) < String.length(text) do 154 | truncated <> " \u2026" 155 | else 156 | truncated 157 | end 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 5 | 6 | 7 | 8 | ## [v2.0.0-rc.1](https://github.com/mrdotb/disco-log/compare/v2.0.0-rc.0...v0.0.0-rc.1) (2025-07-23) 9 | 10 | ### Bug Fixes: 11 | 12 | * truncate full error if it exceeds discord limit 13 | 14 | 15 | 16 | 17 | ## [v2.0.0-rc.0](https://github.com/mrdotb/disco-log/compare/v1.0.3...v2.0.0-rc.0) (2025-06-13) 18 | ### Breaking Changes: 19 | 20 | * Rework core experience 21 | 22 | ### Migration Guide 23 | 24 | * Remove `instrument_phoenix` from your configuration 25 | * Remove `instrument_tesla` from your configuration 26 | * Migrate any usages of `DiscoLog.report/4` to `DiscoLog.report/5`, e.g. `DiscoLog.report(error, stacktrace)` -> `DiscoLog.report(:error, error, stacktrace)` 27 | * [optional] Clean up existing occurrences with `mix disco_log.cleanup` or 28 | manually. The DiscoLog fingerprinting format has changed and it won't be able to 29 | pick up old occurrences. 30 | 31 | 32 | ### Features: 33 | 34 | * plug: Make Plug integration only catch errors for Cowboy 35 | 36 | * dev: Add Oban to dev demo app 37 | 38 | ### Bug Fixes: 39 | 40 | * missing mint structs when using disco-log without presence 41 | 42 | ## [v1.0.3](https://github.com/mrdotb/disco-log/compare/v1.0.2...v1.0.3) (2025-03-20) 43 | 44 | 45 | 46 | 47 | ### Bug Fixes: 48 | 49 | * missing mint structs when using disco-log without presence 50 | 51 | ## [v1.0.2](https://github.com/mrdotb/disco-log/compare/v1.0.1...v1.0.2) (2025-03-18) 52 | 53 | 54 | 55 | 56 | ## [v1.0.1](https://github.com/mrdotb/disco-log/compare/v1.0.0...v1.0.1) (2025-03-16) 57 | 58 | 59 | 60 | 61 | ### Bug Fixes: 62 | 63 | * presence: account for handling multiple data frames at once 64 | 65 | ## [v1.0.0](https://github.com/mrdotb/disco-log/compare/v0.7.0...v1.0.0) (2025-02-01) 66 | 67 | ### Features: 68 | 69 | * go-to-repo: create a link to the repo viewer on discord report 70 | 71 | * presence: set basic presence status 💅 72 | 73 | ### Refactor: 74 | 75 | * Redesign main modules to enable ad-hoc usage 76 | 77 | 78 | 79 | 80 | ## [v1.0.0-rc.2](https://github.com/mrdotb/disco-log/compare/v1.0.0-rc.1...v1.0.0-rc.2) (2025-01-26) 81 | 82 | 83 | 84 | 85 | ### Refactor: 86 | 87 | * Redesign main modules to enable ad-hoc usage 88 | 89 | 90 | 91 | ## [v1.0.0-rc.1](https://github.com/mrdotb/disco-log/compare/v1.0.0-rc.0...v1.0.0-rc.1) (2025-01-18) 92 | 93 | 94 | 95 | 96 | ### Bug Fixes: 97 | 98 | * correct clause in SSL closed message and handle GenServer logger crash 99 | 100 | ## [v1.0.0-rc.0](https://github.com/mrdotb/disco-log/compare/v0.8.0-rc.2...v1.0.0-rc.0) (2025-01-10) 101 | ### Breaking Changes: 102 | 103 | * dynamic tags 104 | 105 | 106 | 107 | ## [v0.8.0-rc.2](https://github.com/mrdotb/disco-log/compare/v0.8.0-rc.1...v0.8.0-rc.2) (2025-01-10) 108 | 109 | 110 | 111 | 112 | ### Bug Fixes: 113 | 114 | * presence: handle ssl_closed message 115 | 116 | * genserver stop struct remove logger handler 117 | 118 | ## [v0.8.0-rc.1](https://github.com/mrdotb/disco-log/compare/v0.8.0-rc.0...v0.8.0-rc.1) (2025-01-06) 119 | 120 | 121 | 122 | 123 | ### Bug Fixes: 124 | 125 | * presence: handle receiving no frames from tcp 126 | 127 | ## [v0.8.0-rc.0](https://github.com/mrdotb/disco-log/compare/v0.7.0...v0.8.0-rc.0) (2025-01-04) 128 | 129 | 130 | 131 | 132 | ### Features: 133 | 134 | * go-to-repo: create a link to the repo viewer on discord report 135 | 136 | * presence: set basic presence status 💅 137 | 138 | * Add Presence 139 | 140 | ### Bug Fixes: 141 | 142 | * presence: handle non-101 response status for ws upgrade request 143 | 144 | * presence: do not expect 101 Upgrade response to have data 145 | 146 | ## [v0.7.0](https://github.com/mrdotb/disco-log/compare/v0.6.0...v0.7.0) (2024-12-09) 147 | 148 | 149 | 150 | 151 | ### Features: 152 | 153 | * Supervisor: override child_spec/1 to automatically use supervisor name as id 154 | 155 | ### Bug Fixes: 156 | 157 | * only limit the final length of inspected metadata 158 | 159 | ## [v0.6.0](https://github.com/mrdotb/disco-log/compare/v0.5.2...v0.6.0) (2024-11-13) 160 | 161 | 162 | 163 | 164 | ### Features: 165 | 166 | * inspect metadata instead of serializing it 167 | 168 | ## [v0.5.2](https://github.com/mrdotb/disco-log/compare/v0.5.1...v0.5.2) (2024-10-26) 169 | 170 | 171 | 172 | 173 | ### Bug Fixes: 174 | 175 | * logger handler not handling IO data 176 | 177 | ## [v0.5.1](https://github.com/mrdotb/disco-log/compare/v0.5.1...v0.5.1) (2024-10-12) 178 | 179 | 180 | 181 | 182 | ### Bug Fixes: 183 | 184 | * write a custom JSON encoder to prevent crashing the handler 185 | 186 | ## [0.5.0] - 2024-09-12] 187 | - Oban trace improvements 188 | - LiveComponent trace improvements 189 | - Fix double error reporting on some plug errors 190 | 191 | ## [0.4.0] - 2024-09-01] 192 | - Allow config to disable DiscoLog in env / test environments 193 | - Fix an issue with logger handler and struct 194 | 195 | ## [0.3.0] - 2024-09-01] 196 | - Bump Req version 197 | - Enable Tesla integration by default 198 | 199 | ## [0.2.0] - 2024-08-31] 200 | - Fix some bugs 201 | - Add new tesla integration 202 | 203 | ## [0.1.0] - 2024-08-28] 204 | - Initial release 205 | -------------------------------------------------------------------------------- /lib/disco_log/discord/prepare.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscoLog.Discord.Prepare do 2 | @moduledoc false 3 | 4 | @text_display_type 10 5 | @components_flag 32_768 6 | @max_title_length 100 7 | @displayed_message_limit 4000 8 | @context_overhead String.length("```elixir\n\n```") 9 | @file_attachment_limit 8_000_000 10 | @full_error_overhead String.length("```\n\n```") 11 | 12 | def prepare_message(message, context) do 13 | context_budget = context_budget(message) 14 | 15 | base_message = %{ 16 | flags: @components_flag, 17 | components: [ 18 | %{ 19 | type: @text_display_type, 20 | content: message 21 | } 22 | ] 23 | } 24 | 25 | append_component(base_message, prepare_context(context, context_budget)) 26 | end 27 | 28 | def prepare_occurrence(error, context, applied_tags) do 29 | %{ 30 | name: String.slice(error.fingerprint <> " " <> error.display_title, 0, @max_title_length), 31 | applied_tags: applied_tags, 32 | message: %{ 33 | flags: @components_flag, 34 | components: [] 35 | } 36 | } 37 | |> append_component(prepare_main_content(error)) 38 | |> then(fn payload -> 39 | budget = full_error_budget(payload) 40 | append_component(payload, prepare_full_error_content(error, budget)) 41 | end) 42 | |> then(fn payload -> 43 | budget = context_budget(payload) 44 | append_component(payload, prepare_context(context, budget)) 45 | end) 46 | end 47 | 48 | def prepare_occurrence_message(error, context) do 49 | %{ 50 | flags: @components_flag, 51 | components: [] 52 | } 53 | |> append_component(prepare_main_content(error)) 54 | |> then(fn payload -> 55 | budget = full_error_budget(payload) 56 | append_component(payload, prepare_full_error_content(error, budget)) 57 | end) 58 | |> then(fn payload -> 59 | budget = context_budget(payload) 60 | append_component(payload, prepare_context(context, budget)) 61 | end) 62 | end 63 | 64 | def fingerprint_from_thread_name(<> <> " " <> _rest), 65 | do: fingerprint 66 | 67 | def fingerprint_from_thread_name(_), do: nil 68 | 69 | defp prepare_main_content(error) do 70 | source_line = 71 | case error do 72 | %{display_source: nil} -> nil 73 | %{source_url: nil} -> "**Source:** `#{error.display_source}`" 74 | _ -> "**Source:** [#{error.display_source}](#{error.source_url})" 75 | end 76 | 77 | kind_line = 78 | if error.display_kind, do: "**Kind:** `#{error.display_kind}`" 79 | 80 | content = 81 | [ 82 | kind_line, 83 | "**Reason:** `#{error.display_short_error}`", 84 | source_line 85 | ] 86 | |> Enum.reject(&is_nil/1) 87 | |> Enum.join("\n") 88 | 89 | %{ 90 | type: @text_display_type, 91 | content: content 92 | } 93 | end 94 | 95 | defp prepare_full_error_content(%{display_full_error: nil}, _full_error_budget), do: nil 96 | 97 | defp prepare_full_error_content(error, full_error_budget) when full_error_budget <= 0 do 98 | {:error, 99 | {String.byte_slice(error.display_full_error, 0, @file_attachment_limit), 100 | [filename: "error.txt"]}} 101 | end 102 | 103 | defp prepare_full_error_content(error, full_error_budget) do 104 | if String.length(error.display_full_error) <= full_error_budget do 105 | %{type: @text_display_type, content: "```\n#{error.display_full_error}\n```"} 106 | else 107 | prepare_full_error_content(error, 0) 108 | end 109 | end 110 | 111 | defp prepare_context(_, context_budget) when context_budget <= 0, do: nil 112 | defp prepare_context(nil, _context_budget), do: nil 113 | 114 | defp prepare_context(context, _context_budget) when is_map(context) and map_size(context) == 0, 115 | do: nil 116 | 117 | defp prepare_context(context, context_budget) do 118 | context 119 | |> inspect(pretty: true, limit: :infinity, printable_limit: :infinity) 120 | |> String.split_at(context_budget) 121 | |> case do 122 | {full, ""} -> 123 | %{ 124 | type: @text_display_type, 125 | content: "```elixir\n#{full}\n```" 126 | } 127 | 128 | {left, right} -> 129 | {:context, 130 | {String.byte_slice(left <> right, 0, @file_attachment_limit), [filename: "context.txt"]}} 131 | end 132 | end 133 | 134 | defp append_component(%{} = message, component) do 135 | append_component([payload_json: message], component) 136 | end 137 | 138 | defp append_component( 139 | [{:payload_json, %{message: %{components: components}}} | _] = payload, 140 | %{} = component 141 | ) do 142 | put_in(payload, [:payload_json, :message, :components], components ++ [component]) 143 | end 144 | 145 | defp append_component( 146 | [{:payload_json, %{components: components}} | _] = payload, 147 | %{} = component 148 | ) do 149 | put_in(payload, [:payload_json, :components], components ++ [component]) 150 | end 151 | 152 | defp append_component( 153 | [_ | _] = payload, 154 | {_filename, {_, [filename: full_filename]}} = attachment 155 | ) do 156 | append_component(payload ++ [attachment], %{ 157 | type: 13, 158 | file: %{url: "attachment://#{full_filename}"} 159 | }) 160 | end 161 | 162 | defp append_component(message, _), do: message 163 | 164 | defp context_budget(main_content), 165 | do: @displayed_message_limit - content_length(main_content) - @context_overhead 166 | 167 | defp full_error_budget(main_content), 168 | do: @displayed_message_limit - content_length(main_content) - @full_error_overhead 169 | 170 | defp content_length([{:payload_json, %{message: %{components: components}}} | _]) do 171 | Enum.sum_by(components, fn 172 | %{content: content} -> String.length(content) 173 | _ -> 0 174 | end) 175 | end 176 | 177 | defp content_length([{:payload_json, %{components: components}} | _]) do 178 | Enum.sum_by(components, fn 179 | %{content: content} -> String.length(content) 180 | _ -> 0 181 | end) 182 | end 183 | 184 | defp content_length(string), do: String.length(string) 185 | end 186 | -------------------------------------------------------------------------------- /lib/disco_log/config.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscoLog.Config do 2 | @configuration_schema [ 3 | otp_app: [ 4 | type: :atom, 5 | required: true, 6 | doc: "Name of your application" 7 | ], 8 | token: [ 9 | type: :string, 10 | required: true, 11 | doc: "Your Discord bot token" 12 | ], 13 | guild_id: [ 14 | type: :string, 15 | required: true, 16 | doc: "Discord Server ID" 17 | ], 18 | category_id: [ 19 | type: :string, 20 | required: true, 21 | doc: "Category (Channel) ID" 22 | ], 23 | occurrences_channel_id: [ 24 | type: :string, 25 | required: true, 26 | doc: "Forum channel ID for error occurrences" 27 | ], 28 | info_channel_id: [ 29 | type: :string, 30 | required: true, 31 | doc: "Text channel ID for info-level logs" 32 | ], 33 | error_channel_id: [ 34 | type: :string, 35 | required: true, 36 | doc: "Text channel ID for logs higher than info-level logs" 37 | ], 38 | enable: [ 39 | type: :boolean, 40 | default: true, 41 | doc: "Automatically start DiscoLog?" 42 | ], 43 | enable_logger: [ 44 | type: :boolean, 45 | default: true, 46 | doc: "Automatically attach logger handler?" 47 | ], 48 | enable_discord_log: [ 49 | type: :boolean, 50 | default: false, 51 | doc: "Log requests to Discord API?" 52 | ], 53 | enable_presence: [ 54 | type: :boolean, 55 | default: false, 56 | doc: "Show DiscoLog bot status as Online?" 57 | ], 58 | instrument_oban: [ 59 | type: :boolean, 60 | default: true, 61 | doc: "Automatically instrument Oban?" 62 | ], 63 | metadata: [ 64 | type: {:list, :atom}, 65 | default: [], 66 | doc: "List of Logger metadata keys to propagate with the message" 67 | ], 68 | excluded_domains: [ 69 | type: {:list, :atom}, 70 | default: [], 71 | doc: "Logs with domains from this list will be ignored" 72 | ], 73 | discord_client_module: [ 74 | type: :atom, 75 | default: DiscoLog.Discord.API.Client, 76 | doc: "Discord client to use" 77 | ], 78 | supervisor_name: [ 79 | type: :atom, 80 | default: DiscoLog, 81 | doc: "Name of the supervisor process running DiscoLog" 82 | ], 83 | presence_status: [ 84 | type: :string, 85 | default: "🪩 Disco Logging", 86 | doc: "A message to display as the bot's status when presence is enabled" 87 | ], 88 | enable_go_to_repo: [ 89 | type: :boolean, 90 | default: false, 91 | doc: "Enable go_to_repo feature?" 92 | ], 93 | go_to_repo_top_modules: [ 94 | type: {:list, :string}, 95 | default: [], 96 | doc: 97 | "List of top-level modules that are not part of the application spec but code belongs to the app" 98 | ], 99 | repo_url: [ 100 | type: :string, 101 | default: "", 102 | doc: "URL to the git repository viewer" 103 | ], 104 | git_sha: [ 105 | type: :string, 106 | default: "", 107 | doc: "The git SHA of the running app" 108 | ] 109 | ] 110 | 111 | @compiled_schema NimbleOptions.new!(@configuration_schema) 112 | 113 | @moduledoc """ 114 | DiscoLog configuration 115 | 116 | ## Configuration Schema 117 | 118 | #{NimbleOptions.docs(@compiled_schema)} 119 | """ 120 | 121 | @type t() :: %{ 122 | otp_app: atom(), 123 | token: String.t(), 124 | guild_id: String.t(), 125 | category_id: String.t(), 126 | occurrences_channel_id: String.t(), 127 | info_channel_id: String.t(), 128 | error_channel_id: String.t(), 129 | enable: boolean(), 130 | enable_logger: boolean(), 131 | enable_discord_log: boolean(), 132 | enable_presence: boolean(), 133 | instrument_oban: boolean(), 134 | metadata: [atom()], 135 | excluded_domains: [atom()], 136 | discord_client_module: module(), 137 | supervisor_name: supervisor_name(), 138 | presence_status: String.t(), 139 | enable_go_to_repo: boolean(), 140 | go_to_repo_top_modules: [module()], 141 | repo_url: String.t(), 142 | git_sha: String.t(), 143 | discord_client: DiscoLog.Discord.API.t(), 144 | in_app_modules: [module()] 145 | } 146 | 147 | @type supervisor_name() :: atom() 148 | 149 | @doc """ 150 | Reads and validates config from global application configuration 151 | """ 152 | @spec read!() :: t() 153 | def read!() do 154 | raw_options = 155 | Application.get_all_env(:disco_log) |> Keyword.take(Keyword.keys(@configuration_schema)) 156 | 157 | case Keyword.get(raw_options, :enable) do 158 | false -> %{enable: false} 159 | _ -> validate!(raw_options) 160 | end 161 | end 162 | 163 | @doc """ 164 | See `validate/1` 165 | """ 166 | @spec validate!(options :: keyword() | map()) :: t() 167 | def validate!(options) do 168 | {:ok, config} = validate(options) 169 | config 170 | end 171 | 172 | @doc """ 173 | Validates configuration against the schema 174 | """ 175 | @spec validate(options :: keyword() | map()) :: 176 | {:ok, t()} | {:error, NimbleOptions.ValidationError.t()} 177 | def validate(%{discord_client: _} = config) do 178 | config 179 | |> Map.delete(:discord_client) 180 | |> validate() 181 | end 182 | 183 | def validate(raw_options) do 184 | with {:ok, validated} <- NimbleOptions.validate(raw_options, @compiled_schema), 185 | {:ok, validated} <- validate_optional_dependencies(validated) do 186 | config = 187 | validated 188 | |> Map.new() 189 | |> then(fn config -> 190 | client = config.discord_client_module.client(config.token) 191 | Map.put(config, :discord_client, %{client | log?: config.enable_discord_log}) 192 | end) 193 | |> then(fn config -> 194 | modules = Application.spec(config.otp_app, :modules) || [] 195 | Map.put(config, :in_app_modules, modules) 196 | end) 197 | 198 | {:ok, config} 199 | end 200 | end 201 | 202 | if Code.ensure_loaded?(Mint.WebSocket) do 203 | defp validate_optional_dependencies(validated), do: {:ok, validated} 204 | else 205 | defp validate_optional_dependencies(validated) do 206 | if value = validated[:enable_presence] do 207 | {:error, 208 | %NimbleOptions.ValidationError{ 209 | message: "optional mint_web_socket dependency is missing", 210 | key: :enable_presence, 211 | value: value 212 | }} 213 | else 214 | {:ok, validated} 215 | end 216 | end 217 | end 218 | end 219 | -------------------------------------------------------------------------------- /lib/disco_log/presence.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscoLog.Presence do 2 | @moduledoc """ 3 | A GenServer responsible for keeping the bot Online 4 | 5 | The presence server is started automatically as long as the `enable_presence` 6 | configuration flag is set. 7 | 8 | See the [standalone presence guide](standalone-presence.md) to learn how to 9 | use presence without the rest of DiscoLog 10 | """ 11 | use GenServer 12 | 13 | alias DiscoLog.WebsocketClient 14 | alias DiscoLog.Discord 15 | 16 | defstruct [ 17 | :bot_token, 18 | :discord_client, 19 | :presence_status, 20 | :registry, 21 | :websocket_client, 22 | :jitter, 23 | :heartbeat_interval, 24 | :sequence_number, 25 | :waiting_for_ack? 26 | ] 27 | 28 | def start_link(opts) do 29 | name = 30 | opts 31 | |> Keyword.fetch!(:supervisor_name) 32 | |> DiscoLog.Registry.via(__MODULE__) 33 | 34 | callers = Process.get(:"$callers", []) 35 | 36 | GenServer.start_link(__MODULE__, {opts, callers}, name: name) 37 | end 38 | 39 | @impl GenServer 40 | def init({opts, callers}) do 41 | state = %__MODULE__{ 42 | bot_token: Keyword.fetch!(opts, :bot_token), 43 | discord_client: Keyword.fetch!(opts, :discord_client), 44 | presence_status: Keyword.fetch!(opts, :presence_status), 45 | jitter: Keyword.get_lazy(opts, :jitter, fn -> :rand.uniform() end) 46 | } 47 | 48 | Process.put(:"$callers", callers) 49 | Process.flag(:trap_exit, true) 50 | 51 | {:ok, state, {:continue, :connect}} 52 | end 53 | 54 | # Connect to Gateway 55 | # https://discord.com/developers/docs/events/gateway#connecting 56 | @impl GenServer 57 | def handle_continue( 58 | :connect, 59 | %__MODULE__{discord_client: discord_client} = state 60 | ) do 61 | {:ok, uri} = Discord.get_gateway(discord_client) 62 | {:ok, client} = WebsocketClient.connect(uri.host, uri.port, "/?v=10&encoding=json") 63 | {:noreply, %{state | websocket_client: client}} 64 | end 65 | 66 | # Receive Hello event and schedule first Heartbeat 67 | # https://discord.com/developers/docs/events/gateway#hello-event 68 | def handle_continue( 69 | {:event, 70 | [ 71 | %{"op" => 10, "d" => %{"heartbeat_interval" => interval}, "s" => sequence_number} 72 | | events 73 | ]}, 74 | state 75 | ) do 76 | (interval * state.jitter) |> round() |> schedule_heartbeat() 77 | state = identify(%{state | heartbeat_interval: interval, sequence_number: sequence_number}) 78 | 79 | {:noreply, state, {:continue, {:event, events}}} 80 | end 81 | 82 | # Note Heartbeat ACK 83 | def handle_continue({:event, [%{"op" => 11} | events]}, state) do 84 | {:noreply, %{state | waiting_for_ack?: false}, {:continue, {:event, events}}} 85 | end 86 | 87 | # Respond to Heartbeat request 88 | # https://discord.com/developers/docs/events/gateway#heartbeat-requests 89 | def handle_continue( 90 | {:event, [%{"op" => 1} | events]}, 91 | %__MODULE__{websocket_client: client, sequence_number: sequence_number} = state 92 | ) do 93 | {:ok, client} = WebsocketClient.send_event(client, %{op: 1, d: sequence_number}) 94 | {:noreply, %{state | websocket_client: client}, {:continue, {:event, events}}} 95 | end 96 | 97 | def handle_continue({:event, [%{"op" => 0, "s" => s} | events]}, state) do 98 | {:noreply, %{state | sequence_number: s}, {:continue, {:event, events}}} 99 | end 100 | 101 | def handle_continue({:event, _events}, state) do 102 | {:noreply, state} 103 | end 104 | 105 | # Close connection if server failed to ACK Heartbeat 106 | # https://discord.com/developers/docs/events/gateway#heartbeat-interval-example-heartbeat-ack 107 | @impl GenServer 108 | def handle_info( 109 | :heartbeat, 110 | %__MODULE__{waiting_for_ack?: true, websocket_client: client} = state 111 | ) do 112 | {:ok, client} = WebsocketClient.begin_disconnect(client, 1008, "server missed ack") 113 | {:noreply, %{state | websocket_client: client, waiting_for_ack?: false}} 114 | end 115 | 116 | # Issue a normal scheduled Heartbeat 117 | def handle_info( 118 | :heartbeat, 119 | %__MODULE__{ 120 | websocket_client: client, 121 | heartbeat_interval: interval, 122 | sequence_number: sequence_number 123 | } = state 124 | ) do 125 | {:ok, client} = WebsocketClient.send_event(client, %{op: 1, d: sequence_number}) 126 | schedule_heartbeat(interval) 127 | {:noreply, %{state | websocket_client: client, waiting_for_ack?: true}} 128 | end 129 | 130 | def handle_info(message, %__MODULE__{websocket_client: client} = state) do 131 | case WebsocketClient.handle_message(client, message) do 132 | {:ok, :closed_by_server, reason} -> 133 | {:stop, {:shutdown, {:closed_by_server, reason}}, state} 134 | 135 | {:ok, :closed} -> 136 | {:stop, {:shutdown, :closed_by_client}, state} 137 | 138 | {:ok, client, messages} -> 139 | {:noreply, %{state | websocket_client: client}, {:continue, {:event, messages}}} 140 | 141 | {:error, _conn, error} when is_struct(error, Mint.WebSocket.UpgradeFailureError) -> 142 | {:stop, {:shutdown, error}, state} 143 | 144 | {:error, _conn, %{reason: :closed} = error, []} 145 | when is_struct(error, Mint.TransportError) -> 146 | {:stop, {:shutdown, error}, state} 147 | 148 | other -> 149 | {:stop, other, state} 150 | end 151 | end 152 | 153 | @impl GenServer 154 | def terminate({:shutdown, {:closed_by_server, _}}, _state), do: :ok 155 | def terminate({:shutdown, :closed_by_client}, _state), do: :ok 156 | def terminate({:shutdown, %Mint.TransportError{}}, _state), do: :ok 157 | 158 | def terminate(_other, %__MODULE__{websocket_client: %{state: :open, websocket: %{}} = client}) do 159 | WebsocketClient.begin_disconnect(client, 1000, "graceful disconnect") 160 | end 161 | 162 | def terminate(_other, _state), do: :ok 163 | 164 | defp schedule_heartbeat(schedule_in) do 165 | Process.send_after(self(), :heartbeat, schedule_in) 166 | end 167 | 168 | # Sending Identify event to update presence 169 | # https://discord.com/developers/docs/events/gateway#identifying 170 | defp identify(%__MODULE__{} = state) do 171 | identify_event = %{ 172 | op: 2, 173 | d: %{ 174 | token: state.bot_token, 175 | intents: 0, 176 | presence: %{ 177 | activities: [%{type: 4, state: state.presence_status, name: "Name"}], 178 | since: nil, 179 | status: "online", 180 | afk: false 181 | }, 182 | properties: %{ 183 | os: "BEAM", 184 | browser: "DiscoLog", 185 | device: "DiscoLog" 186 | } 187 | } 188 | } 189 | 190 | {:ok, client} = WebsocketClient.send_event(state.websocket_client, identify_event) 191 | %{state | websocket_client: client} 192 | end 193 | end 194 | -------------------------------------------------------------------------------- /guides/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | This guide is an introduction to DiscoLog. It will guide you through the setup of DiscoLog and how to use it. 4 | 5 | ## Install DiscoLog 6 | 7 | The first step to add DiscoLog to your application is to declare the package as a dependency in your `mix.exs` file. 8 | 9 | ```elixir 10 | defp deps do 11 | [ 12 | {:disco_log, "~> 1.0.3"} 13 | ] 14 | end 15 | ``` 16 | 17 | Then run the following command to fetch the dependencies. 18 | 19 | ```bash 20 | mix deps.get 21 | ``` 22 | 23 | ## Setup the Discord Server 24 | 25 | You need to register a [Discord Account](https://discord.com/) 26 | 27 | ### Create a community Discord Server 28 | *A Discord community server needs to have a forum-type channel, which we use for error tracking.* 29 | 30 | ![Create Server step 1](assets/001.png) 31 | ![Create Server step 2](assets/002.png) 32 | ![Create Server step 3](assets/003.png) 33 | 34 | ### Edit the Discord Server settings 35 | 36 | *Right-click on the server and select `Server Settings` > `Community Settings`* 37 | ![Edit Server step 4](assets/004.png) 38 | ![Edit Server step 5](assets/005.png) 39 | ![Edit Server step 6](assets/006.png) 40 | ![Edit Server step 7](assets/007.png) 41 | ![Edit Server step 8](assets/008.png) 42 | 43 | *Copy the server ID, it will be needed later* 44 | 45 | +*If you don't see `Copy Server ID` in the UI, enable developer mode in Settings -> Advanced -> Developer Mode.* 46 | 47 | ![Edit Server step 9](assets/009.png) 48 | 49 | ## Create a Discord Bot 50 | 51 | Go to the [developers portal](https://discord.com/developers/applications) 52 | 53 | ![Create bot 1](assets/010.png) 54 | ![Create bot 2](assets/011.png) 55 | 56 | *Disable User Install and add the scope `bot` and the permissions `Attach Files`, `Manage Channels`, `Manage Threads`, `Send Messages`, `Send Messages in Threads`* 57 | ![Bot settings](assets/012.png) 58 | 59 | *Generate and copy the bot token, it will be needed later* 60 | ![Bot token](assets/013.png) 61 | 62 | ## Add Bot to your Server 63 | 64 | *Go to the installation menu and open the installation link* 65 | 66 | ![Install Bot on your server step 1](assets/014.png) 67 | 68 | *Follow the steps* 69 | 70 | ![Install Bot on your server step 2](assets/015.png) 71 | ![Install Bot on your server step 3](assets/016.png) 72 | ![Install Bot on your server step 4](assets/017.png) 73 | 74 | 75 | ## Create DiscoLog channels 76 | 77 | Edit your `config/dev.exs` and add the following configuration with the bot token and the server ID you copied earlier. 78 | 79 | ```elixir 80 | config :disco_log, 81 | otp_app: :app_name, 82 | token: "YOUR_BOT.TOKEN", 83 | guild_id: "YOUR_SERVER_ID", 84 | category_id: "", 85 | occurrences_channel_id: "", 86 | info_channel_id: "", 87 | error_channel_id: "" 88 | ``` 89 | 90 | Run the mix task 91 | ```elixir 92 | mix disco_log.create 93 | ``` 94 | 95 | It will create and output the rest of the necessary configuration for you. 96 | Use this configuration for your production environment or add it to your dev config if you want to test. 97 | 98 | Confirm that everything is working smoothly by running the following mix command it will put a log in each channels. 99 | ```elixir 100 | mix disco_log.sample 101 | ``` 102 | 103 | *How it should look like* 104 | 105 | ![Sample log 1](assets/018.png) 106 | ![Sample log 2](assets/019.png) 107 | 108 | 109 | ## After setup 110 | 111 | When you confirmed that the setup is working you should put this config to disable DiscoLog in `dev.exs` and `test.exs` env. 112 | 113 | ```elixir 114 | config :disco_log, 115 | enable: false 116 | ``` 117 | 118 | ## Presence Status 119 | 120 | DiscoLog can optionally set your bot's status to **Online**, allowing you to display a custom presence message. 121 | 122 | ### Steps to Enable Presence Status 123 | 124 | 1. **Add the `mint_web_socket` Package** 125 | Update your dependencies in `mix.exs`: 126 | 127 | ```elixir 128 | defp deps do 129 | [ 130 | {:disco_log, "~> 1.0.0"}, 131 | {:mint_web_socket, "~> 1.0"} 132 | ] 133 | end 134 | ``` 135 | 136 | 2. **Configure the Presence Settings** 137 | Add the following to your `config.exs` or `prod/config.exs`: 138 | 139 | ```elixir 140 | config :disco_log, 141 | enable_presence: true, 142 | presence_status: "🪩 Disco Logging" # Optional, defaults to this value 143 | ``` 144 | 145 | *Bot with Presence* 146 | 147 | ![Presence status](assets/presence.png) 148 | 149 | ## Go to Repo Feature 150 | 151 | The **Go to Repo** feature allows DiscoLog to link directly to your code repository (e.g., GitHub). This enables users to easily access the specific version of the code related to a log entry. 152 | 153 | ### Example Interface 154 | *Link to GitHub code* 155 | ![Go to code](assets/go-to-code.png) 156 | 157 | ### Configuration Instructions 158 | 159 | To enable this feature, add the following configuration to your `prod/config.exs`: 160 | 161 | ```elixir 162 | config :disco_log, 163 | enable_go_to_repo: true, 164 | go_to_repo_top_modules: ["DemoWeb"], # Optional, see notes below 165 | repo_url: "https://github.com/mrdotb/disco-log/blob", 166 | git_sha: System.fetch_env!("GIT_SHA") # See notes on setting the GIT_SHA below 167 | ``` 168 | 169 | ### Setting the `GIT_SHA` 170 | 171 | The `GIT_SHA` should be the commit hash of the current code version. This ensures links reference the correct version of the code. Here’s how you can set it, depending on your deployment process: 172 | 173 | #### 1. **Bare Metal Deployments** 174 | If you build your release on your own machine, you can set the environment variable using this command: 175 | 176 | ```bash 177 | GIT_SHA=`git rev-parse HEAD` MIX_ENV=prod mix release 178 | ``` 179 | 180 | #### 2. **Docker Deployments** 181 | When building a Docker image, pass the `GIT_SHA` as a build argument: 182 | 183 | **Build Command**: 184 | ```bash 185 | docker build --build-arg GIT_SHA=`git rev-parse HEAD` . 186 | ``` 187 | 188 | **Dockerfile**: 189 | ```Dockerfile 190 | ARG GIT_SHA 191 | 192 | # Set build environment variables 193 | ENV MIX_ENV="prod" 194 | ENV GIT_SHA=${GIT_SHA} 195 | ``` 196 | 197 | #### 3. **CI/CD Pipelines** 198 | In CI/CD environments (e.g., GitHub Actions), the `GIT_SHA` is often available as a predefined environment variable. You can pass it during the build process as follows: 199 | 200 | **Example GitHub Actions Workflow**: 201 | ```yaml 202 | - name: Build and Push Docker Image to GHCR 203 | uses: docker/build-push-action@v5 204 | with: 205 | context: . 206 | push: true 207 | tags: ${{ steps.meta.outputs.tags }} 208 | labels: ${{ steps.meta.outputs.labels }} 209 | build-args: | 210 | BUILD_METADATA=${{ steps.meta.outputs.json }} 211 | ERL_FLAGS=+JPperf true 212 | GIT_SHA=${{ github.sha }} 213 | ``` 214 | 215 | ### Notes 216 | - The `repo_url` configuration should point to the root of your repository's code (e.g., `https://github.com///blob`). 217 | - The `go_to_repo_top_modules` configuration is optional. Use it only if your project contains external modules within the repository. 218 | 219 | Enjoy using DiscoLog! 220 | -------------------------------------------------------------------------------- /test/disco_log/integrations/plug_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DiscoLog.PlugTest do 2 | use DiscoLog.Test.Case, async: true 3 | 4 | import Mox 5 | require Logger 6 | alias DiscoLog.Discord.API 7 | 8 | @moduletag config: [supervisor_name: __MODULE__] 9 | 10 | setup {LoggerHandlerKit.Arrange, :ensure_per_handler_translation} 11 | 12 | setup :setup_supervisor 13 | setup :setup_logger_handler 14 | setup :verify_on_exit! 15 | 16 | defmodule AppRouter do 17 | use Plug.Router 18 | 19 | plug(DiscoLog.Integrations.Plug) 20 | plug(:match) 21 | plug(:dispatch) 22 | 23 | forward("/", to: LoggerHandlerKit.Plug) 24 | end 25 | 26 | describe "Bandit" do 27 | test "plug exception", %{handler_ref: ref} do 28 | pid = self() 29 | 30 | expect(API.Mock, :request, 2, fn 31 | client, method, url, [{:path_params, [channel_id: "info_channel_id"]} | _] = opts -> 32 | API.Stub.request(client, method, url, opts) 33 | 34 | client, method, url, opts -> 35 | send(pid, opts) 36 | API.Stub.request(client, method, url, opts) 37 | end) 38 | 39 | LoggerHandlerKit.Act.plug_error(:exception, Bandit, AppRouter) 40 | LoggerHandlerKit.Assert.assert_logged(ref) 41 | LoggerHandlerKit.Assert.assert_logged(ref) 42 | 43 | assert_receive [ 44 | {:path_params, [channel_id: "occurrences_channel_id"]}, 45 | {:form_multipart, [payload_json: body]} 46 | ] 47 | 48 | assert %{ 49 | applied_tags: ["stub_plug_tag_id"], 50 | message: %{ 51 | components: [ 52 | %{ 53 | type: 10, 54 | content: 55 | """ 56 | **Kind:** `RuntimeError` 57 | **Reason:** `oops` 58 | **Source:** \ 59 | """ <> _ 60 | }, 61 | %{}, 62 | %{} 63 | ] 64 | } 65 | } = body 66 | end 67 | 68 | test "plug throw", %{handler_ref: ref} do 69 | pid = self() 70 | 71 | expect(API.Mock, :request, 2, fn 72 | client, method, url, [{:path_params, [channel_id: "info_channel_id"]} | _] = opts -> 73 | API.Stub.request(client, method, url, opts) 74 | 75 | client, method, url, opts -> 76 | send(pid, opts) 77 | API.Stub.request(client, method, url, opts) 78 | end) 79 | 80 | LoggerHandlerKit.Act.plug_error(:throw, Bandit, AppRouter) 81 | LoggerHandlerKit.Assert.assert_logged(ref) 82 | LoggerHandlerKit.Assert.assert_logged(ref) 83 | 84 | assert_receive [ 85 | {:path_params, [channel_id: "occurrences_channel_id"]}, 86 | {:form_multipart, [payload_json: body]} 87 | ] 88 | 89 | assert %{ 90 | applied_tags: ["stub_plug_tag_id"], 91 | message: %{ 92 | components: [ 93 | %{ 94 | type: 10, 95 | content: 96 | """ 97 | **Reason:** `** (throw) \"catch!\"` 98 | **Source:** \ 99 | """ <> _ 100 | }, 101 | %{}, 102 | %{} 103 | ] 104 | } 105 | } = body 106 | end 107 | 108 | test "plug exit", %{handler_ref: ref} do 109 | pid = self() 110 | 111 | expect(API.Mock, :request, 2, fn 112 | client, method, url, [{:path_params, [channel_id: "info_channel_id"]} | _] = opts -> 113 | API.Stub.request(client, method, url, opts) 114 | 115 | client, method, url, opts -> 116 | send(pid, opts) 117 | API.Stub.request(client, method, url, opts) 118 | end) 119 | 120 | LoggerHandlerKit.Act.plug_error(:exit, Bandit, AppRouter) 121 | LoggerHandlerKit.Assert.assert_logged(ref) 122 | LoggerHandlerKit.Assert.assert_logged(ref) 123 | 124 | assert_receive [ 125 | {:path_params, [channel_id: "occurrences_channel_id"]}, 126 | {:form_multipart, [payload_json: body]} 127 | ] 128 | 129 | assert %{ 130 | applied_tags: ["stub_plug_tag_id"], 131 | message: %{ 132 | components: [ 133 | %{ 134 | type: 10, 135 | content: 136 | """ 137 | **Reason:** `** (exit) \"i quit\"` 138 | **Source:** \ 139 | """ <> _ 140 | }, 141 | %{}, 142 | %{} 143 | ] 144 | } 145 | } = body 146 | end 147 | end 148 | 149 | describe "Cowboy" do 150 | test "plug exception", %{handler_ref: ref} do 151 | pid = self() 152 | 153 | expect(API.Mock, :request, fn 154 | client, method, url, opts -> 155 | send(pid, opts) 156 | API.Stub.request(client, method, url, opts) 157 | end) 158 | 159 | LoggerHandlerKit.Act.plug_error(:exception, Plug.Cowboy, AppRouter) 160 | LoggerHandlerKit.Assert.assert_logged(ref) 161 | 162 | assert_receive [ 163 | {:path_params, [channel_id: "occurrences_channel_id"]}, 164 | {:form_multipart, [payload_json: body]} 165 | ] 166 | 167 | assert %{ 168 | applied_tags: ["stub_plug_tag_id"], 169 | message: %{ 170 | components: [ 171 | %{ 172 | type: 10, 173 | content: 174 | """ 175 | **Kind:** `RuntimeError` 176 | **Reason:** `oops` 177 | **Source:** \ 178 | """ <> _ 179 | }, 180 | %{}, 181 | %{} 182 | ] 183 | } 184 | } = body 185 | end 186 | 187 | test "plug throw", %{handler_ref: ref} do 188 | pid = self() 189 | 190 | expect(API.Mock, :request, fn 191 | client, method, url, opts -> 192 | send(pid, opts) 193 | API.Stub.request(client, method, url, opts) 194 | end) 195 | 196 | LoggerHandlerKit.Act.plug_error(:throw, Plug.Cowboy, AppRouter) 197 | LoggerHandlerKit.Assert.assert_logged(ref) 198 | 199 | assert_receive [ 200 | {:path_params, [channel_id: "occurrences_channel_id"]}, 201 | {:form_multipart, [payload_json: body]} 202 | ] 203 | 204 | assert %{ 205 | applied_tags: ["stub_plug_tag_id"], 206 | message: %{ 207 | components: [ 208 | %{ 209 | type: 10, 210 | content: 211 | """ 212 | **Reason:** `** (throw) \"catch!\"` 213 | **Source:** \ 214 | """ <> _ 215 | }, 216 | %{}, 217 | %{} 218 | ] 219 | } 220 | } = body 221 | end 222 | 223 | test "plug exit", %{handler_ref: ref} do 224 | pid = self() 225 | 226 | expect(API.Mock, :request, fn 227 | client, method, url, opts -> 228 | send(pid, opts) 229 | API.Stub.request(client, method, url, opts) 230 | end) 231 | 232 | LoggerHandlerKit.Act.plug_error(:exit, Plug.Cowboy, AppRouter) 233 | LoggerHandlerKit.Assert.assert_logged(ref) 234 | 235 | assert_receive [ 236 | {:path_params, [channel_id: "occurrences_channel_id"]}, 237 | {:form_multipart, [payload_json: body]} 238 | ] 239 | 240 | assert %{ 241 | applied_tags: ["stub_plug_tag_id"], 242 | message: %{ 243 | components: [ 244 | %{ 245 | type: 10, 246 | content: 247 | """ 248 | **Reason:** `** (exit) \"i quit\"`\ 249 | """ <> _ 250 | }, 251 | %{}, 252 | %{} 253 | ] 254 | } 255 | } = body 256 | end 257 | end 258 | end 259 | -------------------------------------------------------------------------------- /test/support/discord/api/stub.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscoLog.Discord.API.Stub do 2 | @moduledoc """ 3 | A collection of canned API responses used as default stubs for `DiscoLog.Discord.API.Mock` 4 | """ 5 | @behaviour DiscoLog.Discord.API 6 | 7 | @impl DiscoLog.Discord.API 8 | def client(_token), 9 | do: %DiscoLog.Discord.API{client: :stub_client, module: DiscoLog.Discord.API.Mock} 10 | 11 | @impl DiscoLog.Discord.API 12 | def request(_client, :get, "/gateway/bot", _opts) do 13 | {:ok, 14 | %{ 15 | status: 200, 16 | headers: %{}, 17 | body: %{ 18 | "session_start_limit" => %{ 19 | "max_concurrency" => 1, 20 | "remaining" => 988, 21 | "reset_after" => 3_297_000, 22 | "total" => 1000 23 | }, 24 | "shards" => 1, 25 | "url" => "wss://gateway.discord.gg" 26 | } 27 | }} 28 | end 29 | 30 | def request(_client, :get, "/guilds/:guild_id/threads/active", _opts) do 31 | {:ok, 32 | %{ 33 | status: 200, 34 | headers: %{}, 35 | body: %{ 36 | "has_more" => false, 37 | "members" => [], 38 | "threads" => [ 39 | %{ 40 | "applied_tags" => ["1306066065390043156", "1306066121325285446"], 41 | "bitrate" => 64_000, 42 | "flags" => 0, 43 | "guild_id" => "1302395532735414282", 44 | "id" => "stub_thread_id", 45 | "last_message_id" => "1327070193624547442", 46 | "member_count" => 1, 47 | "message_count" => 1, 48 | "name" => "FNGRPT Elixir.RuntimeError", 49 | "owner_id" => "1302396835582836757", 50 | "parent_id" => "stub_occurrences_channel_id", 51 | "rate_limit_per_user" => 0, 52 | "rtc_region" => nil, 53 | "thread_metadata" => %{ 54 | "archive_timestamp" => "2025-01-10T00:23:09.502000+00:00", 55 | "archived" => false, 56 | "auto_archive_duration" => 4320, 57 | "create_timestamp" => "2025-01-10T00:23:09.502000+00:00", 58 | "locked" => false 59 | }, 60 | "total_message_sent" => 1, 61 | "type" => 11, 62 | "user_limit" => 0 63 | }, 64 | %{ 65 | "applied_tags" => ["1306066065390043156", "1306066121325285446"], 66 | "bitrate" => 64_000, 67 | "flags" => 0, 68 | "guild_id" => "1302395532735414282", 69 | "id" => "stub_thread_id", 70 | "last_message_id" => "1327070193624547442", 71 | "member_count" => 1, 72 | "message_count" => 1, 73 | "name" => "Non-DiscoLog Thread", 74 | "owner_id" => "1302396835582836757", 75 | "parent_id" => "stub_occurrences_channel_id", 76 | "rate_limit_per_user" => 0, 77 | "rtc_region" => nil, 78 | "thread_metadata" => %{ 79 | "archive_timestamp" => "2025-01-10T00:23:09.502000+00:00", 80 | "archived" => false, 81 | "auto_archive_duration" => 4320, 82 | "create_timestamp" => "2025-01-10T00:23:09.502000+00:00", 83 | "locked" => false 84 | }, 85 | "total_message_sent" => 1, 86 | "type" => 11, 87 | "user_limit" => 0 88 | } 89 | ] 90 | } 91 | }} 92 | end 93 | 94 | def request(_client, :get, "/channels/:channel_id", _opts) do 95 | {:ok, 96 | %{ 97 | status: 200, 98 | headers: %{}, 99 | body: %{ 100 | "available_tags" => [ 101 | %{ 102 | "emoji_id" => nil, 103 | "emoji_name" => nil, 104 | "id" => "stub_plug_tag_id", 105 | "moderated" => false, 106 | "name" => "plug" 107 | }, 108 | %{ 109 | "emoji_id" => nil, 110 | "emoji_name" => nil, 111 | "id" => "stub_live_view_tag_id", 112 | "moderated" => false, 113 | "name" => "live_view" 114 | }, 115 | %{ 116 | "emoji_id" => nil, 117 | "emoji_name" => nil, 118 | "id" => "stub_oban_tag_id", 119 | "moderated" => false, 120 | "name" => "oban" 121 | } 122 | ], 123 | "default_forum_layout" => 0, 124 | "default_reaction_emoji" => nil, 125 | "default_sort_order" => nil, 126 | "flags" => 0, 127 | "guild_id" => "1302395532735414282", 128 | "icon_emoji" => nil, 129 | "id" => "1306065664909512784", 130 | "last_message_id" => "1327070191821131865", 131 | "name" => "occurrences", 132 | "nsfw" => false, 133 | "parent_id" => "1306065439398428694", 134 | "permission_overwrites" => [], 135 | "position" => 11, 136 | "rate_limit_per_user" => 0, 137 | "template" => "", 138 | "theme_color" => nil, 139 | "topic" => nil, 140 | "type" => 15 141 | } 142 | }} 143 | end 144 | 145 | def request(_client, :post, "/channels/:channel_id/messages", _opts) do 146 | {:ok, 147 | %{ 148 | status: 200, 149 | headers: %{}, 150 | body: %{ 151 | "attachments" => [], 152 | "author" => %{ 153 | "accent_color" => nil, 154 | "avatar" => nil, 155 | "avatar_decoration_data" => nil, 156 | "banner" => nil, 157 | "banner_color" => nil, 158 | "bot" => true, 159 | "clan" => nil, 160 | "discriminator" => "9087", 161 | "flags" => 0, 162 | "global_name" => nil, 163 | "id" => "1302396835582836757", 164 | "primary_guild" => nil, 165 | "public_flags" => 0, 166 | "username" => "Disco Log" 167 | }, 168 | "channel_id" => "1306065758723379293", 169 | "components" => [], 170 | "content" => "Hello, World!", 171 | "edited_timestamp" => nil, 172 | "embeds" => [], 173 | "flags" => 0, 174 | "id" => "1327747295587995708", 175 | "mention_everyone" => false, 176 | "mention_roles" => [], 177 | "mentions" => [], 178 | "pinned" => false, 179 | "timestamp" => "2025-01-11T21:13:43.620000+00:00", 180 | "tts" => false, 181 | "type" => 0 182 | } 183 | }} 184 | end 185 | 186 | def request(_client, :post, "/channels/:channel_id/threads", _opts) do 187 | {:ok, 188 | %{ 189 | status: 201, 190 | headers: %{}, 191 | body: %{ 192 | "bitrate" => 64_000, 193 | "flags" => 0, 194 | "guild_id" => "1302395532735414282", 195 | "id" => "1327765635022852098", 196 | "last_message_id" => "1327765635022852098", 197 | "member" => %{ 198 | "flags" => 1, 199 | "id" => "1327765635022852098", 200 | "join_timestamp" => "2025-01-11T22:26:36.114358+00:00", 201 | "mute_config" => nil, 202 | "muted" => false, 203 | "user_id" => "1302396835582836757" 204 | }, 205 | "member_count" => 1, 206 | "message" => %{ 207 | "attachments" => [], 208 | "author" => %{ 209 | "accent_color" => nil, 210 | "avatar" => nil, 211 | "avatar_decoration_data" => nil, 212 | "banner" => nil, 213 | "banner_color" => nil, 214 | "bot" => true, 215 | "clan" => nil, 216 | "discriminator" => "9087", 217 | "flags" => 0, 218 | "global_name" => nil, 219 | "id" => "1302396835582836757", 220 | "primary_guild" => nil, 221 | "public_flags" => 0, 222 | "username" => "Disco Log" 223 | }, 224 | "channel_id" => "1327765635022852098", 225 | "components" => [], 226 | "content" => 227 | "**At:** \n **Kind:** ``\n **Reason:** ``\n **Source Line:** ``\n **Source Function:** ``\n **Fingerprint:** `foo`", 228 | "edited_timestamp" => nil, 229 | "embeds" => [], 230 | "flags" => 0, 231 | "id" => "1327765635022852098", 232 | "mention_everyone" => false, 233 | "mention_roles" => [], 234 | "mentions" => [], 235 | "pinned" => false, 236 | "position" => 0, 237 | "timestamp" => "2025-01-11T22:26:36.082000+00:00", 238 | "tts" => false, 239 | "type" => 0 240 | }, 241 | "message_count" => 0, 242 | "name" => "foo", 243 | "owner_id" => "1302396835582836757", 244 | "parent_id" => "1306065664909512784", 245 | "rate_limit_per_user" => 0, 246 | "rtc_region" => nil, 247 | "thread_metadata" => %{ 248 | "archive_timestamp" => "2025-01-11T22:26:36.082000+00:00", 249 | "archived" => false, 250 | "auto_archive_duration" => 4320, 251 | "create_timestamp" => "2025-01-11T22:26:36.082000+00:00", 252 | "locked" => false 253 | }, 254 | "total_message_sent" => 0, 255 | "type" => 11, 256 | "user_limit" => 0 257 | } 258 | }} 259 | end 260 | end 261 | -------------------------------------------------------------------------------- /.credo.exs: -------------------------------------------------------------------------------- 1 | # This file contains the configuration for Credo and you are probably reading 2 | # this after creating it with `mix credo.gen.config`. 3 | # 4 | # If you find anything wrong or unclear in this file, please report an 5 | # issue on GitHub: https://github.com/rrrene/credo/issues 6 | # 7 | %{ 8 | # 9 | # You can have as many configs as you like in the `configs:` field. 10 | configs: [ 11 | %{ 12 | # 13 | # Run any config using `mix credo -C `. If no config name is given 14 | # "default" is used. 15 | # 16 | name: "default", 17 | # 18 | # These are the files included in the analysis: 19 | files: %{ 20 | # 21 | # You can give explicit globs or simply directories. 22 | # In the latter case `**/*.{ex,exs}` will be used. 23 | # 24 | included: [ 25 | "lib/", 26 | "src/", 27 | "test/", 28 | "web/", 29 | "apps/*/lib/", 30 | "apps/*/src/", 31 | "apps/*/test/", 32 | "apps/*/web/" 33 | ], 34 | excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] 35 | }, 36 | # 37 | # Load and configure plugins here: 38 | # 39 | plugins: [], 40 | # 41 | # If you create your own checks, you must specify the source files for 42 | # them here, so they can be loaded by Credo before running the analysis. 43 | # 44 | requires: [], 45 | # 46 | # If you want to enforce a style guide and need a more traditional linting 47 | # experience, you can change `strict` to `true` below: 48 | # 49 | strict: false, 50 | # 51 | # To modify the timeout for parsing files, change this value: 52 | # 53 | parse_timeout: 5000, 54 | # 55 | # If you want to use uncolored output by default, you can change `color` 56 | # to `false` below: 57 | # 58 | color: true, 59 | # 60 | # You can customize the parameters of any check by adding a second element 61 | # to the tuple. 62 | # 63 | # To disable a check put `false` as second element: 64 | # 65 | # {Credo.Check.Design.DuplicatedCode, false} 66 | # 67 | checks: %{ 68 | enabled: [ 69 | # 70 | ## Consistency Checks 71 | # 72 | {Credo.Check.Consistency.ExceptionNames, []}, 73 | {Credo.Check.Consistency.LineEndings, []}, 74 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 75 | {Credo.Check.Consistency.SpaceAroundOperators, []}, 76 | {Credo.Check.Consistency.SpaceInParentheses, []}, 77 | {Credo.Check.Consistency.TabsOrSpaces, []}, 78 | 79 | # 80 | ## Design Checks 81 | # 82 | # You can customize the priority of any check 83 | # Priority values are: `low, normal, high, higher` 84 | # 85 | {Credo.Check.Design.AliasUsage, 86 | [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, 87 | {Credo.Check.Design.TagFIXME, []}, 88 | # You can also customize the exit_status of each check. 89 | # If you don't want TODO comments to cause `mix credo` to fail, just 90 | # set this value to 0 (zero). 91 | # 92 | {Credo.Check.Design.TagTODO, [exit_status: 2]}, 93 | 94 | # 95 | ## Readability Checks 96 | # 97 | {Credo.Check.Readability.AliasOrder, []}, 98 | {Credo.Check.Readability.FunctionNames, []}, 99 | {Credo.Check.Readability.LargeNumbers, []}, 100 | {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, 101 | {Credo.Check.Readability.ModuleAttributeNames, []}, 102 | {Credo.Check.Readability.ModuleDoc, []}, 103 | {Credo.Check.Readability.ModuleNames, []}, 104 | {Credo.Check.Readability.ParenthesesInCondition, []}, 105 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, 106 | {Credo.Check.Readability.PipeIntoAnonymousFunctions, []}, 107 | {Credo.Check.Readability.PredicateFunctionNames, []}, 108 | {Credo.Check.Readability.PreferImplicitTry, []}, 109 | {Credo.Check.Readability.RedundantBlankLines, []}, 110 | {Credo.Check.Readability.Semicolons, []}, 111 | {Credo.Check.Readability.SpaceAfterCommas, []}, 112 | {Credo.Check.Readability.StringSigils, []}, 113 | {Credo.Check.Readability.TrailingBlankLine, []}, 114 | {Credo.Check.Readability.TrailingWhiteSpace, []}, 115 | {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, 116 | {Credo.Check.Readability.VariableNames, []}, 117 | {Credo.Check.Readability.WithSingleClause, []}, 118 | 119 | # 120 | ## Refactoring Opportunities 121 | # 122 | {Credo.Check.Refactor.Apply, []}, 123 | {Credo.Check.Refactor.CondStatements, []}, 124 | {Credo.Check.Refactor.CyclomaticComplexity, []}, 125 | {Credo.Check.Refactor.FilterCount, []}, 126 | {Credo.Check.Refactor.FilterFilter, []}, 127 | {Credo.Check.Refactor.FunctionArity, []}, 128 | {Credo.Check.Refactor.LongQuoteBlocks, []}, 129 | {Credo.Check.Refactor.MapJoin, []}, 130 | {Credo.Check.Refactor.MatchInCondition, []}, 131 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 132 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 133 | {Credo.Check.Refactor.Nesting, []}, 134 | {Credo.Check.Refactor.RedundantWithClauseResult, []}, 135 | {Credo.Check.Refactor.RejectReject, []}, 136 | {Credo.Check.Refactor.UnlessWithElse, []}, 137 | {Credo.Check.Refactor.WithClauses, []}, 138 | 139 | # 140 | ## Warnings 141 | # 142 | {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, 143 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 144 | {Credo.Check.Warning.Dbg, []}, 145 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 146 | {Credo.Check.Warning.IExPry, []}, 147 | {Credo.Check.Warning.IoInspect, []}, 148 | {Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, [metadata_keys: [:extra]]}, 149 | {Credo.Check.Warning.OperationOnSameValues, []}, 150 | {Credo.Check.Warning.OperationWithConstantResult, []}, 151 | {Credo.Check.Warning.RaiseInsideRescue, []}, 152 | {Credo.Check.Warning.SpecWithStruct, []}, 153 | {Credo.Check.Warning.UnsafeExec, []}, 154 | {Credo.Check.Warning.UnusedEnumOperation, []}, 155 | {Credo.Check.Warning.UnusedFileOperation, []}, 156 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 157 | {Credo.Check.Warning.UnusedListOperation, []}, 158 | {Credo.Check.Warning.UnusedPathOperation, []}, 159 | {Credo.Check.Warning.UnusedRegexOperation, []}, 160 | {Credo.Check.Warning.UnusedStringOperation, []}, 161 | {Credo.Check.Warning.UnusedTupleOperation, []}, 162 | {Credo.Check.Warning.WrongTestFileExtension, []} 163 | ], 164 | disabled: [ 165 | # 166 | # Checks scheduled for next check update (opt-in for now) 167 | {Credo.Check.Refactor.UtcNowTruncate, []}, 168 | 169 | # 170 | # Controversial and experimental checks (opt-in, just move the check to `:enabled` 171 | # and be sure to use `mix credo --strict` to see low priority checks) 172 | # 173 | {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, 174 | {Credo.Check.Consistency.UnusedVariableNames, []}, 175 | {Credo.Check.Design.DuplicatedCode, []}, 176 | {Credo.Check.Design.SkipTestWithoutComment, []}, 177 | {Credo.Check.Readability.AliasAs, []}, 178 | {Credo.Check.Readability.BlockPipe, []}, 179 | {Credo.Check.Readability.ImplTrue, []}, 180 | {Credo.Check.Readability.MultiAlias, []}, 181 | {Credo.Check.Readability.NestedFunctionCalls, []}, 182 | {Credo.Check.Readability.OneArityFunctionInPipe, []}, 183 | {Credo.Check.Readability.OnePipePerLine, []}, 184 | {Credo.Check.Readability.SeparateAliasRequire, []}, 185 | {Credo.Check.Readability.SingleFunctionToBlockPipe, []}, 186 | {Credo.Check.Readability.SinglePipe, []}, 187 | {Credo.Check.Readability.Specs, []}, 188 | {Credo.Check.Readability.StrictModuleLayout, []}, 189 | {Credo.Check.Readability.WithCustomTaggedTuple, []}, 190 | {Credo.Check.Refactor.ABCSize, []}, 191 | {Credo.Check.Refactor.AppendSingleItem, []}, 192 | {Credo.Check.Refactor.DoubleBooleanNegation, []}, 193 | {Credo.Check.Refactor.FilterReject, []}, 194 | {Credo.Check.Refactor.IoPuts, []}, 195 | {Credo.Check.Refactor.MapMap, []}, 196 | {Credo.Check.Refactor.ModuleDependencies, []}, 197 | {Credo.Check.Refactor.NegatedIsNil, []}, 198 | {Credo.Check.Refactor.PassAsyncInTestCases, []}, 199 | {Credo.Check.Refactor.PipeChainStart, []}, 200 | {Credo.Check.Refactor.RejectFilter, []}, 201 | {Credo.Check.Refactor.VariableRebinding, []}, 202 | {Credo.Check.Warning.LazyLogging, []}, 203 | {Credo.Check.Warning.LeakyEnvironment, []}, 204 | {Credo.Check.Warning.MapGetUnsafePass, []}, 205 | {Credo.Check.Warning.MixEnv, []}, 206 | {Credo.Check.Warning.UnsafeToAtom, []} 207 | 208 | # {Credo.Check.Refactor.MapInto, []}, 209 | 210 | # 211 | # Custom checks can be created using `mix credo.gen.check`. 212 | # 213 | ] 214 | } 215 | } 216 | ] 217 | } 218 | -------------------------------------------------------------------------------- /dev.exs: -------------------------------------------------------------------------------- 1 | ####################################### 2 | # Development Server for DiscoLog. 3 | # 4 | # Based on PhoenixLiveDashboard code. 5 | # 6 | # Usage: 7 | # 8 | # $ iex -S mix dev 9 | ####################################### 10 | Logger.configure(level: :debug) 11 | 12 | # Get configuration 13 | Config.Reader.read!("config/config.exs", env: :dev) 14 | 15 | # Configures the endpoint 16 | Application.put_env(:disco_log, DemoWeb.Endpoint, 17 | adapter: Bandit.PhoenixAdapter, 18 | url: [host: "localhost"], 19 | secret_key_base: "Hu4qQN3iKzTV4fJxhorPQlA/osH9fAMtbtjVS58PFgfw3ja5Z18Q/WSNR9wP4OfW", 20 | live_view: [signing_salt: "hMegieSe"], 21 | http: [port: System.get_env("PORT") || 4000], 22 | debug_errors: true, 23 | check_origin: false, 24 | pubsub_server: Demo.PubSub, 25 | live_reload: [ 26 | patterns: [ 27 | ~r"dev.exs$", 28 | ~r"dist/.*(js|css|png|jpeg|jpg|gif|svg)$", 29 | ~r"lib/error_tracker/web/(live|views)/.*(ex)$", 30 | ~r"lib/error_tracker/web/templates/.*(ex)$" 31 | ] 32 | ] 33 | ) 34 | 35 | # Configures Oban 36 | Application.put_env(:disco_log, Demo.Repo, database: "dev.db") 37 | 38 | defmodule Demo.Repo do 39 | use Ecto.Repo, 40 | adapter: Ecto.Adapters.SQLite3, 41 | otp_app: :disco_log 42 | end 43 | 44 | defmodule Migration0 do 45 | use Ecto.Migration 46 | 47 | def change do 48 | Oban.Migrations.up() 49 | end 50 | end 51 | 52 | defmodule DemoWeb.PageController do 53 | import Plug.Conn 54 | 55 | def init(opts), do: opts 56 | 57 | def call(conn, :index) do 58 | content(conn, """ 59 |

DiscoLog Dev Server

60 | 61 |

Phoenix

62 | 63 | 64 | 65 | 66 | 67 |

Liveview

68 | 69 | 70 | 71 | 72 | 73 |

Logging example

74 | 75 | 76 | 77 | 78 | 79 |

Oban example

80 | 81 | 82 | 83 | 84 | 85 |

Should not generate errors

86 | 87 | """) 88 | end 89 | 90 | def call(conn, :noroute) do 91 | raise Phoenix.Router.NoRouteError, conn: conn, router: DiscoLogDevWeb.Router 92 | end 93 | 94 | def call(_conn, :exception) do 95 | raise "This is a controller exception" 96 | end 97 | 98 | def call(_conn, :exit) do 99 | exit(:timeout) 100 | end 101 | 102 | defp content(conn, content) do 103 | conn 104 | |> put_resp_header("content-type", "text/html") 105 | |> send_resp(200, "#{content}") 106 | end 107 | end 108 | 109 | defmodule DemoWeb.LogController do 110 | import Phoenix.Controller 111 | 112 | require Logger 113 | 114 | def init(opts), do: opts 115 | 116 | def call(conn, :new_user) do 117 | Logger.info(""" 118 | 🎉 New User Registered! 119 | ✨ Username: Bob 120 | 📧 Email: bob@bob.fr 121 | """) 122 | 123 | conn 124 | |> redirect(to: "/") 125 | end 126 | 127 | def call(conn, :user_upgrade) do 128 | Logger.info(""" 129 | 🚀 Upgrade to a Paid Plan! 130 | ✨ Username: Bob 131 | 💼 Plan: Pro 132 | """) 133 | 134 | conn 135 | |> redirect(to: "/") 136 | end 137 | 138 | def call(conn, :extra) do 139 | Logger.info( 140 | """ 141 | ✨ Extra Log ! 142 | 📎 With attachments 143 | """, 144 | extra: %{ 145 | username: "Bob", 146 | id: 1, 147 | and: "more", 148 | stuff: "here" 149 | } 150 | ) 151 | 152 | conn 153 | |> redirect(to: "/") 154 | end 155 | 156 | def call(conn, :long_extra) do 157 | Logger.info( 158 | """ 159 | ✨ Extra Long Log ! 160 | 📎 With `conn` as attachment 161 | """, 162 | extra: conn 163 | ) 164 | 165 | conn 166 | |> redirect(to: "/") 167 | end 168 | end 169 | 170 | defmodule DemoWeb.ObanController do 171 | import Phoenix.Controller 172 | use Oban.Worker, max_attempts: 1 173 | 174 | def init(opts), do: opts 175 | 176 | def call(conn, type) do 177 | new(%{type: type}) |> Oban.insert!() 178 | 179 | conn 180 | |> redirect(to: "/") 181 | end 182 | 183 | @impl Oban.Worker 184 | def perform(%Oban.Job{args: %{"type" => "exception"}}), do: raise "FooBar" 185 | def perform(%Oban.Job{args: %{"type" => "throw"}}), do: throw("catch!") 186 | def perform(%Oban.Job{args: %{"type" => "exit"}}), do: exit("i quit") 187 | def perform(%Oban.Job{args: %{"type" => "error"}}), do: {:error, "foo"} 188 | end 189 | 190 | defmodule DemoWeb.MountErrorLive do 191 | use Phoenix.LiveView 192 | 193 | def mount(_params, _session, _socket) do 194 | :not_ok 195 | end 196 | 197 | def render(assigns) do 198 | ~H""" 199 |

DiscoLog Dev Server

200 | """ 201 | end 202 | end 203 | 204 | defmodule DemoWeb.MultiErrorLive do 205 | use Phoenix.LiveView 206 | 207 | def mount(_params, _session, socket) do 208 | {:ok, socket} 209 | end 210 | 211 | def handle_params(params, _url, socket) do 212 | {:noreply, apply_action(socket, socket.assigns.live_action, params)} 213 | end 214 | 215 | defp apply_action(_socket, :raise, _params) do 216 | raise "Error raised in a live view" 217 | end 218 | 219 | defp apply_action(_socket, :throw, _params) do 220 | throw("Error throwed in a live view") 221 | end 222 | 223 | def render(assigns) do 224 | ~H""" 225 |

DiscoLog Dev Server

226 | """ 227 | end 228 | end 229 | 230 | defmodule DemoWeb.ErrorComponent do 231 | use Phoenix.LiveComponent 232 | 233 | def render(assigns) do 234 | ~H""" 235 |
error
236 | """ 237 | end 238 | 239 | def update(_assigns, socket) do 240 | raise "Error raised in a live component" 241 | {:ok, socket} 242 | end 243 | end 244 | 245 | defmodule DemoWeb.ComponentErrorLive do 246 | use Phoenix.LiveView 247 | 248 | def mount(_params, _session, socket) do 249 | {:ok, socket} 250 | end 251 | 252 | def render(assigns) do 253 | ~H""" 254 | <.live_component id="error-component" module={DemoWeb.ErrorComponent} /> 255 | """ 256 | end 257 | end 258 | 259 | defmodule DiscoLogDevWeb.ErrorView do 260 | def render("404.html", _assigns) do 261 | "This is a 404" 262 | end 263 | 264 | def render("500.html", _assigns) do 265 | "This is a 500" 266 | end 267 | end 268 | 269 | defmodule DemoWeb.Router do 270 | use Phoenix.Router 271 | import Phoenix.LiveView.Router 272 | 273 | pipeline :browser do 274 | plug(:fetch_session) 275 | plug(:protect_from_forgery) 276 | end 277 | 278 | scope "/" do 279 | pipe_through(:browser) 280 | get("/", DemoWeb.PageController, :index) 281 | get("/noroute", DemoWeb.PageController, :noroute) 282 | get("/exception", DemoWeb.PageController, :exception) 283 | get("/exit", DemoWeb.PageController, :exit) 284 | 285 | get("/new_user", DemoWeb.LogController, :new_user) 286 | get("/user_upgrade", DemoWeb.LogController, :user_upgrade) 287 | get("/extra", DemoWeb.LogController, :extra) 288 | get("/long_extra", DemoWeb.LogController, :long_extra) 289 | 290 | live("/liveview/mount_error", DemoWeb.MountErrorLive, :index) 291 | live("/liveview/multi_error/raise", DemoWeb.MultiErrorLive, :raise) 292 | live("/liveview/multi_error/throw", DemoWeb.MultiErrorLive, :throw) 293 | live("/liveview/component", DemoWeb.ComponentErrorLive, :update_raise) 294 | 295 | get("/oban/exception", DemoWeb.ObanController, :exception) 296 | get("/oban/throw", DemoWeb.ObanController, :throw) 297 | get("/oban/exit", DemoWeb.ObanController, :exit) 298 | get("/oban/error", DemoWeb.ObanController, :error) 299 | end 300 | end 301 | 302 | defmodule DemoWeb.Endpoint do 303 | use Phoenix.Endpoint, otp_app: :disco_log 304 | 305 | @session_options [ 306 | store: :cookie, 307 | key: "_disco_log_dev", 308 | signing_salt: "/VEDsdfsffMnp5", 309 | same_site: "Lax" 310 | ] 311 | 312 | socket("/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]) 313 | socket("/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket) 314 | 315 | plug(Phoenix.LiveReloader) 316 | plug(Phoenix.CodeReloader) 317 | 318 | plug(Plug.Session, @session_options) 319 | 320 | plug(Plug.RequestId) 321 | plug(Plug.Telemetry, event_prefix: [:phoenix, :endpoint]) 322 | plug(DiscoLog.Integrations.Plug) 323 | plug(:maybe_exception) 324 | plug(DemoWeb.Router) 325 | 326 | def maybe_exception(%Plug.Conn{path_info: ["plug-exception"]}, _), do: raise("Plug exception") 327 | def maybe_exception(conn, _), do: conn 328 | end 329 | 330 | Application.put_env(:phoenix, :serve_endpoints, true) 331 | 332 | Task.async(fn -> 333 | children = [ 334 | Demo.Repo, 335 | {Phoenix.PubSub, [name: Demo.PubSub, adapter: Phoenix.PubSub.PG2]}, 336 | DemoWeb.Endpoint, 337 | {Oban, repo: Demo.Repo, engine: Oban.Engines.Lite, plugins: [], queues: [default: 10]} 338 | ] 339 | 340 | Demo.Repo.__adapter__().storage_down(Demo.Repo.config()) 341 | Demo.Repo.__adapter__().storage_up(Demo.Repo.config()) 342 | 343 | {:ok, _} = Supervisor.start_link(children, strategy: :one_for_one) 344 | 345 | Ecto.Migrator.run(Demo.Repo, [{0, Migration0}], :up, all: true) 346 | 347 | Process.sleep(:infinity) 348 | end) 349 | -------------------------------------------------------------------------------- /test/disco_log/discord/prepare_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DiscoLog.Discord.PrepareTest do 2 | use DiscoLog.Test.Case, async: true 3 | 4 | alias DiscoLog.Discord.Prepare 5 | alias DiscoLog.Error 6 | 7 | @error %Error{ 8 | kind: :error, 9 | reason: %RuntimeError{message: "Foo"}, 10 | display_kind: "RuntimeError", 11 | display_title: "RuntimeError", 12 | display_short_error: "Foo", 13 | display_full_error: """ 14 | ** (RuntimeError) Foo 15 | dev.exs:111: DemoWeb.PageController.call/2 16 | (phoenix 1.7.21) lib/phoenix/router.ex:484: Phoenix.Router.__call__/5 17 | dev.exs:316: DemoWeb.Endpoint.plug_builder_call/2 18 | deps/plug/lib/plug/debugger.ex:155: DemoWeb.Endpoint."call (overridable 3)"/2 19 | dev.exs:316: DemoWeb.Endpoint."call (overridable 4)"/2 20 | (bandit 1.7.0) lib/bandit/pipeline.ex:131: Bandit.Pipeline.call_plug!/2 21 | (bandit 1.7.0) lib/bandit/pipeline.ex:42: Bandit.Pipeline.run/5 22 | (bandit 1.7.0) lib/bandit/http1/handler.ex:13: Bandit.HTTP1.Handler.handle_data/3 23 | """, 24 | display_source: "dev.exs:111: DemoWeb.PageController.call/2", 25 | fingerprint: "2sxhIQ", 26 | source_url: "https://github.com/mrdotb/disco-log/blob/main/dev.exs#L111" 27 | } 28 | 29 | describe inspect(&Prepare.prepare_message/2) do 30 | test "string message goes to content" do 31 | assert [payload_json: %{flags: 32_768, components: [%{type: 10, content: "Hello World"}]}] = 32 | Prepare.prepare_message("Hello World", %{}) 33 | end 34 | 35 | test "context is attached as code block" do 36 | assert [ 37 | payload_json: %{ 38 | flags: 32_768, 39 | components: [ 40 | %{type: 10, content: "Hello"}, 41 | %{type: 10, content: "```elixir\n%{foo: \"bar\"}\n```"} 42 | ] 43 | } 44 | ] = Prepare.prepare_message("Hello", %{foo: "bar"}) 45 | end 46 | 47 | test "context is attached as file if it exceeds limit" do 48 | assert [ 49 | payload_json: %{ 50 | flags: 32_768, 51 | components: [ 52 | %{type: 10, content: "Hello"}, 53 | %{type: 13, file: %{url: "attachment://context.txt"}} 54 | ] 55 | }, 56 | context: {"%{\n foo: " <> _, [filename: "context.txt"]} 57 | ] = Prepare.prepare_message("Hello", %{foo: String.duplicate("a", 4000)}) 58 | end 59 | end 60 | 61 | describe inspect(&Prepare.prepare_occurrence/2) do 62 | test "applies tags from context" do 63 | assert [ 64 | payload_json: %{ 65 | applied_tags: ["tag_id_1"] 66 | } 67 | ] = 68 | Prepare.prepare_occurrence(@error, %{}, ["tag_id_1"]) 69 | end 70 | 71 | test "thread name is fingerprint + display_title" do 72 | error = %{@error | fingerprint: "AAAAAA", display_title: "Hello"} 73 | 74 | assert [ 75 | payload_json: %{ 76 | name: "AAAAAA Hello" 77 | } 78 | ] = 79 | Prepare.prepare_occurrence(error, %{}, []) 80 | end 81 | 82 | test "main body" do 83 | error = %{ 84 | @error 85 | | display_kind: "KIND", 86 | display_short_error: "SHORT", 87 | display_full_error: "FULL", 88 | display_source: "SOURCE", 89 | source_url: "http://example.com" 90 | } 91 | 92 | assert [ 93 | payload_json: %{ 94 | message: %{ 95 | flags: 32_768, 96 | components: [ 97 | %{ 98 | type: 10, 99 | content: """ 100 | **Kind:** `KIND` 101 | **Reason:** `SHORT` 102 | **Source:** [SOURCE](http://example.com)\ 103 | """ 104 | }, 105 | %{ 106 | type: 10, 107 | content: "```\nFULL\n```" 108 | } 109 | ] 110 | } 111 | } 112 | ] = 113 | Prepare.prepare_occurrence(error, %{}, []) 114 | end 115 | 116 | test "display_kind and display_source are optional" do 117 | error = %{ 118 | @error 119 | | display_kind: nil, 120 | display_short_error: "SHORT", 121 | display_full_error: "FULL", 122 | display_source: nil, 123 | source_url: "http://example.com" 124 | } 125 | 126 | assert [ 127 | payload_json: %{ 128 | message: %{ 129 | flags: 32_768, 130 | components: [ 131 | %{ 132 | type: 10, 133 | content: """ 134 | **Reason:** `SHORT`\ 135 | """ 136 | }, 137 | %{type: 10, content: "```\nFULL\n```"} 138 | ] 139 | } 140 | } 141 | ] = 142 | Prepare.prepare_occurrence(error, %{}, []) 143 | end 144 | 145 | test "context is attached as code block" do 146 | assert [ 147 | payload_json: %{ 148 | message: %{ 149 | flags: 32_768, 150 | components: [ 151 | _, 152 | _, 153 | %{type: 10, content: "```elixir\n%{foo: \"bar\"}\n```"} 154 | ] 155 | } 156 | } 157 | ] = Prepare.prepare_occurrence(@error, %{foo: "bar"}, []) 158 | end 159 | 160 | test "context is attached as file if it exceeds limit" do 161 | assert [ 162 | payload_json: %{ 163 | message: %{ 164 | flags: 32_768, 165 | components: [_, _, %{type: 13, file: %{url: "attachment://context.txt"}}] 166 | } 167 | }, 168 | context: {"%{\n foo: " <> _, [filename: "context.txt"]} 169 | ] = Prepare.prepare_occurrence(@error, %{foo: String.duplicate("a", 4000)}, []) 170 | end 171 | 172 | test "full message is attached as file if it exceeds limit" do 173 | assert [ 174 | payload_json: %{ 175 | message: %{ 176 | flags: 32_768, 177 | components: [_, %{type: 13, file: %{url: "attachment://error.txt"}}, _] 178 | } 179 | }, 180 | error: {"aaaaaa" <> _, [filename: "error.txt"]} 181 | ] = 182 | Prepare.prepare_occurrence( 183 | %{@error | display_full_error: String.duplicate("a", 4000)}, 184 | %{foo: "bar"}, 185 | [] 186 | ) 187 | end 188 | end 189 | 190 | describe inspect(&Prepare.prepare_occurrence_message/1) do 191 | test "creates new message to be put in thread" do 192 | error = %{ 193 | @error 194 | | display_kind: "KIND", 195 | display_short_error: "SHORT", 196 | display_full_error: "FULL", 197 | display_source: "SOURCE", 198 | source_url: "http://example.com" 199 | } 200 | 201 | assert [ 202 | payload_json: %{ 203 | components: [ 204 | %{ 205 | type: 10, 206 | content: """ 207 | **Kind:** `KIND` 208 | **Reason:** `SHORT` 209 | **Source:** [SOURCE](http://example.com)\ 210 | """ 211 | }, 212 | %{ 213 | type: 10, 214 | content: "```\nFULL\n```" 215 | } 216 | ], 217 | flags: 32_768 218 | } 219 | ] = 220 | Prepare.prepare_occurrence_message(error, %{}) 221 | end 222 | 223 | test "display_kind and display_source are optional" do 224 | error = %{ 225 | @error 226 | | display_kind: nil, 227 | display_short_error: "SHORT", 228 | display_full_error: "FULL", 229 | display_source: nil, 230 | source_url: "http://example.com" 231 | } 232 | 233 | assert [ 234 | payload_json: %{ 235 | components: [ 236 | %{ 237 | type: 10, 238 | content: """ 239 | **Reason:** `SHORT`\ 240 | """ 241 | }, 242 | %{ 243 | type: 10, 244 | content: "```\nFULL\n```" 245 | } 246 | ], 247 | flags: 32_768 248 | } 249 | ] = 250 | Prepare.prepare_occurrence_message(error, %{}) 251 | end 252 | 253 | test "context is attached as code block" do 254 | assert [ 255 | payload_json: %{ 256 | components: [_, _, %{type: 10, content: "```elixir\n%{foo: \"bar\"}\n```"}], 257 | flags: 32_768 258 | } 259 | ] = 260 | Prepare.prepare_occurrence_message(@error, %{foo: "bar"}) 261 | end 262 | 263 | test "context is attached as file if it exceeds limit" do 264 | assert [ 265 | payload_json: %{ 266 | components: [_, _, %{type: 13, file: %{url: "attachment://context.txt"}}], 267 | flags: 32_768 268 | }, 269 | context: {"%{\n foo: " <> _, [filename: "context.txt"]} 270 | ] = 271 | Prepare.prepare_occurrence_message(@error, %{ 272 | foo: String.duplicate("a", 4000) 273 | }) 274 | end 275 | 276 | test "full message is attached as file if it exceeds limit" do 277 | assert [ 278 | payload_json: %{ 279 | flags: 32_768, 280 | components: [_, %{type: 13, file: %{url: "attachment://error.txt"}}, _] 281 | }, 282 | error: {"aaaaaa" <> _, [filename: "error.txt"]} 283 | ] = 284 | Prepare.prepare_occurrence_message( 285 | %{@error | display_full_error: String.duplicate("a", 4000)}, 286 | %{foo: "bar"} 287 | ) 288 | end 289 | end 290 | end 291 | -------------------------------------------------------------------------------- /test/disco_log/presence_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DiscoLog.PresenceTest do 2 | use DiscoLog.Test.Case, async: true 3 | 4 | import Mox 5 | 6 | alias DiscoLog.Presence 7 | alias DiscoLog.WebsocketClient 8 | alias DiscoLog.Discord.API 9 | 10 | setup :verify_on_exit! 11 | 12 | setup_all do 13 | Registry.start_link(keys: :unique, name: __MODULE__.Registry) 14 | :ok 15 | end 16 | 17 | describe "start_link" do 18 | test "connects to gateway on startup" do 19 | expect(API.Mock, :request, fn client, :get, "/gateway/bot", [] -> 20 | API.Stub.request(client, :get, "/gateway/bot", []) 21 | end) 22 | 23 | expect(WebsocketClient.Mock, :connect, fn host, port, path -> 24 | assert "gateway.discord.gg" = host 25 | assert 443 = port 26 | assert "/?v=10&encoding=json" = path 27 | {:ok, %WebsocketClient{}} 28 | end) 29 | 30 | pid = 31 | start_link_supervised!( 32 | {Presence, 33 | [ 34 | supervisor_name: __MODULE__, 35 | bot_token: "mytoken", 36 | discord_client: %API{module: API.Mock}, 37 | presence_status: "Status Message" 38 | ]} 39 | ) 40 | 41 | _ = :sys.get_status(pid) 42 | end 43 | end 44 | 45 | describe "Normal work" do 46 | setup do 47 | client = %WebsocketClient{} 48 | stub(WebsocketClient.Mock, :connect, fn _, _, _ -> {:ok, client} end) 49 | 50 | pid = 51 | start_link_supervised!( 52 | {Presence, 53 | [ 54 | supervisor_name: __MODULE__, 55 | bot_token: "mytoken", 56 | discord_client: %API{module: API.Mock}, 57 | presence_status: "Status Message", 58 | jitter: 1 59 | ]} 60 | ) 61 | 62 | :sys.get_status(pid) 63 | 64 | %{client: client, pid: pid} 65 | end 66 | 67 | test "Connect: no immediate Hello event", %{pid: pid} do 68 | WebsocketClient.Mock 69 | |> expect(:boil_message_to_frames, fn %WebsocketClient{} = client, {:ssl, :fake_upgrade} -> 70 | {:ok, client, []} 71 | end) 72 | 73 | send(pid, {:ssl, :fake_upgrade}) 74 | :sys.get_status(pid) 75 | end 76 | 77 | test "Hello: sends Identify event", %{pid: pid} do 78 | WebsocketClient.Mock 79 | |> expect(:boil_message_to_frames, fn %WebsocketClient{} = client, {:ssl, :fake_hello} -> 80 | msg = %{ 81 | "op" => 10, 82 | "s" => 42, 83 | "d" => %{ 84 | "heartbeat_interval" => 60_000 85 | } 86 | } 87 | 88 | {:ok, client, [text: Jason.encode!(msg)]} 89 | end) 90 | |> expect(:send_frame, fn client, {:text, event} -> 91 | assert %{ 92 | "op" => 2, 93 | "d" => %{ 94 | "token" => "mytoken", 95 | "intents" => 0, 96 | "presence" => %{ 97 | "activities" => [ 98 | %{"name" => "Name", "state" => "Status Message", "type" => 4} 99 | ], 100 | "since" => nil, 101 | "status" => "online", 102 | "afk" => false 103 | }, 104 | "properties" => %{ 105 | "os" => "BEAM", 106 | "browser" => "DiscoLog", 107 | "device" => "DiscoLog" 108 | } 109 | } 110 | } = Jason.decode!(event) 111 | 112 | {:ok, client} 113 | end) 114 | 115 | send(pid, {:ssl, :fake_hello}) 116 | :sys.get_status(pid) 117 | end 118 | 119 | test "Hello: schedules first heartbeat at jitter * heartbeat_interval", %{pid: pid} do 120 | test_pid = self() 121 | 122 | WebsocketClient.Mock 123 | |> expect(:boil_message_to_frames, fn %WebsocketClient{} = client, {:ssl, :fake_hello} -> 124 | msg = %{ 125 | "op" => 10, 126 | "s" => 42, 127 | "d" => %{ 128 | "heartbeat_interval" => 0 129 | } 130 | } 131 | 132 | {:ok, client, [text: Jason.encode!(msg)]} 133 | end) 134 | |> expect(:send_frame, 2, fn 135 | %WebsocketClient{ref: nil} = client, _event -> 136 | {:ok, %{client | ref: 1}} 137 | 138 | %WebsocketClient{ref: 1} = client, {:text, event} -> 139 | assert %{ 140 | "op" => 1, 141 | "d" => 42 142 | } = Jason.decode!(event) 143 | 144 | send(test_pid, :heartbeat_completed) 145 | 146 | {:ok, client} 147 | end) 148 | 149 | send(pid, {:ssl, :fake_hello}) 150 | assert_receive :heartbeat_completed 151 | end 152 | 153 | test "Heartbeat ACK: noop", %{pid: pid} do 154 | test_pid = self() 155 | 156 | WebsocketClient.Mock 157 | |> expect(:boil_message_to_frames, fn %WebsocketClient{} = client, {:ssl, :fake_ack} -> 158 | send(test_pid, :ack_handled) 159 | {:ok, client, [text: Jason.encode!(%{"op" => 11})]} 160 | end) 161 | 162 | send(pid, {:ssl, :fake_ack}) 163 | assert_receive :ack_handled 164 | :sys.get_status(pid) 165 | end 166 | 167 | test "Heartbeat ACK: multiple ACKs", %{pid: pid} do 168 | test_pid = self() 169 | 170 | WebsocketClient.Mock 171 | |> expect(:boil_message_to_frames, fn %WebsocketClient{} = client, {:ssl, :fake_ack} -> 172 | send(test_pid, :ack_handled) 173 | msg = Jason.encode!(%{"op" => 11}) 174 | {:ok, client, [text: msg, text: msg]} 175 | end) 176 | 177 | send(pid, {:ssl, :fake_ack}) 178 | assert_receive :ack_handled 179 | :sys.get_status(pid) 180 | end 181 | 182 | test "Heartbeat: closes connection if no ACK received between regular heartbeats", %{pid: pid} do 183 | test_pid = self() 184 | 185 | WebsocketClient.Mock 186 | |> expect(:boil_message_to_frames, fn %WebsocketClient{} = client, {:ssl, :fake_hello} -> 187 | msg = %{ 188 | "op" => 10, 189 | "s" => 42, 190 | "d" => %{ 191 | "heartbeat_interval" => 60_000 192 | } 193 | } 194 | 195 | {:ok, client, [text: Jason.encode!(msg)]} 196 | end) 197 | |> expect(:send_frame, 3, fn 198 | client, {:text, _frame} -> 199 | {:ok, %{client | ref: 1}} 200 | 201 | %{ref: 1} = client, {:close, 1008, "server missed ack"} -> 202 | send(test_pid, :close_sent) 203 | {:ok, client} 204 | end) 205 | 206 | send(pid, {:ssl, :fake_hello}) 207 | send(pid, :heartbeat) 208 | send(pid, :heartbeat) 209 | assert_receive :close_sent 210 | end 211 | 212 | test "Heartbeat: responds to Heartbeat requests", %{pid: pid} do 213 | test_pid = self() 214 | 215 | WebsocketClient.Mock 216 | |> expect(:boil_message_to_frames, fn %WebsocketClient{} = client, 217 | {:ssl, :fake_heartbeat_request} -> 218 | {:ok, client, [text: Jason.encode!(%{"op" => 1})]} 219 | end) 220 | |> expect(:send_frame, fn %WebsocketClient{} = client, {:text, event} -> 221 | assert %{"op" => 1} = Jason.decode!(event) 222 | send(test_pid, :heartbeat_sent) 223 | {:ok, client} 224 | end) 225 | 226 | send(pid, {:ssl, :fake_heartbeat_request}) 227 | assert_receive :heartbeat_sent 228 | end 229 | 230 | test "Ready(Dispatch): noop", %{pid: pid} do 231 | test_pid = self() 232 | 233 | WebsocketClient.Mock 234 | |> expect(:boil_message_to_frames, fn %WebsocketClient{} = client, 235 | {:ssl, :fake_ready_event} -> 236 | send(test_pid, :event_handled) 237 | {:ok, client, [text: Jason.encode!(%{"op" => 0, "s" => 43})]} 238 | end) 239 | 240 | send(pid, {:ssl, :fake_ready_event}) 241 | assert_receive :event_handled 242 | :sys.get_status(pid) 243 | end 244 | 245 | test "Other events: noop", %{pid: pid} do 246 | test_pid = self() 247 | 248 | WebsocketClient.Mock 249 | |> expect(:boil_message_to_frames, fn %WebsocketClient{} = client, {:ssl, :fake_event} -> 250 | send(test_pid, :event_handled) 251 | {:ok, client, [text: Jason.encode!(%{"op" => 7})]} 252 | end) 253 | 254 | send(pid, {:ssl, :fake_event}) 255 | assert_receive :event_handled 256 | end 257 | end 258 | 259 | describe "Fail modes" do 260 | setup tags do 261 | client = 262 | Map.merge(%{state: :open, websocket: %Mint.WebSocket{}}, Map.get(tags, :client, %{})) 263 | 264 | stub(WebsocketClient.Mock, :connect, fn _, _, _ -> 265 | {:ok, struct(WebsocketClient, client)} 266 | end) 267 | 268 | pid = 269 | start_supervised!( 270 | {Presence, 271 | [ 272 | supervisor_name: __MODULE__, 273 | bot_token: "mytoken", 274 | discord_client: %API{module: API.Mock}, 275 | presence_status: "Status Message", 276 | jitter: 1 277 | ]} 278 | ) 279 | 280 | :sys.get_status(pid) 281 | 282 | %{client: client, pid: pid} 283 | end 284 | 285 | test "exits on unexpected message", %{pid: pid} do 286 | ref = Process.monitor(pid) 287 | 288 | WebsocketClient.Mock 289 | |> expect(:boil_message_to_frames, fn _client, :unknown_message -> 290 | {:error, "BOOM"} 291 | end) 292 | |> expect(:send_frame, fn client, _ -> {:ok, client} end) 293 | 294 | send(pid, :unknown_message) 295 | assert_receive {:DOWN, ^ref, :process, ^pid, {:error, "BOOM"}} 296 | end 297 | 298 | test "tries to gracefully disconnect if connection is open", %{pid: pid} do 299 | ref = Process.monitor(pid) 300 | 301 | WebsocketClient.Mock 302 | |> expect(:boil_message_to_frames, fn _client, :unknown_message -> 303 | {:error, "BOOM"} 304 | end) 305 | |> expect(:send_frame, fn client, {:close, 1000, "graceful disconnect"} -> {:ok, client} end) 306 | 307 | send(pid, :unknown_message) 308 | assert_receive {:DOWN, ^ref, :process, ^pid, {:error, "BOOM"}} 309 | end 310 | 311 | test "does not disconnects if process exiting due to disconnect", %{pid: pid} do 312 | ref = Process.monitor(pid) 313 | 314 | WebsocketClient.Mock 315 | |> expect(:boil_message_to_frames, fn _client, {:ssl, :fake_closed} -> 316 | {:ok, :closed} 317 | end) 318 | 319 | send(pid, {:ssl, :fake_closed}) 320 | assert_receive {:DOWN, ^ref, :process, ^pid, {:shutdown, :closed_by_client}} 321 | end 322 | 323 | test "shuts down if server closes connection", %{pid: pid} do 324 | ref = Process.monitor(pid) 325 | 326 | WebsocketClient.Mock 327 | |> expect(:boil_message_to_frames, fn client, {:ssl, :fake_server_closed} -> 328 | {:ok, client, [{:close, 1000, "reason"}]} 329 | end) 330 | |> expect(:send_frame, fn client, :close -> {:ok, client} end) 331 | |> expect(:close, fn client -> {:ok, client} end) 332 | 333 | send(pid, {:ssl, :fake_server_closed}) 334 | assert_receive {:DOWN, ^ref, :process, ^pid, {:shutdown, {:closed_by_server, "reason"}}} 335 | end 336 | 337 | @tag client: %{state: nil, websocket: nil} 338 | test "shuts down if upgrade fails with HTTP status code", %{pid: pid} do 339 | ref = Process.monitor(pid) 340 | 341 | WebsocketClient.Mock 342 | |> expect(:connect, fn _, _, _ -> {:ok, %WebsocketClient{}} end) 343 | |> expect(:boil_message_to_frames, fn _client, {:ssl, :fake_upgrade} -> 344 | {:error, nil, %Mint.WebSocket.UpgradeFailureError{status_code: 520}} 345 | end) 346 | 347 | send(pid, {:ssl, :fake_upgrade}) 348 | 349 | assert_receive {:DOWN, ^ref, :process, ^pid, 350 | {:shutdown, %Mint.WebSocket.UpgradeFailureError{status_code: 520}}} 351 | end 352 | 353 | test "shuts down if tcp socket closes", %{pid: pid} do 354 | ref = Process.monitor(pid) 355 | 356 | WebsocketClient.Mock 357 | |> expect(:boil_message_to_frames, fn _client, {:ssl, :fake_ssl_closed} -> 358 | {:error, nil, %Mint.TransportError{reason: :closed}, []} 359 | end) 360 | 361 | send(pid, {:ssl, :fake_ssl_closed}) 362 | 363 | assert_receive {:DOWN, ^ref, :process, ^pid, 364 | {:shutdown, %Mint.TransportError{reason: :closed}}} 365 | end 366 | end 367 | end 368 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bandit": {:hex, :bandit, "1.7.0", "d1564f30553c97d3e25f9623144bb8df11f3787a26733f00b21699a128105c0c", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "3e2f7a98c7a11f48d9d8c037f7177cd39778e74d55c7af06fe6227c742a8168a"}, 3 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 4 | "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, 5 | "cowboy": {:hex, :cowboy, "2.13.0", "09d770dd5f6a22cc60c071f432cd7cb87776164527f205c5a6b0f24ff6b38990", [:make, :rebar3], [{:cowlib, ">= 2.14.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "e724d3a70995025d654c1992c7b11dbfea95205c047d86ff9bf1cda92ddc5614"}, 6 | "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, 7 | "cowlib": {:hex, :cowlib, "2.15.0", "3c97a318a933962d1c12b96ab7c1d728267d2c523c25a5b57b0f93392b6e9e25", [:make, :rebar3], [], "hexpm", "4f00c879a64b4fe7c8fcb42a4281925e9ffdb928820b03c3ad325a617e857532"}, 8 | "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, 9 | "db_connection": {:hex, :db_connection, "2.8.0", "64fd82cfa6d8e25ec6660cea73e92a4cbc6a18b31343910427b702838c4b33b2", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "008399dae5eee1bf5caa6e86d204dcb44242c82b1ed5e22c881f2c34da201b15"}, 10 | "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, 11 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 12 | "ecto": {:hex, :ecto, "3.13.2", "7d0c0863f3fc8d71d17fc3ad3b9424beae13f02712ad84191a826c7169484f01", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "669d9291370513ff56e7b7e7081b7af3283d02e046cf3d403053c557894a0b3e"}, 13 | "ecto_sql": {:hex, :ecto_sql, "3.13.2", "a07d2461d84107b3d037097c822ffdd36ed69d1cf7c0f70e12a3d1decf04e2e1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "539274ab0ecf1a0078a6a72ef3465629e4d6018a3028095dc90f60a19c371717"}, 14 | "ecto_sqlite3": {:hex, :ecto_sqlite3, "0.21.0", "8531f5044fb08289b3aacd21e383a9fb187e5a78981b9ed6d0929a78a25c2341", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "9c3e90ea33099ca0ddd160c8d9eaf80d7d4a9b110d325fa6ed0409858a714606"}, 15 | "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, 16 | "ex_doc": {:hex, :ex_doc, "0.38.3", "ddafe36b8e9fe101c093620879f6604f6254861a95133022101c08e75e6c759a", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "ecaa785456a67f63b4e7d7f200e8832fa108279e7eb73fd9928e7e66215a01f9"}, 17 | "exqlite": {:hex, :exqlite, "0.33.0", "2cc96c4227fbb2d0864716def736dff18afb9949b1eaa74630822a0865b4b342", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8a7c2792e567bbebb4dafe96f6397f1c527edd7039d74f508a603817fbad2844"}, 18 | "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, 19 | "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [: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", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, 20 | "git_cli": {:hex, :git_cli, "0.3.0", "a5422f9b95c99483385b976f5d43f7e8233283a47cda13533d7c16131cb14df5", [:mix], [], "hexpm", "78cb952f4c86a41f4d3511f1d3ecb28edb268e3a7df278de2faa1bd4672eaf9b"}, 21 | "git_ops": {:hex, :git_ops, "2.8.0", "29ac9ab68bf9645973cb2752047b987e75cbd3d9761489c615e3ba80018fa885", [:mix], [{:git_cli, "~> 0.2", [hex: :git_cli, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.27 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "b535e4ad6b5d13e14c455e76f65825659081b5530b0827eb0232d18719530eec"}, 22 | "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, 23 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 24 | "logger_handler_kit": {:hex, :logger_handler_kit, "0.4.0", "0eeeb8e5b4059a81379c1ddc6d31928c6952a3adb7f10acb6e356b5196c7ad28", [:mix], [{:bandit, "~> 1.7", [hex: :bandit, repo: "hexpm", optional: false]}, {:mint, "~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: false]}], "hexpm", "aaa6471dc19669390675824cff8f70873a1605ae7ad59a0531ecb43a3e1365e2"}, 25 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 26 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 27 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 28 | "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, 29 | "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"}, 30 | "mint_web_socket": {:hex, :mint_web_socket, "1.0.4", "0b539116dbb3d3f861cdf5e15e269a933cb501c113a14db7001a3157d96ffafd", [:mix], [{:mint, ">= 1.4.1 and < 2.0.0-0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "027d4c5529c45a4ba0ce27a01c0f35f284a5468519c045ca15f43decb360a991"}, 31 | "mox": {:hex, :mox, "1.2.0", "a2cd96b4b80a3883e3100a221e8adc1b98e4c3a332a8fc434c39526babafd5b3", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "c7b92b3cc69ee24a7eeeaf944cd7be22013c52fcb580c1f33f50845ec821089a"}, 32 | "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 33 | "nimble_ownership": {:hex, :nimble_ownership, "1.0.1", "f69fae0cdd451b1614364013544e66e4f5d25f36a2056a9698b793305c5aa3a6", [:mix], [], "hexpm", "3825e461025464f519f3f3e4a1f9b68c47dc151369611629ad08b636b73bb22d"}, 34 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 35 | "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, 36 | "oban": {:hex, :oban, "2.20.1", "39d0b68787e5cf251541c0d657a698f6142a24d8744e1e40b2cf045d4fa232a6", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17a45277dbeb41a455040b41dd8c467163fad685d1366f2f59207def3bcdd1d8"}, 37 | "phoenix": {:hex, :phoenix, "1.8.0", "dc5d256bb253110266ded8c4a6a167e24fabde2e14b8e474d262840ae8d8ea18", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "15f6e9cb76646ad8d9f2947240519666fc2c4f29f8a93ad9c7664916ab4c167b"}, 38 | "phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"}, 39 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.0", "2791fac0e2776b640192308cc90c0dbcf67843ad51387ed4ecae2038263d708d", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b3a1fa036d7eb2f956774eda7a7638cf5123f8f2175aca6d6420a7f95e598e1c"}, 40 | "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.4", "619f80f7bc8e99d67bda9fafa28b0e3e0d69fad9fcbe859be8988bf9183d83b3", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ad32d316c546b029ef22895355f9980c74657026ab5e093157d8990f2aa7bd5d"}, 41 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, 42 | "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, 43 | "plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"}, 44 | "plug_cowboy": {:hex, :plug_cowboy, "2.7.4", "729c752d17cf364e2b8da5bdb34fb5804f56251e88bb602aff48ae0bd8673d11", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "9b85632bd7012615bae0a5d70084deb1b25d2bcbb32cab82d1e9a1e023168aa3"}, 45 | "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, 46 | "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, 47 | "req": {:hex, :req, "0.5.15", "662020efb6ea60b9f0e0fac9be88cd7558b53fe51155a2d9899de594f9906ba9", [: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", "a6513a35fad65467893ced9785457e91693352c70b58bbc045b47e5eb2ef0c53"}, 48 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 49 | "thousand_island": {:hex, :thousand_island, "1.3.14", "ad45ebed2577b5437582bcc79c5eccd1e2a8c326abf6a3464ab6c06e2055a34a", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d0d24a929d31cdd1d7903a4fe7f2409afeedff092d277be604966cd6aa4307ef"}, 50 | "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, 51 | "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, 52 | } 53 | -------------------------------------------------------------------------------- /test/disco_log/logger_handler_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DiscoLog.LoggerHandlerTest do 2 | use DiscoLog.Test.Case, async: true 3 | 4 | import Mox 5 | require Logger 6 | alias DiscoLog.Discord.API 7 | 8 | @moduletag config: [supervisor_name: __MODULE__] 9 | 10 | setup_all {LoggerHandlerKit.Arrange, :ensure_per_handler_translation} 11 | 12 | setup :setup_supervisor 13 | setup :setup_logger_handler 14 | setup :verify_on_exit! 15 | 16 | describe "info level" do 17 | test "string", %{handler_ref: ref} do 18 | pid = self() 19 | 20 | expect(API.Mock, :request, fn client, method, url, opts -> 21 | send(pid, opts) 22 | API.Stub.request(client, method, url, opts) 23 | end) 24 | 25 | LoggerHandlerKit.Act.string_message() 26 | LoggerHandlerKit.Assert.assert_logged(ref) 27 | 28 | assert_receive [ 29 | {:path_params, [channel_id: "info_channel_id"]}, 30 | {:form_multipart, [payload_json: body]} 31 | ] 32 | 33 | assert %{components: [%{type: 10, content: "Hello World"}]} = body 34 | end 35 | 36 | test "charlist", %{handler_ref: ref} do 37 | pid = self() 38 | 39 | expect(API.Mock, :request, fn client, method, url, opts -> 40 | send(pid, opts) 41 | API.Stub.request(client, method, url, opts) 42 | end) 43 | 44 | LoggerHandlerKit.Act.charlist_message() 45 | LoggerHandlerKit.Assert.assert_logged(ref) 46 | 47 | assert_receive [ 48 | {:path_params, [channel_id: "info_channel_id"]}, 49 | {:form_multipart, [payload_json: body]} 50 | ] 51 | 52 | assert %{components: [%{type: 10, content: "Hello World"}]} = body 53 | end 54 | 55 | test "chardata", %{handler_ref: ref} do 56 | pid = self() 57 | 58 | expect(API.Mock, :request, fn client, method, url, opts -> 59 | send(pid, opts) 60 | API.Stub.request(client, method, url, opts) 61 | end) 62 | 63 | LoggerHandlerKit.Act.chardata_message(:improper) 64 | LoggerHandlerKit.Assert.assert_logged(ref) 65 | 66 | assert_receive [ 67 | {:path_params, [channel_id: "info_channel_id"]}, 68 | {:form_multipart, [payload_json: body]} 69 | ] 70 | 71 | assert %{components: [%{type: 10, content: "Hello World"}]} = body 72 | end 73 | 74 | test "map report", %{handler_ref: ref} do 75 | pid = self() 76 | 77 | expect(API.Mock, :request, fn client, method, url, opts -> 78 | send(pid, opts) 79 | API.Stub.request(client, method, url, opts) 80 | end) 81 | 82 | LoggerHandlerKit.Act.map_report() 83 | LoggerHandlerKit.Assert.assert_logged(ref) 84 | 85 | assert_receive [ 86 | {:path_params, [channel_id: "info_channel_id"]}, 87 | {:form_multipart, [payload_json: body]} 88 | ] 89 | 90 | assert %{components: [%{type: 10, content: "%{hello: \"world\"}"}]} = body 91 | end 92 | 93 | test "keyword report", %{handler_ref: ref} do 94 | pid = self() 95 | 96 | expect(API.Mock, :request, fn client, method, url, opts -> 97 | send(pid, opts) 98 | API.Stub.request(client, method, url, opts) 99 | end) 100 | 101 | LoggerHandlerKit.Act.keyword_report() 102 | LoggerHandlerKit.Assert.assert_logged(ref) 103 | 104 | assert_receive [ 105 | {:path_params, [channel_id: "info_channel_id"]}, 106 | {:form_multipart, [payload_json: body]} 107 | ] 108 | 109 | assert %{components: [%{type: 10, content: "[hello: \"world\"]"}]} = body 110 | end 111 | 112 | test "struct report", %{handler_ref: ref} do 113 | pid = self() 114 | 115 | expect(API.Mock, :request, fn client, method, url, opts -> 116 | send(pid, opts) 117 | API.Stub.request(client, method, url, opts) 118 | end) 119 | 120 | LoggerHandlerKit.Act.struct_report() 121 | LoggerHandlerKit.Assert.assert_logged(ref) 122 | 123 | assert_receive [ 124 | {:path_params, [channel_id: "info_channel_id"]}, 125 | {:form_multipart, [payload_json: body]} 126 | ] 127 | 128 | assert %{ 129 | components: [ 130 | %{type: 10, content: "%LoggerHandlerKit.FakeStruct{hello: \"world\"}"} 131 | ] 132 | } = body 133 | end 134 | 135 | test "erlang io format", %{handler_ref: ref} do 136 | pid = self() 137 | 138 | expect(API.Mock, :request, fn client, method, url, opts -> 139 | send(pid, opts) 140 | API.Stub.request(client, method, url, opts) 141 | end) 142 | 143 | LoggerHandlerKit.Act.io_format() 144 | LoggerHandlerKit.Assert.assert_logged(ref) 145 | 146 | assert_receive [ 147 | {:path_params, [channel_id: "info_channel_id"]}, 148 | {:form_multipart, [payload_json: body]} 149 | ] 150 | 151 | assert %{components: [%{type: 10, content: "Hello World"}]} = body 152 | end 153 | 154 | @tag config: [metadata: [:extra]] 155 | test "metadata is attached if configured", %{handler_ref: ref} do 156 | pid = self() 157 | 158 | expect(API.Mock, :request, fn client, method, url, opts -> 159 | send(pid, opts) 160 | API.Stub.request(client, method, url, opts) 161 | end) 162 | 163 | Logger.metadata(extra: "Hello") 164 | LoggerHandlerKit.Act.string_message() 165 | LoggerHandlerKit.Assert.assert_logged(ref) 166 | 167 | assert_receive [ 168 | {:path_params, [channel_id: "info_channel_id"]}, 169 | {:form_multipart, [payload_json: body]} 170 | ] 171 | 172 | assert %{ 173 | components: [ 174 | %{type: 10, content: "Hello World"}, 175 | %{type: 10, content: "```elixir\n%{extra: \"Hello\"}\n```"} 176 | ] 177 | } = body 178 | end 179 | 180 | @tag config: [metadata: [:extra]] 181 | test "context is not attached for log messages", %{handler_ref: ref} do 182 | pid = self() 183 | 184 | expect(API.Mock, :request, fn client, method, url, opts -> 185 | send(pid, opts) 186 | API.Stub.request(client, method, url, opts) 187 | end) 188 | 189 | Logger.metadata(extra: "Hello") 190 | DiscoLog.Context.set(:foo, "bar") 191 | LoggerHandlerKit.Act.string_message() 192 | LoggerHandlerKit.Assert.assert_logged(ref) 193 | 194 | assert_receive [ 195 | {:path_params, [channel_id: "info_channel_id"]}, 196 | {:form_multipart, [payload_json: body]} 197 | ] 198 | 199 | assert %{ 200 | components: [ 201 | %{type: 10, content: "Hello World"}, 202 | %{type: 10, content: "```elixir\n%{extra: \"Hello\"}\n```"} 203 | ] 204 | } = body 205 | end 206 | end 207 | 208 | describe "error level" do 209 | test "string", %{handler_ref: ref} do 210 | pid = self() 211 | 212 | expect(API.Mock, :request, fn client, method, url, opts -> 213 | send(pid, opts) 214 | API.Stub.request(client, method, url, opts) 215 | end) 216 | 217 | Logger.error("Error message") 218 | LoggerHandlerKit.Assert.assert_logged(ref) 219 | 220 | assert_receive [ 221 | {:path_params, [channel_id: "error_channel_id"]}, 222 | {:form_multipart, [payload_json: body]} 223 | ] 224 | 225 | assert %{components: [%{type: 10, content: "Error message"}]} = body 226 | end 227 | 228 | test "charlist", %{handler_ref: ref} do 229 | pid = self() 230 | 231 | expect(API.Mock, :request, fn client, method, url, opts -> 232 | send(pid, opts) 233 | API.Stub.request(client, method, url, opts) 234 | end) 235 | 236 | Logger.error(~c"Hello World") 237 | LoggerHandlerKit.Assert.assert_logged(ref) 238 | 239 | assert_receive [ 240 | {:path_params, [channel_id: "error_channel_id"]}, 241 | {:form_multipart, [payload_json: body]} 242 | ] 243 | 244 | assert %{components: [%{type: 10, content: "Hello World"}]} = body 245 | end 246 | 247 | test "chardata", %{handler_ref: ref} do 248 | pid = self() 249 | 250 | expect(API.Mock, :request, fn client, method, url, opts -> 251 | send(pid, opts) 252 | API.Stub.request(client, method, url, opts) 253 | end) 254 | 255 | Logger.error([?H, ["ello", []], 32 | ~c"World"]) 256 | LoggerHandlerKit.Assert.assert_logged(ref) 257 | 258 | assert_receive [ 259 | {:path_params, [channel_id: "error_channel_id"]}, 260 | {:form_multipart, [payload_json: body]} 261 | ] 262 | 263 | assert %{components: [%{type: 10, content: "Hello World"}]} = body 264 | end 265 | 266 | test "map", %{handler_ref: ref} do 267 | pid = self() 268 | 269 | expect(API.Mock, :request, fn client, method, url, opts -> 270 | send(pid, opts) 271 | API.Stub.request(client, method, url, opts) 272 | end) 273 | 274 | Logger.error(%{message: "Error message"}) 275 | LoggerHandlerKit.Assert.assert_logged(ref) 276 | 277 | assert_receive [ 278 | {:path_params, [channel_id: "error_channel_id"]}, 279 | {:form_multipart, [payload_json: body]} 280 | ] 281 | 282 | assert %{components: [%{type: 10, content: "%{message: \"Error message\"}"}]} = body 283 | end 284 | 285 | test "keyword", %{handler_ref: ref} do 286 | pid = self() 287 | 288 | expect(API.Mock, :request, fn client, method, url, opts -> 289 | send(pid, opts) 290 | API.Stub.request(client, method, url, opts) 291 | end) 292 | 293 | Logger.error(message: "Error message") 294 | LoggerHandlerKit.Assert.assert_logged(ref) 295 | 296 | assert_receive [ 297 | {:path_params, [channel_id: "error_channel_id"]}, 298 | {:form_multipart, [payload_json: body]} 299 | ] 300 | 301 | assert %{components: [%{type: 10, content: "[message: \"Error message\"]"}]} = body 302 | end 303 | 304 | test "struct", %{handler_ref: ref} do 305 | pid = self() 306 | 307 | expect(API.Mock, :request, fn client, method, url, opts -> 308 | send(pid, opts) 309 | API.Stub.request(client, method, url, opts) 310 | end) 311 | 312 | Logger.error(%LoggerHandlerKit.FakeStruct{hello: "world"}) 313 | LoggerHandlerKit.Assert.assert_logged(ref) 314 | 315 | assert_receive [ 316 | {:path_params, [channel_id: "error_channel_id"]}, 317 | {:form_multipart, [payload_json: body]} 318 | ] 319 | 320 | assert %{ 321 | components: [ 322 | %{type: 10, content: "%LoggerHandlerKit.FakeStruct{hello: \"world\"}"} 323 | ] 324 | } = body 325 | end 326 | 327 | test "erlang io format", %{handler_ref: ref} do 328 | pid = self() 329 | 330 | expect(API.Mock, :request, fn client, method, url, opts -> 331 | send(pid, opts) 332 | API.Stub.request(client, method, url, opts) 333 | end) 334 | 335 | :logger.error("Hello ~s", ["World"]) 336 | LoggerHandlerKit.Assert.assert_logged(ref) 337 | 338 | assert_receive [ 339 | {:path_params, [channel_id: "error_channel_id"]}, 340 | {:form_multipart, [payload_json: body]} 341 | ] 342 | 343 | assert %{components: [%{type: 10, content: "Hello World"}]} = body 344 | end 345 | 346 | test "task error exception", %{handler_ref: ref} do 347 | pid = self() 348 | 349 | expect(API.Mock, :request, fn client, method, url, opts -> 350 | send(pid, opts) 351 | API.Stub.request(client, method, url, opts) 352 | end) 353 | 354 | LoggerHandlerKit.Act.task_error(:exception) 355 | LoggerHandlerKit.Assert.assert_logged(ref) 356 | 357 | assert_receive [ 358 | {:path_params, [channel_id: "occurrences_channel_id"]}, 359 | {:form_multipart, [payload_json: body]} 360 | ] 361 | 362 | assert %{ 363 | applied_tags: [], 364 | message: %{ 365 | components: [ 366 | %{content: _message}, 367 | %{content: message} 368 | ] 369 | }, 370 | name: <<_::binary-size(7)>> <> "** (RuntimeError) oops" 371 | } = body 372 | 373 | assert message =~ "Task.Supervised.invoke_mfa" 374 | end 375 | 376 | test "task error undefined", %{handler_ref: ref} do 377 | pid = self() 378 | 379 | expect(API.Mock, :request, fn client, method, url, opts -> 380 | send(pid, opts) 381 | API.Stub.request(client, method, url, opts) 382 | end) 383 | 384 | LoggerHandlerKit.Act.task_error(:undefined) 385 | LoggerHandlerKit.Assert.assert_logged(ref) 386 | 387 | assert_receive [ 388 | {:path_params, [channel_id: "occurrences_channel_id"]}, 389 | {:form_multipart, [payload_json: body]} 390 | ] 391 | 392 | assert %{ 393 | applied_tags: [], 394 | message: %{ 395 | components: [ 396 | %{content: _message}, 397 | %{content: message} 398 | ] 399 | }, 400 | name: 401 | <<_::binary-size(7)>> <> 402 | "** (UndefinedFunctionError) function :module_does_not_exist.undef/0 is undefined …" 403 | } = body 404 | 405 | assert message =~ "function :module_does_not_exist.undef/0 is undefined" 406 | end 407 | 408 | test "task error throw", %{handler_ref: ref} do 409 | pid = self() 410 | 411 | expect(API.Mock, :request, fn client, method, url, opts -> 412 | send(pid, opts) 413 | API.Stub.request(client, method, url, opts) 414 | end) 415 | 416 | LoggerHandlerKit.Act.task_error(:throw) 417 | LoggerHandlerKit.Assert.assert_logged(ref) 418 | 419 | assert_receive [ 420 | {:path_params, [channel_id: "occurrences_channel_id"]}, 421 | {:form_multipart, [payload_json: body]} 422 | ] 423 | 424 | assert %{ 425 | applied_tags: [], 426 | message: %{ 427 | components: [ 428 | %{content: _message}, 429 | %{content: message} 430 | ] 431 | }, 432 | name: <<_::binary-size(7)>> <> "** (throw) \"catch!\"" 433 | } = body 434 | 435 | assert message =~ "LoggerHandlerKit.Act.task_error" 436 | end 437 | 438 | test "task error exit", %{handler_ref: ref} do 439 | pid = self() 440 | 441 | expect(API.Mock, :request, fn client, method, url, opts -> 442 | send(pid, opts) 443 | API.Stub.request(client, method, url, opts) 444 | end) 445 | 446 | LoggerHandlerKit.Act.task_error(:exit) 447 | LoggerHandlerKit.Assert.assert_logged(ref) 448 | 449 | assert_receive [ 450 | {:path_params, [channel_id: "occurrences_channel_id"]}, 451 | {:form_multipart, [payload_json: body]} 452 | ] 453 | 454 | assert %{ 455 | applied_tags: [], 456 | message: %{ 457 | components: [ 458 | %{content: _message}, 459 | %{content: message} 460 | ] 461 | }, 462 | name: <<_::binary-size(7)>> <> "** (exit) \"i quit\"" 463 | } = body 464 | 465 | assert message =~ "LoggerHandlerKit.Act.task_error" 466 | end 467 | 468 | test "genserver crash exception", %{handler_ref: ref} do 469 | pid = self() 470 | 471 | expect(API.Mock, :request, fn client, method, url, opts -> 472 | send(pid, opts) 473 | API.Stub.request(client, method, url, opts) 474 | end) 475 | 476 | LoggerHandlerKit.Act.genserver_crash(:exception) 477 | LoggerHandlerKit.Assert.assert_logged(ref) 478 | 479 | assert_receive [ 480 | {:path_params, [channel_id: "occurrences_channel_id"]}, 481 | {:form_multipart, [payload_json: body]} 482 | ] 483 | 484 | assert %{ 485 | applied_tags: [], 486 | message: %{ 487 | components: [ 488 | %{content: _message}, 489 | %{content: message} 490 | ] 491 | }, 492 | name: <<_::binary-size(7)>> <> "** (RuntimeError) oops" 493 | } = body 494 | 495 | assert message =~ "LoggerHandlerKit.Act.genserver_crash" 496 | end 497 | 498 | test "genserver crash throw", %{handler_ref: ref} do 499 | pid = self() 500 | 501 | expect(API.Mock, :request, fn client, method, url, opts -> 502 | send(pid, opts) 503 | API.Stub.request(client, method, url, opts) 504 | end) 505 | 506 | LoggerHandlerKit.Act.genserver_crash(:throw) 507 | LoggerHandlerKit.Assert.assert_logged(ref) 508 | 509 | assert_receive [ 510 | {:path_params, [channel_id: "occurrences_channel_id"]}, 511 | {:form_multipart, [payload_json: body]} 512 | ] 513 | 514 | assert %{ 515 | applied_tags: [], 516 | message: %{ 517 | components: [ 518 | %{content: _message}, 519 | %{content: message} 520 | ] 521 | }, 522 | name: <<_::binary-size(7)>> <> "** (exit) bad return value: \"catch!\"" 523 | } = body 524 | 525 | assert message =~ "GenServer " 526 | assert message =~ "terminating" 527 | end 528 | 529 | test "genserver crash abnormal exit", %{handler_ref: ref} do 530 | pid = self() 531 | 532 | expect(API.Mock, :request, fn client, method, url, opts -> 533 | send(pid, opts) 534 | API.Stub.request(client, method, url, opts) 535 | end) 536 | 537 | try do 538 | {:ok, pid} = LoggerHandlerKit.GenServer.start(nil) 539 | GenServer.call(pid, {:run, fn -> {:stop, :bad_exit, :no_state} end}) 540 | catch 541 | :exit, {:bad_exit, _} -> :ok 542 | end 543 | 544 | LoggerHandlerKit.Assert.assert_logged(ref) 545 | 546 | assert_receive [ 547 | {:path_params, [channel_id: "occurrences_channel_id"]}, 548 | {:form_multipart, [payload_json: body]} 549 | ] 550 | 551 | assert %{ 552 | applied_tags: [], 553 | message: %{ 554 | components: [ 555 | %{content: _message}, 556 | %{content: message} 557 | ] 558 | }, 559 | name: <<_::binary-size(7)>> <> "** (exit) :bad_exit" 560 | } = body 561 | 562 | assert message =~ "GenServer " 563 | assert message =~ "terminating" 564 | end 565 | 566 | test "genserver crash while calling another process", %{handler_ref: ref} do 567 | pid = self() 568 | 569 | expect(API.Mock, :request, fn client, method, url, opts -> 570 | send(pid, opts) 571 | API.Stub.request(client, method, url, opts) 572 | end) 573 | 574 | # Get a PID and make sure it's done before using it. 575 | {dead_pid, monitor_ref} = spawn_monitor(fn -> :ok end) 576 | assert_receive {:DOWN, ^monitor_ref, _, _, _} 577 | 578 | try do 579 | {:ok, pid} = LoggerHandlerKit.GenServer.start(nil) 580 | GenServer.call(pid, {:run, fn -> GenServer.call(dead_pid, :ping) end}) 581 | catch 582 | :exit, {{:noproc, _}, _} -> :ok 583 | end 584 | 585 | LoggerHandlerKit.Assert.assert_logged(ref) 586 | 587 | assert_receive [ 588 | {:path_params, [channel_id: "occurrences_channel_id"]}, 589 | {:form_multipart, [payload_json: body]} 590 | ] 591 | 592 | assert %{ 593 | applied_tags: [], 594 | message: %{ 595 | components: [ 596 | %{content: _message}, 597 | %{content: message} 598 | ] 599 | }, 600 | name: <<_::binary-size(7)>> <> "** (exit) exited in …" 601 | } = body 602 | 603 | assert message =~ 604 | "** (EXIT) no process: the process is not alive or there's no process currently associated with the given name" 605 | end 606 | 607 | test "genserver crash due to timeout calling another genserver", %{handler_ref: ref} do 608 | pid = self() 609 | 610 | expect(API.Mock, :request, fn client, method, url, opts -> 611 | send(pid, opts) 612 | API.Stub.request(client, method, url, opts) 613 | end) 614 | 615 | {:ok, agent} = Agent.start_link(fn -> nil end) 616 | 617 | try do 618 | {:ok, pid} = LoggerHandlerKit.GenServer.start(nil) 619 | GenServer.call(pid, {:run, fn -> Agent.get(agent, & &1, 0) end}) 620 | catch 621 | :exit, {{:timeout, _}, _} -> :ok 622 | end 623 | 624 | LoggerHandlerKit.Assert.assert_logged(ref) 625 | 626 | assert_receive [ 627 | {:path_params, [channel_id: "occurrences_channel_id"]}, 628 | {:form_multipart, [payload_json: body]} 629 | ] 630 | 631 | assert %{ 632 | applied_tags: [], 633 | message: %{ 634 | components: [ 635 | %{content: _message}, 636 | %{content: message} 637 | ] 638 | }, 639 | name: <<_::binary-size(7)>> <> "** (exit) exited in …" 640 | } = body 641 | 642 | assert message =~ "GenServer" 643 | assert message =~ "terminating" 644 | end 645 | 646 | test "genserver crash exit with a struct", %{handler_ref: ref} do 647 | pid = self() 648 | 649 | expect(API.Mock, :request, fn client, method, url, opts -> 650 | send(pid, opts) 651 | API.Stub.request(client, method, url, opts) 652 | end) 653 | 654 | LoggerHandlerKit.Act.genserver_crash(:exit_with_struct) 655 | LoggerHandlerKit.Assert.assert_logged(ref) 656 | 657 | assert_receive [ 658 | {:path_params, [channel_id: "occurrences_channel_id"]}, 659 | {:form_multipart, [payload_json: body]} 660 | ] 661 | 662 | assert %{ 663 | applied_tags: [], 664 | message: %{ 665 | components: [ 666 | %{content: _}, 667 | %{content: message} 668 | ] 669 | }, 670 | name: 671 | <<_::binary-size(7)>> <> 672 | "** (exit) %LoggerHandlerKit.FakeStruct{hello: \"world\"}" <> _ 673 | } = body 674 | 675 | assert message =~ "GenServer" 676 | assert message =~ "terminating" 677 | end 678 | 679 | @tag config: [enable_presence: true] 680 | test "GenServer crash should not crash the logger handler", %{ 681 | handler_ref: handler_ref, 682 | config: config 683 | } do 684 | pid = self() 685 | ref1 = make_ref() 686 | ref2 = make_ref() 687 | 688 | DiscoLog.WebsocketClient.Mock 689 | |> expect(:boil_message_to_frames, fn _client, {:ssl, :fake_ssl_closed} -> 690 | {:error, nil, %Mint.TransportError{reason: :closed}} 691 | end) 692 | 693 | expect(API.Mock, :request, 2, fn 694 | client, method, "/channels/:channel_id/threads" = url, opts -> 695 | send(pid, {ref1, opts}) 696 | API.Stub.request(client, method, url, opts) 697 | 698 | # Presence will crash and restart, so we need to have gateway stubbed 699 | client, method, "/gateway/bot" = url, opts -> 700 | send(pid, {ref2, opts}) 701 | API.Stub.request(client, method, url, opts) 702 | end) 703 | 704 | pid = 705 | DiscoLog.Registry.via(config.supervisor_name, DiscoLog.Presence) |> GenServer.whereis() 706 | 707 | send(pid, {:ssl, :fake_ssl_closed}) 708 | 709 | LoggerHandlerKit.Assert.assert_logged(handler_ref) 710 | 711 | # Wait until Presence complete the crash to avoid race conditions 712 | assert_receive {^ref2, _} 713 | 714 | assert_receive {^ref1, 715 | [ 716 | {:path_params, [channel_id: "occurrences_channel_id"]}, 717 | {:form_multipart, [payload_json: body]} 718 | ]} 719 | 720 | assert %{ 721 | applied_tags: [], 722 | message: %{ 723 | components: [ 724 | %{content: _message}, 725 | %{content: message} 726 | ] 727 | }, 728 | name: 729 | <<_::binary-size(7)>> <> "** (exit) {:error, nil, %Mint.TransportError{reason …" 730 | } = body 731 | 732 | assert message =~ "GenServer {DiscoLog.Registry, DiscoLog.Presence} terminating" 733 | end 734 | 735 | test "GenServer timeout is reported", %{handler_ref: ref} do 736 | pid = self() 737 | 738 | expect(API.Mock, :request, fn client, method, url, opts -> 739 | send(pid, opts) 740 | API.Stub.request(client, method, url, opts) 741 | end) 742 | 743 | {:ok, pid} = LoggerHandlerKit.GenServer.start(nil) 744 | 745 | Task.start(fn -> 746 | GenServer.call(pid, {:run, fn -> Process.sleep(:infinity) end}, 0) 747 | end) 748 | 749 | LoggerHandlerKit.Assert.assert_logged(ref) 750 | 751 | assert_receive [ 752 | {:path_params, [channel_id: "occurrences_channel_id"]}, 753 | {:form_multipart, [payload_json: body]} 754 | ] 755 | 756 | assert %{ 757 | applied_tags: [], 758 | message: %{ 759 | components: [ 760 | %{content: _message}, 761 | %{content: message} 762 | ] 763 | }, 764 | name: <<_::binary-size(7)>> <> "** (exit) exited in …" 765 | } = body 766 | 767 | assert message =~ "timeout" 768 | end 769 | 770 | @tag config: [metadata: [:extra]] 771 | test "both configured metadata and context attached to the occurrence", %{handler_ref: ref} do 772 | pid = self() 773 | 774 | expect(API.Mock, :request, fn client, method, url, opts -> 775 | send(pid, opts) 776 | API.Stub.request(client, method, url, opts) 777 | end) 778 | 779 | try do 780 | {:ok, pid} = LoggerHandlerKit.GenServer.start(nil) 781 | 782 | GenServer.call( 783 | pid, 784 | {:run, 785 | fn -> 786 | Logger.metadata(extra: "hello") 787 | DiscoLog.Context.set(:foo, "bar") 788 | {:stop, :bad_exit, :no_state} 789 | end} 790 | ) 791 | catch 792 | :exit, {:bad_exit, _} -> :ok 793 | end 794 | 795 | LoggerHandlerKit.Assert.assert_logged(ref) 796 | 797 | assert_receive [ 798 | {:path_params, [channel_id: "occurrences_channel_id"]}, 799 | {:form_multipart, [payload_json: body]} 800 | ] 801 | 802 | assert %{ 803 | applied_tags: [], 804 | message: %{ 805 | components: [ 806 | _, 807 | _, 808 | %{type: 10, content: "```elixir\n%{extra: \"hello\", foo: \"bar\"}\n```"} 809 | ] 810 | } 811 | } = body 812 | end 813 | end 814 | 815 | describe "sasl reports" do 816 | @describetag handle_sasl_reports: true 817 | 818 | test "reports crashed c:GenServer.init/1", %{handler_ref: ref} do 819 | pid = self() 820 | 821 | expect(API.Mock, :request, fn client, method, url, opts -> 822 | send(pid, opts) 823 | API.Stub.request(client, method, url, opts) 824 | end) 825 | 826 | LoggerHandlerKit.Act.genserver_init_crash() 827 | LoggerHandlerKit.Assert.assert_logged(ref) 828 | 829 | assert_receive [{:path_params, [channel_id: "occurrences_channel_id"]} | _] 830 | end 831 | end 832 | end 833 | --------------------------------------------------------------------------------