StepWise
is a light wrapper for the parts of your code which need to be debuggable in production.
That means that it:
- ...encourages the breaking down of such code into steps
- ...requires that each step returns a success or failure state (via standard
{:ok, _}
and{:error, _}
tuples) - ...provides telemetry events to separate/centralize code for concerns such as logging, metrics, and tracing.
Let's start with some code...
defmodule MyApp.NotifyCommenters do
# This outer `step` isn't neccessary, but it is a useful convention to be able to
# track the status of the whole run in addition to individual steps.
def run(post) do
StepWise.step({:ok, post}, &run_steps/1)
end
def run_steps(post) do
{:ok, post}
|> StepWise.step(&MyApp.Posts.get_comments/1)
|> StepWise.map_step(fn comment ->
# get_commenter/1 doesn't return `{:ok, _}`, so we need
# to do that here
{:ok, MyApp.Posts.get_commenter(comment)}
end)
|> StepWise.step(¬ify_users/1)
end
def notify_users(user) do
# ...
end
end
You might notice that the step/1
and map_step/1
functions take function values. These can be anonymous (i.e. as in map_step
above), though errors will be clearer when using function values coming from named functions.
The step
and map_step
functions rescue
/ catch
anything which bubbles up so that you don't have to. All exceptions/throws will be returned as {:error, _}
tuples so that they can be handled. exit
s, however, are not caught on purpose because, as this Elixir guide says: "exit signals are an important part of the fault tolerant system provided by the Erlang VM..."
{:error, _}
tuples will always be returned with Exception values (all {:error, _}
tuples without exceptions will be wrapped). This means that you can:
- ...call
Exception.message
to get a string - ...
raise
the exception value if you want to raise the error - ...hand the exception to error-collecting services like Sentry, Rollbar, etc...
- ...pattern match or act upon on the structure and attributes of the exception
You might also check out this tweet from Andrea Leopardi and the linked blog post regarding Exception
values in error tuples.
If you are familiar with Elixir's with
, you may be wondering about it's relation to StepWise
since with
also helps you handle a series of statements which could succeed or fail. See below for more discussion StepWise
vs with
.
As my colleague put it: "Logging definitely feels like one of those areas where it very quickly jumps from 'these sprinkled out log calls are giving us a lot of value' to 'we now have a mess in both code and log output'"
Central to StepWise
is it's telemetry events to allow actions such as logging, metrics, and tracing be separated as a different concern from your code. There are three telemetry events:
[:step_wise, :step, :start]
Executed when a step starts with the following metadata:
id
: A unique ID generated by:erlang.unique_integer()
step_func
: The function object given to thestep
/step_map
functionmodule
: The module where thestep_func
is defined (for convenience)func_name
: The name of thestep_func
(for convenience)system_time
: The system time when the step was started
[:step_wise, :step, :stop]
Executed when a step stop with all of the same metadata as the start
event, but also with:
result
: the value ({:ok, _}
or{:error, _}
tuple) that was returned from the step functionsuccess
: A boolean describing if the result was a success (for convenience, based onresult
)
There is also a duration
measurement value to give the total time taken by the step (in the native
time unit)
If you use phoenix
you'll get telemetry_metrics
and a MyAppWeb.Telemetry
module by default. In that case you can easily get metrics for time and total counts for all steps that you create:
summary([:step_wise, :step, :stop, :duration],
unit: {:native, :millisecond},
tags: [:hostname, :module, :func_name]
),
counter([:step_wise, :step, :stop, :duration],
unit: {:native, :millisecond},
tags: [:hostname, :module, :func_name]
),
Here is an example of how you might implement logging for your steps (call MyApp.StepWiseIntegration.install()
somewhere like your MyApp.Application.start/2
):
defmodule MyApp.StepWiseIntegration do
def install do
:telemetry.attach_many(
__MODULE__,
[
# [:step_wise, :step, :start],
[:step_wise, :step, :stop],
],
&__MODULE__.handle/4,
[]
)
end
def handle(
[:step_wise, :step, :stop],
%{duration: duration},
%{module: module, func_name: func_name, result: result},
_config
) do
case result do
{:error, exception} ->
Logger.error(Exception.message(exception))
# Since `StepWise` wraps all errors, calling `Exception.message` will return
# information about the if the error was returned/raised and about which
# step it came from. In the code above, calling `Exception.message` on a returned
# exception might give us a string like:
# "There was an error *returned* in MyApp.NotifyCommenters.notify_users/1:\n\n\"Email server is not available\""
{:ok, value} ->
log_info("#{module}.#{func_name} *succeeded* in #{duration}")
# You may not choose to log successes if it generates too many logs
end
end
end
First, while StepWise
has some overlap with with
's ability to handle errors, it's attempting to solve a specific problem (improving debugging of production code). Let's discuss some of the differences:
The with
clause in Elixir is a way to specify a pattern-matched "happy path" for a series of expressions. The first expression which does not match it's corresponding pattern will be either:
- ...returned from the
with
(if noelse
is given) - ...given to a series of pattern matches (using
else
)
with
also doesn't rescue
or catch
for you.
- ...uses functions to give identification to steps when something goes wrong.
- ...
rescue
s from exceptions andcatch
es throws. - ...requires the use of
{:ok, _}
/{:error, _}
tuples. - ...emits
telemetry
events to allow for integration with various debugging tools.
Above is a primary use-case of chaining together functions in a pipe-like way (starting with one value and transforming or replacing it as the chain progresses). In some cases, however, you may want to use a more GenServer
-like style where you have a state object that is modified along the way:
def EmailPost do
import StepWise
def run(user_id, post_id) do
%{user_id: user_id, post_id: post_id}
|> step(&MyApp.Posts.get_comments/1)
|> step(&fetch_user_data/1)
|> step(&fetch_post_data/1)
|> step(&finalize/1)
end
def fetch_user_data(%{user_id: id} = state) do
{:ok, Map.put(state, :user, MyApp.Users.get(id))}
end
def fetch_post_data(%{post_id: id} = state) do
{:ok, Map.put(state, :post, MyApp.Posts.get(id))}
end
def finalize(%{user: user, post: post}) do
# ...
end
end
Note that import StepWise
is used here. The first example used the StepWise
module explicitly to demonstrate the recommendation from the Elixir guides to prefer alias
over import
. But in self-contained modules you may find the style of import
preferable.
If available in Hex, the package can be installed
by adding step_wise
to your list of dependencies in mix.exs
:
def deps do
[
{:step_wise, "~> 0.1.0"}
]
end
Documentation can be generated with ExDoc and published on HexDocs. Once published, the docs can be found at https://hexdocs.pm/step_wise.