├── 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 |
4 |
5 |
6 |
7 | > Use Discord as a logging service and error tracking solution
8 |
9 | [video demo](https://youtu.be/gYFN15aHP6o)
10 |
11 | [
](https://discord.gg/ReqNqU7Nde)
12 |
13 |
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 | 
31 | 
32 | 
33 |
34 | ### Edit the Discord Server settings
35 |
36 | *Right-click on the server and select `Server Settings` > `Community Settings`*
37 | 
38 | 
39 | 
40 | 
41 | 
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 | 
48 |
49 | ## Create a Discord Bot
50 |
51 | Go to the [developers portal](https://discord.com/developers/applications)
52 |
53 | 
54 | 
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 | 
58 |
59 | *Generate and copy the bot token, it will be needed later*
60 | 
61 |
62 | ## Add Bot to your Server
63 |
64 | *Go to the installation menu and open the installation link*
65 |
66 | 
67 |
68 | *Follow the steps*
69 |
70 | 
71 | 
72 | 
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 | 
106 | 
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 | 
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 | 
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 |
--------------------------------------------------------------------------------