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/<date>/MyLoadTest/master-<timestamp>.csv"
Chaperon.run_load_test MyLoadTest, tag: "master"
# Outputs metrics in JSON format to "results/<date>/MyLoadTest/master-<timestamp>.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