package gateway import ( "errors" "strings" "sync" "time" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" "github.com/shopspring/decimal" "github.com/tech/sendico/pkg/merrors" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" ) var ( metricsOnce sync.Once rpcLatency *prometheus.HistogramVec rpcStatus *prometheus.CounterVec payoutCounter *prometheus.CounterVec payoutAmountTotal *prometheus.CounterVec payoutErrorCount *prometheus.CounterVec payoutMissedAmounts *prometheus.CounterVec ) func initMetrics() { metricsOnce.Do(func() { rpcLatency = promauto.NewHistogramVec(prometheus.HistogramOpts{ Namespace: "sendico", Subsystem: "mntx_gateway", Name: "rpc_latency_seconds", Help: "Latency distribution for Monetix gateway RPC handlers.", Buckets: prometheus.DefBuckets, }, []string{"method"}) rpcStatus = promauto.NewCounterVec(prometheus.CounterOpts{ Namespace: "sendico", Subsystem: "mntx_gateway", Name: "rpc_requests_total", Help: "Total number of RPC invocations grouped by method and status.", }, []string{"method", "status"}) payoutCounter = promauto.NewCounterVec(prometheus.CounterOpts{ Namespace: "sendico", Subsystem: "mntx_gateway", Name: "payouts_total", Help: "Total payouts processed grouped by outcome.", }, []string{"status"}) payoutAmountTotal = promauto.NewCounterVec(prometheus.CounterOpts{ Namespace: "sendico", Subsystem: "mntx_gateway", Name: "payout_amount_total", Help: "Total payout amount grouped by outcome and currency.", }, []string{"status", "currency"}) payoutErrorCount = promauto.NewCounterVec(prometheus.CounterOpts{ Namespace: "sendico", Subsystem: "mntx_gateway", Name: "payout_errors_total", Help: "Payout failures grouped by reason.", }, []string{"reason"}) payoutMissedAmounts = promauto.NewCounterVec(prometheus.CounterOpts{ Namespace: "sendico", Subsystem: "mntx_gateway", Name: "payout_missed_amount_total", Help: "Total payout volume that failed grouped by reason and currency.", }, []string{"reason", "currency"}) }) } func observeRPC(method string, err error, duration time.Duration) { if rpcLatency != nil { rpcLatency.WithLabelValues(method).Observe(duration.Seconds()) } if rpcStatus != nil { rpcStatus.WithLabelValues(method, statusLabel(err)).Inc() } } func observePayoutSuccess(amount *moneyv1.Money) { if payoutCounter != nil { payoutCounter.WithLabelValues("processed").Inc() } value, currency := monetaryValue(amount) if value > 0 && payoutAmountTotal != nil { payoutAmountTotal.WithLabelValues("processed", currency).Add(value) } } func observePayoutError(reason string, amount *moneyv1.Money) { reason = reasonLabel(reason) if payoutCounter != nil { payoutCounter.WithLabelValues("failed").Inc() } if payoutErrorCount != nil { payoutErrorCount.WithLabelValues(reason).Inc() } value, currency := monetaryValue(amount) if value <= 0 { return } if payoutAmountTotal != nil { payoutAmountTotal.WithLabelValues("failed", currency).Add(value) } if payoutMissedAmounts != nil { payoutMissedAmounts.WithLabelValues(reason, currency).Add(value) } } func monetaryValue(amount *moneyv1.Money) (float64, string) { if amount == nil { return 0, "unknown" } val := strings.TrimSpace(amount.Amount) if val == "" { return 0, currencyLabel(amount.Currency) } dec, err := decimal.NewFromString(val) if err != nil { return 0, currencyLabel(amount.Currency) } f, _ := dec.Float64() if f < 0 { return 0, currencyLabel(amount.Currency) } return f, currencyLabel(amount.Currency) } func currencyLabel(code string) string { code = strings.ToUpper(strings.TrimSpace(code)) if code == "" { return "unknown" } return code } func reasonLabel(reason string) string { reason = strings.TrimSpace(reason) if reason == "" { return "unknown" } return strings.ToLower(reason) } func statusLabel(err error) string { switch { case err == nil: return "ok" case errors.Is(err, merrors.ErrInvalidArg): return "invalid_argument" case errors.Is(err, merrors.ErrNoData): return "not_found" case errors.Is(err, merrors.ErrDataConflict): return "conflict" case errors.Is(err, merrors.ErrAccessDenied): return "denied" case errors.Is(err, merrors.ErrInternal): return "internal" default: return "error" } } func normalizeCallbackStatus(status string) string { status = strings.TrimSpace(status) if status == "" { return "unknown" } return strings.ToLower(status) }