diff --git a/lib/mix/mixer.ex b/lib/mix/mixer.ex index 4346295..922cc37 100644 --- a/lib/mix/mixer.ex +++ b/lib/mix/mixer.ex @@ -119,10 +119,6 @@ if not Code.ensure_loaded?(Bonfire.Mixer) do def deps_recompile(deps \\ deps_names(:bonfire)), do: Mix.Task.run("bonfire.dep.compile", ["--force"] ++ List.wrap(deps)) - # def flavour_path(path) when is_binary(path), do: path - def flavour_path(config \\ mix_config()), - do: System.get_env("FLAVOUR_PATH", "flavours/" <> flavour(config)) - def flavour(config \\ mix_config()) def flavour(default_flavour) when is_binary(default_flavour), @@ -133,8 +129,6 @@ if not Code.ensure_loaded?(Bonfire.Mixer) do def config_path(path \\ nil, filename), do: Path.expand(Path.join([path || "config", filename])) - # flavour_path(config_or_flavour) - def forks_path(), do: System.get_env("FORKS_PATH", "extensions/") def forks_paths(), do: [forks_path(), "forks/"] @@ -155,42 +149,42 @@ if not Code.ensure_loaded?(Bonfire.Mixer) do # |> log(label: "messy") end - def mess_other_flavour_deps(current_flavour \\ System.get_env("FLAVOUR", "classic")) do - current_flavour_sources = - mess_source_files(System.get_env("WITH_FORKS", "1"), System.get_env("WITH_GIT_DEPS", "1")) + # def mess_other_flavour_deps(current_flavour \\ System.get_env("FLAVOUR", "ember")) do + # current_flavour_sources = + # mess_source_files(System.get_env("WITH_FORKS", "1"), System.get_env("WITH_GIT_DEPS", "1")) - current_flavour_deps = - enum_mess_sources(current_flavour_sources) - # |> log(label: "current_mess_sources") - |> Mess.deps([], []) - |> deps_names_list() + # current_flavour_deps = + # enum_mess_sources(current_flavour_sources) + # # |> log(label: "current_mess_sources") + # |> Mess.deps([], []) + # |> deps_names_list() - # |> log(label: "current_flavour_deps", limit: :infinity) + # # |> log(label: "current_flavour_deps", limit: :infinity) - other_flavours_sources = other_flavour_sources(current_flavour_sources, current_flavour) - # |> log(label: "other_flavours_sources") + # other_flavours_sources = other_flavour_sources(current_flavour_sources, current_flavour) + # # |> log(label: "other_flavours_sources") - Mess.deps(other_flavours_sources, [], []) - # |> log(label: "other_flavours_deps") - |> Enum.reject(fn - {dep, _} -> dep in current_flavour_deps - {dep, _, _} -> dep in current_flavour_deps - end) - |> log("other_flavour_deps") - end + # Mess.deps(other_flavours_sources, [], []) + # # |> log(label: "other_flavours_deps") + # |> Enum.reject(fn + # {dep, _} -> dep in current_flavour_deps + # {dep, _, _} -> dep in current_flavour_deps + # end) + # |> log("other_flavour_deps") + # end - def mess_other_flavour_dep_names(current_flavour \\ System.get_env("FLAVOUR", "classic")) do - mess_other_flavour_deps(current_flavour) - |> deps_names_list() - end + # def mess_other_flavour_dep_names(current_flavour \\ System.get_env("FLAVOUR", "ember")) do + # mess_other_flavour_deps(current_flavour) + # |> deps_names_list() + # end defp maybe_all_flavour_sources( existing_sources, current_flavour, "1" = _WITH_ALL_FLAVOUR_DEPS ) do - (enum_mess_sources(existing_sources) ++ - [disabled: other_flavour_sources(existing_sources, current_flavour)]) + enum_mess_sources(existing_sources) + # ++ [disabled: other_flavour_sources(existing_sources, current_flavour)] |> log("all_flavour_sources") end @@ -198,27 +192,27 @@ if not Code.ensure_loaded?(Bonfire.Mixer) do enum_mess_sources(existing_sources) end - def other_flavour_sources( - existing_sources \\ mess_source_files( - System.get_env("WITH_FORKS", "1"), - System.get_env("WITH_GIT_DEPS", "1") - ), - current_flavour \\ System.get_env("FLAVOUR", "classic") - ) do - flavour_paths = - for path <- "flavours/*/config/" |> Path.wildcard() do - path - end - |> Enum.reject(&(&1 == "flavours/#{current_flavour}/config")) - - # |> log(label: "creams") - - Enum.map( - flavour_paths, - &(List.first(existing_sources) - |> enum_mess_sources(&1)) - ) - end + # def other_flavour_sources( + # existing_sources \\ mess_source_files( + # System.get_env("WITH_FORKS", "1"), + # System.get_env("WITH_GIT_DEPS", "1") + # ), + # current_flavour \\ System.get_env("FLAVOUR", "ember") + # ) do + # flavour_paths = + # for path <- "flavours/*/config/" |> Path.wildcard() do + # path + # end + # |> Enum.reject(&(&1 == "flavours/#{current_flavour}/config")) + + # # |> log(label: "creams") + + # Enum.map( + # flavour_paths, + # &(List.first(existing_sources) + # |> enum_mess_sources(&1)) + # ) + # end defp enum_mess_sources(sublist, path \\ nil) @@ -232,20 +226,27 @@ if not Code.ensure_loaded?(Bonfire.Mixer) do end defp mess_source_files("0" = _not_WITH_FORKS, "0" = _not_WITH_GIT_DEPS), - do: [[hex: "deps.flavour.hex"], [hex: "deps.hex"]] + do: [[hex: "current_flavour/deps.hex"], [hex: "deps.hex"]] defp mess_source_files("0" = _not_WITH_FORKS, "1" = _WITH_GIT_DEPS), - do: [[git: "deps.flavour.git", hex: "deps.flavour.hex"], [git: "deps.git", hex: "deps.hex"]] + do: [ + [git: "current_flavour/deps.git", hex: "current_flavour/deps.hex"], + [git: "deps.git", hex: "deps.hex"] + ] defp mess_source_files("1" = _WITH_FORKS, "0" = _not_WITH_GIT_DEPS), do: [ - [path: "deps.flavour.path", hex: "deps.flavour.hex"], + [path: "current_flavour/deps.path", hex: "current_flavour/deps.hex"], [path: "deps.path", hex: "deps.hex"] ] defp mess_source_files("1" = _WITH_FORKS, "1" = _WITH_GIT_DEPS), do: [ - [path: "deps.flavour.path", git: "deps.flavour.git", hex: "deps.flavour.hex"], + [ + path: "current_flavour/deps.path", + git: "current_flavour/deps.git", + hex: "current_flavour/deps.hex" + ], [path: "deps.path", git: "deps.git", hex: "deps.hex"] ] @@ -263,7 +264,7 @@ if not Code.ensure_loaded?(Bonfire.Mixer) do |> deps_names() # |> log( - # "Running Bonfire #{version(config)} at #{System.get_env("HOSTNAME", "localhost")} with configuration from #{flavour_path(config)} in #{Mix.env()} environment. You can run `just mix bonfire.deps.update` to update these extensions and dependencies" + # "Running Bonfire #{version(config)} at #{System.get_env("HOSTNAME", "localhost")} in #{Mix.env()} environment. You can run `just mix bonfire.deps.update` to update these extensions and dependencies" # ) end @@ -287,8 +288,8 @@ if not Code.ensure_loaded?(Bonfire.Mixer) do def extra_guide_paths(config) do deps = deps_names_for(:docs, config) ++ umbrella_extension_paths() + # Enum.map(Path.wildcard("flavours/*/README.md"), &flavour_readme/1) ++ List.wrap(config[:guides]) ++ - Enum.map(Path.wildcard("flavours/*/README.md"), &flavour_readme/1) ++ Enum.map(Path.wildcard("docs/DEPENDENCIES/*.md"), &flavour_deps_doc/1) ++ Enum.flat_map( deps, diff --git a/lib/mix/testing.ex b/lib/mix/testing.ex new file mode 100644 index 0000000..dabe473 --- /dev/null +++ b/lib/mix/testing.ex @@ -0,0 +1,100 @@ +defmodule Bonfire.Common.Testing do + def configure_start_test(opts \\ [migrate: false]) do + running_a_second_test_instance? = System.get_env("TEST_INSTANCE") == "yes" + + # Start ExUnitSummary application, with recommended config + # ExUnitSummary.start(:normal, %ExUnitSummary.Config{ + # filter_results: :success, + # # filter_results: :failed, + # print_delay: 100 + # }) + + ExUnit.configure( + # please note that Mneme overrides any custom formatters + formatters: Bonfire.Common.RuntimeConfig.test_formatters(), + #  miliseconds + timeout: 120_000, + assert_receive_timeout: 1000, + exclude: Bonfire.Common.RuntimeConfig.skip_test_tags(), + # only show log for failed tests (Can be overridden for individual tests via `@tag capture_log: false`) + capture_log: !running_a_second_test_instance? and System.get_env("CAPTURE_LOG") != "no" + ) + + # ExUnit.configuration() + # |> IO.inspect() + + # Code.put_compiler_option(:nowarn_unused_vars, true) + + ExUnit.start() + + if System.get_env("TEST_WITH_MNEME") != "no", + do: Mneme.start(), + else: Mneme.Options.configure([]) + + repo = Bonfire.Common.Config.repo() + + if repo do + try do + if opts[:migrate] do + Mix.Task.run("ecto.create") + Mix.Task.run("ecto.migrate") + EctoSparkles.Migrator.migrate(repo) + end + + # Ecto.Adapters.SQL.Sandbox.mode(repo, :manual) + + # if System.get_env("PHX_SERVER") !="yes" do + Ecto.Adapters.SQL.Sandbox.mode(repo, :auto) + # end + + # insert fixtures in test instance's repo on startup + if running_a_second_test_instance?, + do: + Bonfire.Common.TestInstanceRepo.apply(fn -> + nil + # EctoSparkles.Migrator.migrate(Bonfire.Common.TestInstanceRepo) + end) + rescue + e in RuntimeError -> + IO.warn("Could not set up database") + IO.inspect(e) + end + end + + # ExUnit.after_suite(fn results -> + # # do stuff + # IO.inspect(test_results: results) + + # :ok + # end) + + try do + Application.put_env(:wallaby, :base_url, Bonfire.Web.Endpoint.url()) + chromedriver_path = Bonfire.Common.Config.get([:wallaby, :chromedriver, :path]) + + if chromedriver_path && File.exists?(chromedriver_path), + do: {:ok, _} = Application.ensure_all_started(:wallaby), + else: + IO.inspect("Note: Wallaby UI tests will not run because the chromedriver is missing") + rescue + e in RuntimeError -> + IO.warn("Could not set up Wallaby UI tests ") + IO.inspect(e) + end + + IO.puts(""" + + Testing shows the presence, not the absence of bugs. + - Edsger W. Dijkstra + """) + + if System.get_env("OBSERVE") do + Bonfire.Application.observer() + end + + # ExUnit.configuration() + # |> IO.inspect() + + :ok + end +end diff --git a/lib/mix/testing_insecure_pw.ex b/lib/mix/testing_insecure_pw.ex new file mode 100644 index 0000000..94c7c09 --- /dev/null +++ b/lib/mix/testing_insecure_pw.ex @@ -0,0 +1,15 @@ +defmodule Bonfire.Common.Testing.InsecurePW do + # use Comeonin + + @impl true + def hash_pwd_salt(password, _opts \\ []) do + password + end + + @impl true + def verify_pass(_password, _stored_hash) do + true + end + + def no_user_verify, do: nil +end diff --git a/lib/mix_tasks/helpers.ex b/lib/mix_tasks/helpers.ex index 33a5e25..7708210 100644 --- a/lib/mix_tasks/helpers.ex +++ b/lib/mix_tasks/helpers.ex @@ -19,11 +19,18 @@ defmodule Bonfire.Common.Mix.Tasks.Helpers do igniter_copy(igniter, sources, target, opts) else - contents_to_copy = File.read!(source) + if File.exists?(source) do + contents_to_copy = File.read!(source) - Igniter.create_or_update_file(igniter, target, contents_to_copy, fn source -> - Rewrite.Source.update(source, :content, contents_to_copy) - end) + if String.contains?(target, "/"), do: File.mkdir_p!(Path.dirname(target)) + + Igniter.create_or_update_file(igniter, target, contents_to_copy, fn source -> + Rewrite.Source.update(source, :content, contents_to_copy) + end) + else + IO.puts("Warning: Source file `#{source}` does not exist") + igniter + end end end diff --git a/lib/mix_tasks/install/copy_configs.ex b/lib/mix_tasks/install/copy_configs.ex index 7ff940a..474c12f 100644 --- a/lib/mix_tasks/install/copy_configs.ex +++ b/lib/mix_tasks/install/copy_configs.ex @@ -47,10 +47,10 @@ defmodule Mix.Tasks.Bonfire.Install.CopyConfigs do def copy_for_extensions(igniter, extensions, opts) do IO.inspect(opts, label: "Options") - path = opts[:to] || Path.expand(@default_config_path, Bonfire.Mixer.flavour_path()) + to = opts[:to] || @default_config_path dest_path = - Path.expand(path, File.cwd!()) + Path.expand(to, File.cwd!()) |> IO.inspect(label: "to path") from = opts[:from] || @default_config_path diff --git a/lib/mix_tasks/install/copy_migrations.ex b/lib/mix_tasks/install/copy_migrations.ex index 3a24559..adf479b 100644 --- a/lib/mix_tasks/install/copy_migrations.ex +++ b/lib/mix_tasks/install/copy_migrations.ex @@ -21,7 +21,7 @@ defmodule Mix.Tasks.Bonfire.Install.CopyMigrations do """ @switches [from: :string, to: :string, force: :boolean] - @default_repo_path "repo" + @default_repo_path "priv/repo" @default_mig_path @default_repo_path <> "/migrations" def igniter(igniter, args) do @@ -46,16 +46,18 @@ defmodule Mix.Tasks.Bonfire.Install.CopyMigrations do def copy_for_extensions(igniter, extensions, opts) do IO.inspect(opts, label: "Options") - path = opts[:to] || Path.expand(@default_repo_path, Bonfire.Mixer.flavour_path()) + to = opts[:to] || @default_repo_path dest_path = - Path.expand(path, File.cwd!()) + Path.expand(to, File.cwd!()) |> IO.inspect(label: "to path") + from = opts[:from] || @default_mig_path + extension_paths = extensions |> IO.inspect(label: "deps to include") - |> Bonfire.Mixer.dep_paths(opts[:from] || "priv/" <> @default_mig_path) + |> Bonfire.Mixer.dep_paths(from) |> IO.inspect(label: "paths to copy") if igniter do diff --git a/lib/mix_tasks/install/install_extension.ex b/lib/mix_tasks/install/install_extension.ex index b72d3e3..ecd5281 100644 --- a/lib/mix_tasks/install/install_extension.ex +++ b/lib/mix_tasks/install/install_extension.ex @@ -51,9 +51,9 @@ defmodule Mix.Tasks.Bonfire.Install.Extension do defs = opts[:defs] || [ - path: "config/deps.flavour.path", - git: "config/deps.flavour.git", - hex: "config/deps.flavour.hex" + path: "config/current_flavour/deps.path", + git: "config/current_flavour/deps.git", + hex: "config/current_flavour/deps.hex" ] :ok = diff --git a/lib/modularity/extend.ex b/lib/modularity/extend.ex index babba09..847b108 100644 --- a/lib/modularity/extend.ex +++ b/lib/modularity/extend.ex @@ -769,7 +769,9 @@ defmodule Bonfire.Common.Extend do :ok """ def generate_reverse_router!() do - Utils.maybe_apply(Bonfire.Common.Config.endpoint_module(), :generate_reverse_router!) + Utils.maybe_apply(Config.get(:router_module, Bonfire.Web.Router), :generate_reverse_router!, [ + Config.get(:otp_app) + ]) |> debug("reverse_router generated?") end diff --git a/lib/telemetry/telemetry.ex b/lib/telemetry/telemetry.ex new file mode 100644 index 0000000..b61bcb2 --- /dev/null +++ b/lib/telemetry/telemetry.ex @@ -0,0 +1,89 @@ +defmodule Bonfire.Common.Telemetry do + require Logger + alias Bonfire.Common.Extend + + def setup(env, repo_module) do + setup_opentelemetry(env, repo_module) + + if repo_module do + EctoSparkles.Log.setup(repo_module) + # Ecto.DevLogger.install(repo_module) + + # if Code.ensure_loaded?(Mix) and Config.env() == :dev do + # OnePlusNDetector.setup(repo_module) + # end + + IO.puts("Ecto Repo logging is set up...") + + setup_oban() + + IO.puts("Oban logging is set up...") + end + + setup_wobserver() + + Corsica.Telemetry.attach_default_handler(log_levels: [rejected: :warning, invalid: :warning]) + + IO.puts("Corsica telemetry is set up...") + end + + def setup_opentelemetry(_env, repo_module) do + if System.get_env("ECTO_IPV6") do + # should we attempt to use ipv6 to connect to telemetry remotes? + :httpc.set_option(:ipfamily, :inet6fb4) + end + + if Application.get_env(:opentelemetry, :modularity) != :disabled do + IO.puts("NOTE: OTLP (open telemetry) data is being collected") + + if Application.get_env(:bonfire, Bonfire.Web.Endpoint, [])[:adapter] in [ + Phoenix.Endpoint.Cowboy2Adapter, + nil + ] and Extend.extension_enabled?(:opentelemetry_cowboy) do + :opentelemetry_cowboy.setup() + end + + if Extend.module_enabled?(OpentelemetryPhoenix), do: OpentelemetryPhoenix.setup() + if Extend.module_enabled?(OpentelemetryLiveView), do: OpentelemetryLiveView.setup() + + # Only trace Oban jobs to minimize noise + if Extend.module_enabled?(OpentelemetryOban), do: OpentelemetryOban.setup(trace: [:jobs]) + + if repo_module && Extend.module_enabled?(OpentelemetryEcto), + do: + repo_module.config() + |> Keyword.fetch!(:telemetry_prefix) + |> OpentelemetryEcto.setup() + else + IO.puts("NOTE: OTLP (open telemetry) data will NOT be collected") + end + end + + def setup_oban do + :telemetry.attach( + "bonfire-oban-errors", + [:oban, :job, :exception], + &Bonfire.Common.Telemetry.handle_event/4, + [] + ) + + Oban.Telemetry.attach_default_logger(encode: false) + end + + defp setup_wobserver do + # if Extend.module_enabled?(Wobserver) do + # Wobserver.register(:page, {"Task Bunny", :taskbunny, &Status.page/0}) + # Wobserver.register(:metric, [&Status.metrics/0]) + # end + end + + def handle_event([:oban, :job, :exception], measure, meta, _) do + # TODO: check if still necessary now that Sentry SDK has Oban integration + extra = + meta.job + |> Map.take([:id, :args, :meta, :queue, :worker]) + |> Map.merge(measure) + + Bonfire.Common.Errors.debug_log(extra, meta.error, meta.stacktrace, :error) + end +end diff --git a/lib/telemetry/telemetry_metrics.ex b/lib/telemetry/telemetry_metrics.ex new file mode 100644 index 0000000..4888e57 --- /dev/null +++ b/lib/telemetry/telemetry_metrics.ex @@ -0,0 +1,158 @@ +if Code.ensure_loaded?(Telemetry.Metrics) do + defmodule Bonfire.Common.Telemetry.Metrics do + use Supervisor + import Telemetry.Metrics + + def start_link(arg) do + Supervisor.start_link(__MODULE__, arg, name: __MODULE__) + end + + @impl true + def init(_arg) do + children = [ + # Telemetry poller will execute the given period measurements + # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics + {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} + # Add reporters as children of your supervision tree. + # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} + ] + + Supervisor.init(children, strategy: :one_for_one) + end + + @millis {:native, :millisecond} + + def metrics do + [ + # Phoenix + summary("phoenix.endpoint.stop.duration", unit: @millis), + summary("phoenix.router_dispatch.stop.duration", + tags: [:method, :route], + tag_values: &get_and_put_http_method/1, + unit: @millis + ), + summary("phoenix.error_rendered.duration", unit: @millis), + summary("phoenix.socket_connected.duration", unit: @millis), + summary("phoenix.channel_joined.duration", unit: @millis), + summary("phoenix.channel_joined.duration", unit: @millis), + + # Phoenix LiveView + summary("phoenix.live_view.mount.stop.duration", + unit: @millis, + tags: [:view, :connected?], + tag_values: &live_view_metric_tag_values/1 + ), + summary("phoenix.live_view.mount.exception.duration", + unit: @millis, + tags: [:view, :connected?], + tag_values: &live_view_metric_tag_values/1 + ), + summary("phoenix.live_view.handle_params.stop.duration", + unit: @millis, + tags: [:view, :connected?], + tag_values: &live_view_metric_tag_values/1 + ), + summary("phoenix.live_view.handle_params.exception.duration", + unit: @millis, + tags: [:view, :connected?], + tag_values: &live_view_metric_tag_values/1 + ), + summary("phoenix.live_view.handle_event.stop.duration", + unit: @millis, + tags: [:view, :event], + tag_values: fn metadata -> + Map.put(metadata, :view, "#{inspect(metadata.socket.view)}") + end + ), + summary("phoenix.live_view.handle_event.exception.duration", + unit: @millis, + tags: [:view, :event], + tag_values: fn metadata -> + Map.put(metadata, :view, "#{inspect(metadata.socket.view)}") + end + ), + summary("phoenix.live_component.handle_event.stop.duration", + unit: @millis, + tags: [:view, :event], + tag_values: fn metadata -> + Map.put(metadata, :view, "#{inspect(metadata.socket.view)}") + end + ), + summary("phoenix.live_component.handle_event.exception.duration", + unit: @millis, + tags: [:view, :event], + tag_values: fn metadata -> + Map.put(metadata, :view, "#{inspect(metadata.socket.view)}") + end + ), + + # Database Metrics + summary("bonfire.repo.query.total_time", unit: @millis), + summary("bonfire.repo.query.decode_time", unit: @millis), + summary("bonfire.repo.query.query_time", unit: @millis), + summary("bonfire.repo.query.queue_time", unit: @millis), + summary("bonfire.repo.query.idle_time", unit: @millis), + + # VM Metrics + summary("vm.memory.total", unit: {:byte, :megabyte}), + summary("vm.total_run_queue_lengths.total"), + summary("vm.total_run_queue_lengths.cpu"), + summary("vm.total_run_queue_lengths.io"), + + # Oban + summary("oban.workers.memory.total", tags: [:worker]) + ] + end + + defp periodic_measurements do + [ + # A module, function and arguments to be invoked periodically. + # This function must call :telemetry.execute/3 and a metric must be added above. + # {Bonfire.UI.Common.Web, :count_users, []} + {Bonfire.Common.Telemetry.Metrics, :oban_worker_memory, []} + ] + end + + defp get_and_put_http_method(%{conn: %{method: method}} = metadata) do + Map.put(metadata, :method, method) + end + + defp live_view_metric_tag_values(metadata) do + metadata + |> Map.put(:view, inspect(metadata.socket.view)) + |> Map.put(:connected?, get_connection_status(Phoenix.LiveView.connected?(metadata.socket))) + end + + defp get_connection_status(true), do: "Connected" + defp get_connection_status(_), do: "Disconnected" + + def oban_worker_memory() do + pid = Oban.Registry.whereis(Oban, {:producer, "default"}) + # |> IO.inspect(label: "Oban PID") + + if is_pid(pid) and Process.alive?(pid) do + %{running: running} = :sys.get_state(pid) + + Enum.map(running, fn {_ref, {pid, executor}} -> + {executor.job.worker, + Bonfire.Common.MemoryMonitor.get_memory_usage(executor.job.worker, pid)} + end) + # drop nils from workers we failed to check + |> Enum.reject(&is_nil/1) + |> Enum.group_by( + fn {worker, _memory} -> worker end, + fn {_worker, memory} -> memory end + ) + |> Enum.map(fn {worker, memory_list} -> + # sum up the amount of memory used by all instances of the worker. + # result will be zero if there are no active instances + :telemetry.execute( + [:oban, :workers, :memory], + %{total: Enum.sum(memory_list)}, + %{worker: worker} + ) + end) + end + end + end +end diff --git a/lib/telemetry/telemetry_storage.ex b/lib/telemetry/telemetry_storage.ex new file mode 100644 index 0000000..da6160f --- /dev/null +++ b/lib/telemetry/telemetry_storage.ex @@ -0,0 +1,66 @@ +defmodule Bonfire.Common.Telemetry.Storage do + use GenServer + + @history_buffer_size 50 + + def metrics_history(metric) do + GenServer.call(__MODULE__, {:data, metric}) + end + + def start_link(args) do + GenServer.start_link(__MODULE__, args, name: __MODULE__) + end + + @impl true + def init(metrics) do + Process.flag(:trap_exit, true) + + metric_histories_map = + metrics + |> Enum.map(fn metric -> + attach_handler(metric) + {metric, CircularBuffer.new(@history_buffer_size)} + end) + |> Map.new() + + {:ok, metric_histories_map} + end + + @impl true + def terminate(_, metrics) do + for metric <- metrics do + :telemetry.detach({__MODULE__, metric, self()}) + end + + :ok + end + + defp attach_handler(%{event_name: name_list} = metric) do + :telemetry.attach( + {__MODULE__, metric, self()}, + name_list, + &__MODULE__.handle_event/4, + metric + ) + end + + def handle_event(_event_name, data, metadata, metric) do + if data = Phoenix.LiveDashboard.extract_datapoint_for_metric(metric, data, metadata) do + GenServer.cast(__MODULE__, {:telemetry_metric, data, metric}) + end + end + + @impl true + def handle_cast({:telemetry_metric, data, metric}, state) do + {:noreply, update_in(state[metric], &CircularBuffer.insert(&1, data))} + end + + @impl true + def handle_call({:data, metric}, _from, state) do + if history = state[metric] do + {:reply, CircularBuffer.to_list(history), state} + else + {:reply, [], state} + end + end +end diff --git a/lib/telemetry/telemetry_system_monitor.ex b/lib/telemetry/telemetry_system_monitor.ex new file mode 100644 index 0000000..d4e0414 --- /dev/null +++ b/lib/telemetry/telemetry_system_monitor.ex @@ -0,0 +1,91 @@ +defmodule Bonfire.Common.Telemetry.SystemMonitor do + import Untangle + alias Bonfire.Common.Config + + # NOTE: see `config :os_mon` for what triggers this + + def init({_args, {:alarm_handler, alarms}}) do + debug("Custom alarm handler init...") + for {alarm_name, alarm_description} <- alarms, do: handle_alarm(alarm_name, alarm_description) + {:ok, []} + end + + def handle_event({:set_alarm, {alarm_name, alarm_description}}, state) do + handle_alarm(alarm_name, alarm_description) + {:ok, state} + end + + def handle_event({:clear_alarm, {alarm_name, _alarm_description}}, state) do + state + |> debug("Clearing the alarm #{alarm_name}") + + {:ok, state} + end + + def handle_event({:clear_alarm, alarm_name}, state) do + state + |> debug("Clearing the alarm #{alarm_name}") + + {:ok, state} + end + + def handle_alarm({alarm_name, alarm_description}, []), + do: handle_alarm(alarm_name, alarm_description) + + def handle_alarm(:disk_almost_full = alarm_name, alarm_description) do + handle_alarm( + "#{alarm_name} : #{alarm_description}", + :disksup.get_disk_data() + |> Enum.map(fn {mountpoint, kbytes, percent} -> + "#{mountpoint} is at #{format_percent(percent)} of #{Sizeable.filesize(kbytes * 1024)}" + end) + |> Enum.join("\n") + ) + end + + def handle_alarm(:process_memory_high_watermark = alarm_name, alarm_description) do + {total, allocated, {worst_pid, worst_usage}} = :memsup.get_memory_data() + # system_memory = :memsup.get_system_memory_data() + # system_total = system_memory[:total_memory] || system_memory[:system_total_memory] || total + + handle_alarm( + "#{alarm_name} : #{alarm_description}", + "OTP memory: #{Sizeable.filesize(allocated)} allocated of #{Sizeable.filesize(total)} (highest usage by #{inspect(worst_pid)}: #{Sizeable.filesize(worst_usage)} )" + # <>"\nSystem memory is #{format_percent(system_total/system_memory[:free_memory])} free (#{Sizeable.filesize(system_memory[:free_memory])} of #{Sizeable.filesize(system_total)})" + ) + end + + def handle_alarm(alarm_name, alarm_description) when not is_binary(alarm_description), + do: handle_alarm(alarm_name, inspect(alarm_description)) + + def handle_alarm(alarm_name, alarm_description) do + warn(alarm_description, "System monitor alarm: #{alarm_name}") + + case Config.get(:env) == :prod and Config.get([Bonfire.Mailer, :reply_to]) do + false -> + :skip + + nil -> + warn("You need to configure an email") + + to -> + title = "Alert: #{alarm_name}" + + Bonfire.Mailer.new( + subject: "[System alert from Bonfire] " <> title, + html_body: + title <> + "

" <> String.replace(alarm_description, "\n", "
") <> "

", + text_body: title <> " " <> alarm_description + ) + |> Bonfire.Mailer.send_async(to) + end + end + + @doc """ + Formats percent. + """ + def format_percent(percent) when is_float(percent), do: "#{Float.round(percent, 1)}%" + def format_percent(nil), do: "0%" + def format_percent(percent), do: "#{percent}%" +end diff --git a/mix.exs b/mix.exs index cfd2a83..48d928c 100755 --- a/mix.exs +++ b/mix.exs @@ -36,12 +36,13 @@ defmodule Bonfire.Common.MixProject do {:text, "~> 0.2.0", optional: true}, {:text_corpus_udhr, "~> 0.1.0", optional: true}, {:bumblebee, "~> 0.6.0", optional: true}, + {:telemetry_metrics, "~> 1.0", optional: true}, # needed for graphql client, eg github for changelog {:neuron, "~> 5.0", optional: true}, # for extension install + mix tasks that do patching {:igniter, "~> 0.5", optional: true}, # for encryption - {:cloak, "~> 1.1.4", optional: true} + {:cloak, "~> 1.1.4", optional: true}, ]) ] end