defmodule DaProductApp.Telemetry.BusinessTelemetry do @moduledoc """ Telemetry emitters for payment and external-provider (Alipay/AANI) events. Business code calls these functions at the point an operation starts, completes, or (for external HTTP calls) times out. `DaProductApp.Telemetry.MetricsCollector` attaches to the underlying `:telemetry` events and turns them into Prometheus metrics served at `GET /monitoring/metrics`. ## Semantic note on purchase "success" For Alipay/AANI purchases, `emit_payment_completed(..., :success, "purchase", ...)` fires as soon as QR/provider *initiation* succeeds (the QR code was generated) - it does NOT mean the customer has actually completed or settled the payment. Do not treat `payment_amount_success_total` as confirmed revenue - it measures amounts for which QR/provider initiation succeeded. Real payment completion is tracked separately via `emit_payment_finalized/3` (`payment_finalized_total`), emitted only from the authoritative Alipay webhook (`AlipayWebhookController`) at the exact point a transaction genuinely transitions from `pending` to a final status. The Alipay async inquiry poll and the AANI notification flow do not emit this event today (the poll has no duplicate-safe guard against racing the webhook; AANI's flow never updates transaction status at all). """ require Logger # Fixed, low-cardinality failure reasons for payment_failure_reason_total. # Never expose raw error terms, messages, or IDs as label values. @failure_reasons ~w(timeout invalid_qr provider_error declined database_error internal_error unknown) @doc "Emit when a payment/QR provider flow (purchase/cancel/refund) begins." @spec emit_payment_started(String.t(), String.t()) :: :ok def emit_payment_started(provider, transaction_type) when transaction_type in ["purchase", "cancel", "refund", "unknown"] do :telemetry.execute( [:da_product_app, :payment, :started], %{count: 1}, %{provider: to_string(provider), transaction_type: transaction_type} ) end @doc """ Emit when a payment/QR provider flow has completed (success or failure). `opts` may include: * `:amount` - major-currency-unit amount (float/number), purchase/refund only * `:currency` - ISO currency code matching `:amount` * `:failure_reason` - one of #{inspect(@failure_reasons)}, required when `result: :failure` and amount/failure-reason tracking is desired; anything else is normalized to "unknown" Amount and failure-reason are carried on this same event (rather than separate telemetry events) so a single payment outcome can never emit an inconsistent set of counters. """ @spec emit_payment_completed( String.t(), non_neg_integer(), :success | :failure, String.t(), keyword() ) :: :ok def emit_payment_completed(provider, duration_ms, result, transaction_type, opts \\ []) when result in [:success, :failure] and transaction_type in ["purchase", "cancel", "refund", "unknown"] do base_metadata = %{ provider: to_string(provider), result: result, transaction_type: transaction_type } metadata = base_metadata |> maybe_put_amount(opts) |> maybe_put_failure_reason(result, opts) :telemetry.execute( [:da_product_app, :payment, :completed], %{duration_ms: duration_ms}, metadata ) end @doc """ Emit exactly once when a transaction genuinely, verifiably transitions from `pending` to a final status (`"success"` or `"failed"`) - not on QR/provider initiation (see `emit_payment_completed/5`), and not on duplicate/repeated webhook deliveries for an already-finalized transaction. Callers must only invoke this after their own guarded database update confirms exactly one row made that transition (e.g. a `WHERE status = "pending"` guarded update that affected exactly one row) - this function performs no such check itself. """ @spec emit_payment_finalized(String.t(), String.t(), String.t()) :: :ok def emit_payment_finalized(provider, result, transaction_type) when result in ["success", "failed"] do :telemetry.execute( [:da_product_app, :payment, :finalized], %{count: 1}, %{provider: to_string(provider), result: result, transaction_type: transaction_type} ) end defp maybe_put_amount(metadata, opts) do case {Keyword.get(opts, :amount), Keyword.get(opts, :currency)} do {amount, currency} when is_number(amount) and is_binary(currency) -> Map.merge(metadata, %{amount: amount / 1, currency: currency}) _ -> metadata end end defp maybe_put_failure_reason(metadata, :failure, opts) do Map.put( metadata, :failure_reason, classify_failure_reason(Keyword.get(opts, :failure_reason)) ) end defp maybe_put_failure_reason(metadata, :success, _opts), do: metadata @doc """ Normalize an internal failure reason (atom, exception, or provider error shape) into one of the fixed, low-cardinality reasons exposed as a Prometheus label: #{inspect(@failure_reasons)}. `declined` cannot be reliably classified from current provider response handling (Alipay/AANI failure result codes are not yet mapped to a decline-specific list) - such cases currently fall under `provider_error` or `unknown`. """ @spec classify_failure_reason(term()) :: String.t() def classify_failure_reason(reason) when reason in [:timeout, :connect_timeout], do: "timeout" def classify_failure_reason(:invalid_qr), do: "invalid_qr" def classify_failure_reason(:provider_error), do: "provider_error" def classify_failure_reason(:declined), do: "declined" def classify_failure_reason(:database_error), do: "database_error" def classify_failure_reason(:internal_error), do: "internal_error" def classify_failure_reason(:unsupported_provider), do: "internal_error" def classify_failure_reason(%{status_code: _}), do: "provider_error" def classify_failure_reason(reason) when reason in @failure_reasons, do: reason def classify_failure_reason(_reason), do: "unknown" @doc "Emit when a call to an external provider API (Alipay/AANI) has completed." @spec emit_external_api_call(String.t(), String.t(), non_neg_integer(), :success | :failure) :: :ok def emit_external_api_call(provider, operation, duration_ms, result) when result in [:success, :failure] do :telemetry.execute( [:da_product_app, :external_api, :call], %{duration_ms: duration_ms}, %{provider: to_string(provider), operation: to_string(operation), result: result} ) end @doc """ Emit when a call to an external provider API (Alipay/AANI) times out. Superseded name for the metric formerly emitted as `payment_timeout_total` - it is emitted exclusively from HTTP-layer code (`instrumented_post/4` in the Alipay/AANI provider modules) and is not scoped to payment outcomes, so it is named/exposed as `external_api_timeout_total`. There is no `payment_timeout_total` anymore; do not emit both. """ @spec emit_external_api_timeout(String.t(), String.t()) :: :ok def emit_external_api_timeout(provider, operation) do :telemetry.execute( [:da_product_app, :external_api, :timeout], %{count: 1}, %{provider: to_string(provider), operation: to_string(operation)} ) end # Fixed, low-cardinality HTTP-layer error reasons for # external_api_error_reason_total. Distinct from @failure_reasons above - # this enum is scoped to what a raw HTTP call can actually tell us, not # business-level outcomes (declined/invalid_qr/database_error do not apply # at this layer). @external_api_error_reasons ~w(timeout provider_error connection_error unknown) @doc """ Emit exactly once per failed external provider API call, alongside the existing `emit_external_api_call/4` failure update - never as a replacement for it. """ @spec emit_external_api_error_reason(String.t(), String.t(), String.t()) :: :ok def emit_external_api_error_reason(provider, operation, reason) when reason in @external_api_error_reasons do :telemetry.execute( [:da_product_app, :external_api, :error], %{count: 1}, %{provider: to_string(provider), operation: to_string(operation), reason: reason} ) end @doc """ Classify a raw `HTTPoison.post/3` result into one of the fixed `external_api_error_reason_total` reasons: #{inspect(@external_api_error_reasons)}. Never derive this label from raw error terms, response bodies, or URLs. """ @spec classify_external_api_error(term()) :: String.t() def classify_external_api_error({:error, %HTTPoison.Error{reason: reason}}) when reason in [:timeout, :connect_timeout], do: "timeout" def classify_external_api_error({:ok, %HTTPoison.Response{}}), do: "provider_error" def classify_external_api_error({:error, %HTTPoison.Error{}}), do: "connection_error" def classify_external_api_error(_result), do: "unknown" @doc "Monotonic start reference for timing a business operation." @spec start_timer() :: integer() def start_timer, do: System.monotonic_time() @doc "Elapsed milliseconds since a `start_timer/0` reference." @spec elapsed_ms(integer()) :: non_neg_integer() def elapsed_ms(start_ref) do System.convert_time_unit(System.monotonic_time() - start_ref, :native, :millisecond) end end