lib/tesla/middleware/telemetry.ex

if Code.ensure_loaded?(:telemetry) do
  defmodule Tesla.Middleware.Telemetry do
    @moduledoc """
    Emits events using the `:telemetry` library to expose instrumentation.

    ## Examples

    ```
    defmodule MyClient do
      use Tesla

      plug Tesla.Middleware.Telemetry
    end

    :telemetry.attach(
      "my-tesla-telemetry",
      [:tesla, :request, :stop],
      fn event, measurements, meta, config ->
        # Do something with the event
      end,
      nil
    )
    ```

    ## Options

    - `:metadata` - additional metadata passed to telemetry events

    ## Telemetry Events

    * `[:tesla, :request, :start]` - emitted at the beginning of the request.
      * Measurement: `%{system_time: System.system_time()}`
      * Metadata: `%{env: Tesla.Env.t()}`

    * `[:tesla, :request, :stop]` - emitted at the end of the request.
      * Measurement: `%{duration: native_time}`
      * Metadata: `%{env: Tesla.Env.t()} | %{env: Tesla.Env.t(), error: term()}`

    * `[:tesla, :request, :exception]` - emitted when an exception has been raised.
      * Measurement: `%{duration: native_time}`
      * Metadata: `%{env: Tesla.Env.t(), kind: Exception.kind(), reason: term(), stacktrace: Exception.stacktrace()}`

    ## Legacy Telemetry Events

      * `[:tesla, :request]` - This event is emitted for backwards compatibility only and should be considered deprecated.
          This event can be disabled by setting `config :tesla, Tesla.Middleware.Telemetry, disable_legacy_event: true` in your config.
          Be sure to run `mix deps.compile --force tesla` after changing this setting to ensure the change is picked up.

    Please check the [telemetry](https://hexdocs.pm/telemetry/) for the further usage.

    ## URL event scoping with `Tesla.Middleware.PathParams` and `Tesla.Middleware.KeepRequest`

    Sometimes, it is useful to have access to a template url (i.e. `"/users/:user_id"`) for grouping
    Telemetry events. For such cases, a combination of the `Tesla.Middleware.PathParams`,
    `Tesla.Middleware.Telemetry` and `Tesla.Middleware.KeepRequest` may be used.

    ```
    defmodule MyClient do
      use Tesla

      # The KeepRequest middleware sets the template url as a Tesla.Env.opts entry
      # Said entry must be used because on happy-path scenarios,
      # the Telemetry middleware will receive the Tesla.Env.url resolved by PathParams.
      plug Tesla.Middleware.KeepRequest
      plug Tesla.Middleware.Telemetry
      plug Tesla.Middleware.PathParams
    end

    :telemetry.attach(
      "my-tesla-telemetry",
      [:tesla, :request, :stop],
      fn event, measurements, meta, config ->
        path_params_template_url = meta.env.opts[:req_url]
        # The meta.env.url key will only present the resolved URL on happy-path scenarios.
        # Error cases will still return the original template url.
        path_params_resolved_url = meta.env.url
      end,
      nil
    )
    ```
    """

    @disable_legacy_event Application.compile_env(
                            :tesla,
                            [Tesla.Middleware.Telemetry, :disable_legacy_event],
                            false
                          )

    @behaviour Tesla.Middleware

    @impl Tesla.Middleware
    def call(env, next, opts) do
      metadata = opts[:metadata] || %{}
      start_time = System.monotonic_time()

      emit_start(Map.merge(metadata, %{env: env}))

      try do
        Tesla.run(env, next)
      catch
        kind, reason ->
          stacktrace = __STACKTRACE__
          duration = System.monotonic_time() - start_time

          emit_exception(
            duration,
            Map.merge(metadata, %{env: env, kind: kind, reason: reason, stacktrace: stacktrace})
          )

          :erlang.raise(kind, reason, stacktrace)
      else
        {:ok, env} = result ->
          duration = System.monotonic_time() - start_time

          emit_stop(duration, Map.merge(metadata, %{env: env}))
          emit_legacy_event(duration, result)

          result

        {:error, reason} = result ->
          duration = System.monotonic_time() - start_time

          emit_stop(duration, Map.merge(metadata, %{env: env, error: reason}))
          emit_legacy_event(duration, result)

          result
      end
    end

    defp emit_start(metadata) do
      :telemetry.execute(
        [:tesla, :request, :start],
        %{system_time: System.system_time()},
        metadata
      )
    end

    defp emit_stop(duration, metadata) do
      :telemetry.execute(
        [:tesla, :request, :stop],
        %{duration: duration},
        metadata
      )
    end

    if @disable_legacy_event do
      defp emit_legacy_event(_duration, _result) do
        :ok
      end
    else
      defp emit_legacy_event(duration, result) do
        duration = System.convert_time_unit(duration, :native, :microsecond)

        :telemetry.execute(
          [:tesla, :request],
          %{request_time: duration},
          %{result: result}
        )
      end
    end

    defp emit_exception(duration, metadata) do
      :telemetry.execute(
        [:tesla, :request, :exception],
        %{duration: duration},
        metadata
      )
    end
  end
end