lib/huggingface_client/inference/telemetry.ex

defmodule HuggingfaceClient.Inference.Telemetry do
  @moduledoc """
  Telemetry integration for `HuggingfaceClient`.

  ## Events emitted

  All events share the prefix `[:huggingface_client, :request]`.

  | Event | When |
  |-------|------|
  | `[:huggingface_client, :request, :start]` | Before the HTTP call |
  | `[:huggingface_client, :request, :stop]`  | After a successful HTTP call |
  | `[:huggingface_client, :request, :exception]` | If the call raises |

  ### Measurements

  - `:duration` — wall-clock time in native units (`:stop` and `:exception` only)
  - `:monotonic_time` — start time (`:start` only)

  ### Metadata

  | Key | Description |
  |-----|-------------|
  | `:provider` | Provider string, e.g. `"groq"` |
  | `:task` | Task string, e.g. `"conversational"` |
  | `:model` | Model ID or `nil` |
  | `:status` | HTTP status code (`:stop` only) |
  | `:error` | Exception struct (`:exception` only) |

  ## Attaching a handler

      HuggingfaceClient.Inference.Telemetry.attach_default_logger()

  This logs a single line per request at `:info` level using `Logger`.

  ## Custom handler example

      :telemetry.attach(
        "my-hf-metrics",
        [:huggingface_client, :request, :stop],
        fn _event, %{duration: duration}, %{provider: provider, task: task}, _ ->
          MyMetrics.histogram("hf_inference.request_duration",
            duration,
            tags: [provider: provider, task: task]
          )
        end,
        nil
      )
  """

  require Logger

  @start_event [:huggingface_client, :request, :start]
  @stop_event [:huggingface_client, :request, :stop]
  @exception_event [:huggingface_client, :request, :exception]

  @all_events [@start_event, @stop_event, @exception_event]

  @doc """
  Attaches a default structured logger handler.

  Logs `:info` on success, `:warning` on exceptions.
  Safe to call multiple times — detaches the old handler first.
  """
  @spec attach_default_logger(atom()) :: :ok
  def attach_default_logger(handler_id \\ :huggingface_client_default_logger) do
    :telemetry.detach(handler_id)

    :telemetry.attach_many(
      handler_id,
      @all_events,
      &__MODULE__.handle_event/4,
      %{log_level: :info}
    )

    :ok
  end

  @doc "Detaches the default logger."
  @spec detach_default_logger(atom()) :: :ok | {:error, :not_found}
  def detach_default_logger(handler_id \\ :huggingface_client_default_logger) do
    :telemetry.detach(handler_id)
  end

  @doc "Returns all telemetry events emitted by this library."
  @spec events() :: [[atom()]]
  def events, do: @all_events

  @doc false
  @spec handle_event([atom()], map(), map(), map()) :: :ok
  def handle_event(@stop_event, %{duration: duration}, meta, %{log_level: level}) do
    ms = System.convert_time_unit(duration, :native, :millisecond)

    Logger.log(
      level,
      "[HuggingfaceClient] #{meta.provider}/#{meta.task} #{meta.model} #{ms}ms status=#{meta[:status] || "ok"}"
    )
  end

  def handle_event(@exception_event, %{duration: duration}, %{error: error} = meta, _) do
    ms = System.convert_time_unit(duration, :native, :millisecond)

    Logger.warning(
      "[HuggingfaceClient] #{meta.provider}/#{meta.task} #{meta.model} #{ms}ms error=#{inspect(error)}"
    )
  end

  def handle_event(@start_event, _, _, _), do: :ok
end