Interceptor (interceptor v0.5.4) View Source

The Interceptor library allows you to intercept function calls by configuring your interception functions and then:

Start by creating a module with a get_intercept_config/0 function that returns the interception configuration map.

In the example below, the Intercepted.abc/1 function will be intercepted before it starts, after it ends, and when it concludes successfully or not:

defmodule Interception.Config do
  def get_intercept_config, do: %{
    {Intercepted, :abc, 1} => [
      before: {MyInterceptor, :intercept_before, 1},
      after: {MyInterceptor, :intercept_after, 2},
      on_success: {MyInterceptor, :intercept_on_success, 3},
      on_error: {MyInterceptor, :intercept_on_error, 3}
      # there's also a `wrapper` callback available!
    ]
  }
end

Point to the previous configuration module in your configuration:

# [...]
config :interceptor,
    configuration: Interception.Config

Define the callback functions that will be called during the execution of your intercepted functions:

defmodule MyInterceptor do
  def intercept_before(mfargs),
    do: IO.puts "Intercepted #{inspect(mfargs)} before it started."

  def intercept_after(mfargs, result),
    do: IO.puts "Intercepted #{inspect(mfargs)} after it completed. Its result: #{inspect(result)}"

  def intercept_on_success(mfargs, result, _start_timestamp),
    do: IO.puts "Intercepted #{inspect(mfargs)} after it completed successfully. Its result: #{inspect(result)}"

  def intercept_on_error(mfargs, error, _start_timestamp),
    do: IO.puts "Intercepted #{inspect(mfargs)} after it raised an error. Here's the error: #{inspect(error)}"
end

Finally, wrap the functions to intercept with an Interceptor.intercept/1 block (Interceptor.intercept/1 is actually a macro). Notice that if your functions are placed outside of this block or if they don't have a corresponding interceptor configuration, they won't be intercepted.

This is how the Intercepted module using the intercept/1 macro looks like:

defmodule Intercepted do
  require Interceptor, as: I

  I.intercept do
    def abc(x), do: "Got #{inspect(x)}"
  end

  # the following function can't be intercepted
  # because it isn't enclosed in the `Interceptor.intercept/1` block
  def not_intercepted(f, g, h), do: f+g+h
end

Or, use the @intercept true approach 💡

If you don't like to use the Interceptor.intercept/1 block, you can annotate your functions with @intercept true and use the Interceptor.Annotated module. Please check the Interceptor.Annotated module documentation for more information.

Callbacks 101

In the previous example, we defined four callbacks:

  • a before callback, that will be called before the intercepted function starts;
  • an after callback, that will be called after the intercepted function completes;
  • an on_success callback, that will be called if the function completes successfully;
  • an on_error callback, that will be called if the function raises any error.

Now when you run your code, whenever the Intercepted.abc/1 function is called, it will be intercepted before it starts, after it completes, when it completes successfully or when it raises an error.

You can also intercept private functions in the exact same way you intercept public functions. You just need to configure the callbacks that should be invoked for the given private function, and the private function definition needs to be enclosed in an Interceptor.intercept/1 macro.

MFA passed to your callbacks

Every function callback that you define will receive as its first argument a "MFArgs" tuple, i.e., {intercepted_module, intercepted_function, intercepted_args}. The intercepted_args is a list of arguments passed to your intercepted function. Even if your intercepted function only receives a single argument, intercepted_args will still be a list with a single element.

Pro-tip: Since your callback function receives the arguments that the intercepted function received, you can pattern match on the argument values function. ⚠️ Just have in mind that if your intercepted_args don't pattern match the values your callback function expects, you'll get an error every time your callback function does its thing and intercepts the function.

Wrapper callback (aka build your custom callback)

If none of the previous callbacks suits your needs, you can use the wrapper callback. This way, the intercepted function will be wrapped in a lambda and passed to your callback function.

When you use a wrapper callback, you can't use any other callback, i.e., the before, after, on_success and on_error callbacks can't be used for a function that is already being intercepted by a wrapper callback. If you try so, an exception in compile-time will be raised.

When you use the wrapper callback, it's the responsibility of the callback function to invoke the lambda and return the result. If you don't return the result from your callback, the return value of the intercepted function will be whatever value your wrapper callback function returns.

Possible callbacks

  • before - The callback function that you use to intercept your function will be passed the MFArgs ({intercepted_module, intercepted_function, intercepted_args}) of the intercepted function, hence it needs to receive one argument. E.g.:
defmodule BeforeInterceptor do
  def called_before_your_function({module, function, args}) do
    ...
  end
end
  • after - The callback function that you use to intercept your function will be passed the MFArgs ({intercepted_module, intercepted_function, intercepted_args}) of the intercepted function and its result, hence it needs to receive two arguments. E.g.:
defmodule AfterInterceptor do
  def called_after_your_function({module, function, args}, result) do
    ...
  end
end
  • on_success - The callback function that you use to intercept your function on success will be passed the MFArgs ({intercepted_module, intercepted_function, intercepted_args}) of the intercepted function, its success result and the start timestamp (in microseconds, obtained with :os.system_time(:microsecond)), hence it needs to receive three arguments. E.g.:
defmodule SuccessInterceptor do
  def called_when_your_function_completes_successfully(
    {module, function, args}, result, start_timestamp) do
    ...
  end
end
  • on_error - The callback function that you use to intercept your function on error will be passed the MFArgs ({intercepted_module, intercepted_function, intercepted_args}) of the intercepted function, the raised error and the start timestamp (in microseconds, obtained with :os.system_time(:microsecond)), hence it needs to receive three arguments. E.g.:
defmodule ErrorInterceptor do
  def called_when_your_function_raises_an_error(
    {module, function, args}, error, start_timestamp) do
    ...
  end
end
  • wrapper - The callback function that you use to intercept your function will be passed the MFArgs ({intercepted_module, intercepted_function, intercepted_args}) of the intercepted function and its body wrapped in a lambda, hence it needs to receive two arguments. E.g.:
defmodule WrapperInterceptor do
  def called_instead_of_your_function(
    {module, function, args}, intercepted_function_lambda) do
    # do something with the result, or measure how long the lambda call took
    result = intercepted_function_lambda.()

    result
  end
end

Streamlined configuration

If you think that defining a get_intercept_config/0 function on the configuration module or using the {module, function, arity} format is too verbose, you can use the Interceptor.Configurator module that will allow you to use its intercept/2 macro and the "Module.function/arity" streamlined format.

Using the Configurator and the new streamlined format, the previous configuration for the Intercepted.abc/1 function would become:

defmodule Interception.Config do
  use Interceptor.Configurator

  intercept "Intercepted.abc/1",
    before: "MyInterceptor.intercept_before/1",
    after: "MyInterceptor.intercept_after/2"
    on_success: "MyInterceptor.intercept_on_success/3",
    on_error: "MyInterceptor.intercept_on_error/3"
    # there's also a `wrapper` callback available!

  intercept "OtherModule.another_function/2",
    on_success: "OtherInterceptor.success_callback/3"

  # ...
end

The Configurator module is defining the needed get_intercept_config/0 function for you, and converting those string MFAs into tuple-based MFAs. If you want to intercept another function, it's just a matter of adding other intercept "OtherModule.another_function/2", ... entry, exactly as we did.

Intercept configuration on the intercepted module

If you don't want to place the intercept configuration on the application configuration file, you can set it directly on the intercepted module, just add use Interceptor, config: <config_module>, instead of requiring the Interceptor module. Using the previous Intercepted module as an example:

defmodule Intercepted do
  use Interceptor, config: Interception.Config

  Interceptor.intercept do
    def abc(x), do: "Got #{inspect(x)}"
  end

  def not_intercepted(f, g, h), do: f+g+h
end

Note1: If the configuration you set on the intercepted module overlaps with a configuration set on the application configuration file, the former will take precedence, i.e., if both the intercepted module configuration and the application configuration set the rules to intercept the Intercepted.abc/1 function, the rules set on the intercepted module will prevail, overriding the rules set on the application configuration file.

Instead of pointing to the intercept configuration module, you may also pass the intercept configuration directly via the config keyword. E.g:

defmodule Intercepted do
  use Interceptor, config: %{
    "Intercepted.abc/1" => [
      before: "MyInterceptor.intercept_before/1",
      after: "MyInterceptor.intercept_after/2"
    ]
  }

  Interceptor.intercept do
    def abc(x), do: "Got #{inspect(x)}"
  end

  def not_intercepted(f, g, h), do: f+g+h
end

Notice that we're using the streamlined format for the MFAs, but we could also use the more verbose tuple-based MFAs.

Link to this section Summary

Functions

Use this macro to wrap all the function definitions of your modules that you want to intercept. Remember that you need to configure how the interception

This function will be called as the error callback, in those cases when you only define a success callback for your intercepted function.

This function will be called as the success callback, in those cases when you only define an error callback for your intercepted function.

Link to this section Functions

Link to this function

add_calls(function, current_module)

View Source
Link to this macro

intercept(list)

View Source (macro)

Use this macro to wrap all the function definitions of your modules that you want to intercept. Remember that you need to configure how the interception

Here's an example of a module that we want to intercept, using the Interceptor.intercept/1 macro:

defmodule ModuleToBeIntercepted do
  require Interceptor, as: I

  I.intercept do
    def foo(x), do: "Got #{inspect(x)}"
    def bar, do: "Hi"
    def baz(a, b, c, d), do: a + b + c + d
  end
end
Link to this function

intercept_it(caller, function_def)

View Source
Link to this function

on_error_default_callback(mfa, error, started_at)

View Source

This function will be called as the error callback, in those cases when you only define a success callback for your intercepted function.

Link to this function

on_success_default_callback(mfa, result, started_at)

View Source

This function will be called as the success callback, in those cases when you only define an error callback for your intercepted function.