lib/opencensus/test_support/span_capture_reporter.ex

defmodule Opencensus.TestSupport.SpanCaptureReporter do
  @moduledoc """
  An `:opencensus.reporter` to capture spans for tests.

  `:oc_reporter` can't unregister reporters, but `:telemetry` can detach handlers, so we configure
  `:opencensus` to send spans to use our reporter, in `mix.exs`:

  ```elixir
  if Mix.env() == :test do
    config :opencensus,
      send_interval_ms: 100,
      reporters: [{Opencensus.TestSupport.SpanCaptureReporter, []}]
  end
  ```

  It'll call `:telemetry.execute/3` whenever spans are reported. If you've called `attach/0`,
  the handler will convert the spans to structs with `Span.from/1` and deliver them to your
  process inbox. To collect them, call `collect/0`. When you're finished, call `detach/0`:

  ```elixir
  defmodule NyApp.SpanCaptureTest do
    use ExUnit.Case, async: false

    alias Opencensus.TestSupport.SpanCaptureReporter

    setup do
      SpanCaptureReporter.attach()
      on_exit(make_ref(), &SpanCaptureReporter.detach/0)
    end

    test "can gather spans" do
      :ocp.with_child_span("our span name")
      :ocp.finish_span()
      [span] = SpanCaptureReporter.collect()
      assert span.name == "our span name"
    end
  end
  ```
  """

  alias Opencensus.Span

  @behaviour :oc_reporter

  @impl true
  def init([]), do: []

  @impl true
  def report(spans, []) do
    :telemetry.execute([__MODULE__], %{}, %{spans: spans})
    :ok
  end

  @doc false
  def handler([__MODULE__], %{}, %{spans: spans}, {pid, attached_monotonic}) do
    started_after_attach? = fn span -> span |> Span.began_monotonic() >= attached_monotonic end
    filtered = spans |> Enum.filter(started_after_attach?)
    send(pid, {:spans, filtered})
  end

  @doc "Attach the reporter to deliver spans to your process inbox."
  def attach do
    :telemetry.attach(__MODULE__, [__MODULE__], &handler/4, {self(), :erlang.monotonic_time()})
  end

  @doc """
  Detach the reporter to stop delivering spans to your process inbox.

  If still attached, triggers span delivery before detaching.
  """
  def detach do
    if handler_attached?(), do: trigger_and_wait_for_span_delivery()
    :telemetry.detach(__MODULE__)
  end

  @doc """
  Collect spans from your process inbox.

  If still attached, triggers span delivery before collection.
  """
  @spec collect() :: list(%Span{})
  def collect do
    if handler_attached?(), do: trigger_and_wait_for_span_delivery()
    collect_span_records([]) |> Enum.map(&Span.from/1)
  end

  defp trigger_and_wait_for_span_delivery do
    send(:oc_reporter, :report_spans)
    :timer.sleep(1)
  end

  defp handler_attached? do
    :telemetry.list_handlers([__MODULE__]) != []
  end

  defp collect_span_records(acc) when is_list(acc) do
    receive do
      {:spans, spans} -> collect_span_records(acc ++ spans)
    after
      1 -> acc
    end
  end
end