defmodule Chaperon do @moduledoc """ Chaperon is a HTTP service load & performance testing tool. """ use Application require Logger alias Chaperon.Util # See http://elixir-lang.org/docs/stable/elixir/Application.html # for more information on OTP Applications def start(_type, _args) do HTTPoison.start() Chaperon.Supervisor.start_link() end @spec connect_to_master(atom) :: :ok | {:error, any} def connect_to_master(node_name) do if Node.connect(node_name) do :ok else {:error, "Connecting to Chaperon master node failed: #{node_name}"} end end @version Mix.Project.config()[:version] def version do @version end @doc """ Connect the current node to a Chaperon cluster without taking on any work (not running any load test Session tasks). Useful if you want to connect to a running cluster for inspecting running load tests and kicking off new ones without performing any load testing work on the connecting node. """ @spec connect_to_master_without_work(atom) :: :ok | {:error, any} def connect_to_master_without_work(node_name) do if Node.connect(node_name) do # wait a bit because of node connection delays Process.sleep(500) :ok = Chaperon.Master.ignore_node_as_worker(node()) else {:error, "Connecting to Chaperon master node failed: #{node_name}"} end end @doc """ Runs a given load_test module's scenarios concurrently, outputting metrics at the end. - `lt_mod` LoadTest module to be executed - `options` List of options to be used. Valid values are: - `:print_results` If set to `true`, will print all action results. - `:export` Can be set to any module implementing the `Chaperon.Exporter` behaviour. Defaults to `Chaperon.Export.CSV`. When using `Chaperon.Export.S3` it defaults to the CSV export format. You can use another export format by wrapping it in a tuple, like so: `{Chaperon.Export.S3, Chaperon.Export.JSON}` - `:output` Can be set to a file path - `:tag` Can be set to be used when using the default export filename. Allows adding a custom 'tag' string as a prefix to the generated result output filename. - `:metrics` Filtering options for metrics Valid filters: - (metric) -> boolean - [metric_type] ## Example alias Chaperon.Export.JSON # Prints results & outputs metrics in CSV (default) format at the end Chaperon.run_load_test MyLoadTest, print_results: true # Doesn't print results & outputs metrics in JSON format at the end Chaperon.run_load_test MyLoadTest, export: JSON # Outputs metrics in CSV format to metrics.csv file Chaperon.run_load_test MyLoadTest, output: "metrics.csv" # Outputs metrics in JSON format to metrics.json file Chaperon.run_load_test MyLoadTest, export: JSON, output: "metrics.json" # Outputs metrics in CCSV format to "results//MyLoadTest/master-.csv" Chaperon.run_load_test MyLoadTest, tag: "master" # Outputs metrics in JSON format to "results//MyLoadTest/master-.json" Chaperon.run_load_test MyLoadTest, export: JSON, tag: "master" # Tracks only calls in MyScenario (can take any function that returns `true` or `false`) Chaperon.run_load_test MyLoadTest, tag: "master", metrics: fn {:call, {MyScenario, _}} -> true _ -> false end # You can also just pass a list of metric types/names: Chaperon.run_load_test MyLoadTest metrics: [ :run_scenario, :call, :post, :my_custom_metric ] """ def run_load_test(lt_mod, options \\ []) do timeout = Chaperon.LoadTest.timeout(lt_mod) config = options |> Keyword.get(:config, %{}) |> Map.put(:metrics, Chaperon.Scenario.Metrics.config(options)) results = Chaperon.LoadTest |> Task.async(:run, [lt_mod, config]) |> Task.await(timeout) duration_s = results.duration_ms / 1_000 duration_min = Float.round(results.duration_ms / 60_000, 2) lt_name = Chaperon.LoadTest.name(lt_mod) Logger.info( "#{lt_name} finished in #{results.duration_ms} ms (#{duration_s} s / #{duration_min} min)" ) if results.timed_out > 0 do succeeded = Enum.count(results.sessions) Logger.warn( "#{lt_name} : #{results.timed_out} sessions timed out. #{succeeded} sessions succeeded." ) end if options[:print_results] do print_results(results) end session = results |> Chaperon.LoadTest.merge_sessions() session = if session.config[:merge_scenario_sessions] do session |> Chaperon.Scenario.Metrics.add_histogram_metrics() else session end if options[:print_metrics] do print_metrics(session) end print_separator() {exporter, new_options} = options |> exporter {:ok, data} = exporter |> apply(:encode, [ session, Keyword.merge( new_options, load_test: Util.shortened_module_name(lt_mod), duration: duration_s ) ]) case options |> output(lt_mod) do :remote -> {:remote, session, data} output -> Logger.info("Using result exporter #{inspect(exporter)} with options #{inspect(options)}") {:ok, _} = exporter |> apply(:write_output, [ lt_mod, new_options, data, output ]) session end end defp output(options, lt_mod) do options |> Keyword.get(:output, default_output_file(options, lt_mod)) end def exporter(options) do case Keyword.get(options, :export, Chaperon.Export.CSV) do {exporter, nested_exporter} when exporter == Chaperon.Export.S3 -> options = options |> Keyword.put(:export, nested_exporter) Logger.info( "Chaperon.exporter | Using S3 exporter #{inspect(exporter)} with nested #{ inspect(nested_exporter) }" ) {Chaperon.Export.S3, options} exporter -> Logger.info("Chaperon.exporter | Using exporter #{inspect(exporter)}") {exporter, options |> Keyword.delete(:export)} end end # if output not defined by user, use default output format and file name defp default_output_file(options, lt_mod) do mod_name = lt_mod |> Chaperon.LoadTest.name() |> String.split(".") |> Enum.join("/") timestamp = DateTime.utc_now() |> DateTime.to_unix() dir = "results/#{Date.utc_today()}/#{mod_name}" case options[:tag] do nil -> "#{dir}/#{timestamp}" t -> "#{dir}/#{t}-#{timestamp}" end end def write_output_to_stdio(lt_mod, output) do IO.puts(output) print_separator() IO.inspect( %{ scenarios: lt_mod.scenarios, default_config: Chaperon.LoadTest.default_config(lt_mod) }, pretty: true ) :ok end def write_output_to_file(lt_mod, runtime_config, output, path) do path |> Path.dirname() |> File.mkdir_p!() File.write!(path, output) config_file_path = path <> ".config.exs" File.write!( config_file_path, inspect( lt_mod |> Chaperon.LoadTest.default_config() |> DeepMerge.deep_merge(runtime_config), pretty: true, limit: :infinity, printable_limit: :infinity ) ) scenarios_file_path = path <> ".scenarios.exs" File.write!( scenarios_file_path, inspect( lt_mod.scenarios, pretty: true, limit: :infinity, printable_limit: :infinity ) ) {:ok, [path, config_file_path, scenarios_file_path]} end defp print_separator do IO.puts("") IO.puts(for _ <- 1..80, do: "=") IO.puts("") end defp print_metrics(session) do print_separator() Logger.info("Metrics:") for {k, v} <- session.metrics do k = inspect(k) delimiter = for _ <- 1..byte_size(k), do: "=" IO.puts("#{delimiter}\n#{k}\n#{delimiter}") IO.inspect(v) IO.puts("") end end defp print_results(results) do print_separator() Logger.info("Results:") for session <- results.sessions do for {action, results} <- session.results do for res <- results |> List.wrap() |> List.flatten() do case res do {:async, name, results} when is_list(results) -> results |> Enum.each(&print_result(name, &1)) {:async, name, res} -> Logger.info("~> #{name} -> #{res.status_code}") results when is_list(results) -> results |> Enum.each(&print_result(action, &1)) res -> print_result(action, res) end end end end end defp print_result(action, %HTTPoison.Response{status_code: status_code}) do Logger.info("#{action} -> #{status_code}") end defp print_result(action, result) when is_binary(result) do Logger.info("#{action} -> #{result}") end end