lib/hummingbird.ex

defmodule Hummingbird do
  @moduledoc """
  A plug for shipping events to honeycomb for tracing.

  Assumes that incoming requests use the b3 propagation headers.

  Add it to your endpoint:

      defmodule MyAppWeb.Endpoint do
        use Phoenix.Endpoint, otp_app: :my_app

        plug Hummingbird

  or under a branch of your router.

  Add the telemetry genserver to your application:

      children = [
        ..
        Hummingbird.Telemetry,
        ..
      ]
  """

  use Plug.Builder

  alias Opencensus.Honeycomb.Event
  alias Hummingbird.Impl

  @sender Application.get_env(:hummingbird, :sender, Hummingbird.Sender)

  @doc false
  def init(opts), do: Keyword.take(opts, [:service_name])

  @doc false
  def call(conn, opts) do
    conn |> put_private(:hummingbird, trace_info_from_conn(conn, opts))
  end

  defp trace_info_from_conn(conn, opts) do
    %{
      trace_id: Impl.trace_id(conn),
      parent_id: Impl.parent_id(conn),
      span_id: Impl.span_id(conn),
      span_start: Event.now(),
      sample?: Impl.sampling_state(conn),
      service_name: Keyword.get(opts, :service_name),
      events: []
    }
  end

  @doc false
  def send_spans(conn) do
    with {:ok, hummingbird} <- Map.fetch(conn.private, :hummingbird),
         true <- hummingbird.sample? do
      [build_generic_honeycomb_event(conn) | conn.private.hummingbird.events]
      |> @sender.send_batch()
    else
      _ -> :ok
    end

    conn
  end

  @doc false
  # Wraps the conn in what honeycomb craves for beeeeee processing
  # Many things are in the conn and duplicated here.  The reason is by normalizing
  # the output here, we can tell honeycomb to always look in the same place for
  # user events. They don't (afaik) have an easy ability to translate different
  # shapes of events on their side

  # For example:  `booked_by:` is a different actor location than the assigns for
  # a conn.
  def build_generic_honeycomb_event(%{private: %{hummingbird: hummingbird}} = conn) do
    %Event{
      time: hummingbird.span_start,
      samplerate: 1,
      data: %{
        conn: conn |> Impl.sanitize() |> Impl.encode_params(),
        component: "app",
        name: Impl.endpoint_name(conn),
        traceId: hummingbird.trace_id,
        id: hummingbird.span_id,
        parentId: hummingbird.parent_id,
        user_id: conn.assigns[:current_user][:user_id],
        serviceName: hummingbird.service_name,
        durationMs: Impl.convert_time_unit(conn.assigns[:request_duration_native]),
        http: Impl.http_metadata_from_conn(conn)
        # This is incorrect, but do not know how to programatically assign based on
        # type.  My intuation is we would create a different build_ for that
        # application.
        #
        # kind: "span_event"
      }
    }
  end

  # coveralls-ignore-start
  def build_generic_honeycomb_event(_conn), do: nil
  # coveralls-ignore-stop

  @doc """
  Produces a random span ID.

  Produces a string of lowercase hex-encoded characters of length 16 by
  default.
  """
  def random_span_id(length \\ 16) do
    length
    |> :crypto.strong_rand_bytes()
    |> Base.encode16()
    |> binary_part(0, length)
    |> String.downcase()
  end

  @doc """
  Produces a random trace ID.

  Follows the same generation rules as a span ID, but 32 characters are used
  instead of 16.
  """
  def random_trace_id, do: random_span_id(32)

  @doc """
  Produces a list of headers for trace propagation given a conn
  """
  def propagation_headers(conn) do
    [
      {"x-b3-traceid", Impl.trace_id(conn)},
      {"x-b3-parentid", get_in(conn.private, [:hummingbird, :parent_id])},
      {"x-b3-spanid", Impl.span_id(conn)},
      {"x-b3-sampled", Impl.sampling_state_to_header_value(conn)}
    ]
    |> Enum.reject(fn {_k, v} -> v |> is_nil end)
  end
end