feat(analytics): FRM Analytics (#4880)

Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com>
Co-authored-by: Abhitator216 <abhishek.kanojia@juspay.in>
Co-authored-by: Abhishek Kanojia <89402434+Abhitator216@users.noreply.github.com>
Co-authored-by: ivor-juspay <138492857+ivor-juspay@users.noreply.github.com>
Co-authored-by: Sampras Lopes <sampras.lopes@juspay.in>
This commit is contained in:
Sandeep Kumar
2024-07-04 12:22:27 +05:30
committed by GitHub
parent 7a1651d26b
commit cc88c0707f
36 changed files with 1629 additions and 78 deletions

View File

@ -14,9 +14,10 @@ pub mod routes {
},
GenerateReportRequest, GetActivePaymentsMetricRequest, GetApiEventFiltersRequest,
GetApiEventMetricRequest, GetAuthEventMetricRequest, GetDisputeMetricRequest,
GetPaymentFiltersRequest, GetPaymentIntentFiltersRequest, GetPaymentIntentMetricRequest,
GetPaymentMetricRequest, GetRefundFilterRequest, GetRefundMetricRequest,
GetSdkEventFiltersRequest, GetSdkEventMetricRequest, ReportRequest,
GetFrmFilterRequest, GetFrmMetricRequest, GetPaymentFiltersRequest,
GetPaymentIntentFiltersRequest, GetPaymentIntentMetricRequest, GetPaymentMetricRequest,
GetRefundFilterRequest, GetRefundMetricRequest, GetSdkEventFiltersRequest,
GetSdkEventMetricRequest, ReportRequest,
};
use error_stack::ResultExt;
@ -54,6 +55,9 @@ pub mod routes {
web::resource("filters/payments")
.route(web::post().to(get_payment_filters)),
)
.service(
web::resource("filters/frm").route(web::post().to(get_frm_filters)),
)
.service(
web::resource("filters/refunds")
.route(web::post().to(get_refund_filters)),
@ -87,6 +91,9 @@ pub mod routes {
web::resource("metrics/auth_events")
.route(web::post().to(get_auth_event_metrics)),
)
.service(
web::resource("metrics/frm").route(web::post().to(get_frm_metrics)),
)
.service(
web::resource("api_event_logs").route(web::get().to(get_api_events)),
)
@ -270,6 +277,38 @@ pub mod routes {
.await
}
/// # Panics
///
/// Panics if `json_payload` array does not contain one `GetFrmMetricRequest` element.
pub async fn get_frm_metrics(
state: web::Data<AppState>,
req: actix_web::HttpRequest,
json_payload: web::Json<[GetFrmMetricRequest; 1]>,
) -> impl Responder {
#[allow(clippy::expect_used)]
// safety: This shouldn't panic owing to the data type
let payload = json_payload
.into_inner()
.to_vec()
.pop()
.expect("Couldn't get GetFrmMetricRequest");
let flow = AnalyticsFlow::GetFrmMetrics;
Box::pin(api::server_wrap(
flow,
state,
&req,
payload,
|state, auth: AuthenticationData, req, _| async move {
analytics::frm::get_metrics(&state.pool, &auth.merchant_account.merchant_id, req)
.await
.map(ApplicationResponse::Json)
},
&auth::JWTAuth(Permission::Analytics),
api_locking::LockAction::NotApplicable,
))
.await
}
/// # Panics
///
/// Panics if `json_payload` array does not contain one `GetSdkEventMetricRequest` element.
@ -458,6 +497,28 @@ pub mod routes {
.await
}
pub async fn get_frm_filters(
state: web::Data<AppState>,
req: actix_web::HttpRequest,
json_payload: web::Json<GetFrmFilterRequest>,
) -> impl Responder {
let flow = AnalyticsFlow::GetFrmFilters;
Box::pin(api::server_wrap(
flow,
state,
&req,
json_payload.into_inner(),
|state, auth: AuthenticationData, req: GetFrmFilterRequest, _| async move {
analytics::frm::get_filters(&state.pool, req, &auth.merchant_account.merchant_id)
.await
.map(ApplicationResponse::Json)
},
&auth::JWTAuth(Permission::Analytics),
api_locking::LockAction::NotApplicable,
))
.await
}
pub async fn get_sdk_event_filters(
state: web::Data<AppState>,
req: actix_web::HttpRequest,

View File

@ -36,9 +36,8 @@ pub mod user;
pub mod user_authentication_method;
pub mod user_key_store;
pub mod user_role;
use diesel_models::{
fraud_check::{FraudCheck, FraudCheckNew, FraudCheckUpdate},
fraud_check::{FraudCheck, FraudCheckUpdate},
organization::{Organization, OrganizationNew, OrganizationUpdate},
};
use error_stack::ResultExt;
@ -53,16 +52,23 @@ use hyperswitch_domain_models::payouts::{
use hyperswitch_domain_models::{PayoutAttemptInterface, PayoutsInterface};
use masking::PeekInterface;
use redis_interface::errors::RedisError;
use router_env::logger;
use storage_impl::{errors::StorageError, redis::kv_store::RedisConnInterface, MockDb};
pub use self::kafka_store::KafkaStore;
use self::{fraud_check::FraudCheckInterface, organization::OrganizationInterface};
pub use crate::{
core::errors::{self, ProcessTrackerError},
errors::CustomResult,
services::{
kafka::{KafkaError, KafkaProducer, MQResult},
Store,
},
types::{
domain,
storage::{self},
AccessToken,
},
};
#[derive(PartialEq, Eq)]
@ -259,36 +265,74 @@ impl RequestIdStore for KafkaStore {
impl FraudCheckInterface for KafkaStore {
async fn insert_fraud_check_response(
&self,
new: FraudCheckNew,
new: storage::FraudCheckNew,
) -> CustomResult<FraudCheck, StorageError> {
self.diesel_store.insert_fraud_check_response(new).await
let frm = self.diesel_store.insert_fraud_check_response(new).await?;
if let Err(er) = self
.kafka_producer
.log_fraud_check(&frm, None, self.tenant_id.clone())
.await
{
logger::error!(message = "Failed to log analytics event for fraud check", error_message = ?er);
}
Ok(frm)
}
async fn update_fraud_check_response_with_attempt_id(
&self,
fraud_check: FraudCheck,
fraud_check_update: FraudCheckUpdate,
this: FraudCheck,
fraud_check: FraudCheckUpdate,
) -> CustomResult<FraudCheck, StorageError> {
self.diesel_store
.update_fraud_check_response_with_attempt_id(fraud_check, fraud_check_update)
let frm = self
.diesel_store
.update_fraud_check_response_with_attempt_id(this, fraud_check)
.await?;
if let Err(er) = self
.kafka_producer
.log_fraud_check(&frm, None, self.tenant_id.clone())
.await
{
logger::error!(message="Failed to log analytics event for fraud check {frm:?}", error_message=?er)
}
Ok(frm)
}
async fn find_fraud_check_by_payment_id(
&self,
payment_id: String,
merchant_id: String,
) -> CustomResult<FraudCheck, StorageError> {
self.diesel_store
let frm = self
.diesel_store
.find_fraud_check_by_payment_id(payment_id, merchant_id)
.await?;
if let Err(er) = self
.kafka_producer
.log_fraud_check(&frm, None, self.tenant_id.clone())
.await
{
logger::error!(message="Failed to log analytics event for fraud check {frm:?}", error_message=?er)
}
Ok(frm)
}
async fn find_fraud_check_by_payment_id_if_present(
&self,
payment_id: String,
merchant_id: String,
) -> CustomResult<Option<FraudCheck>, StorageError> {
self.diesel_store
let frm = self
.diesel_store
.find_fraud_check_by_payment_id_if_present(payment_id, merchant_id)
.await
.await?;
if let Some(fraud_check) = frm.clone() {
if let Err(er) = self
.kafka_producer
.log_fraud_check(&fraud_check, None, self.tenant_id.clone())
.await
{
logger::error!(message="Failed to log analytics event for frm {frm:?}", error_message=?er);
}
}
Ok(frm)
}
}

View File

@ -116,33 +116,3 @@ impl FraudCheckInterface for MockDb {
Err(errors::StorageError::MockDbError)?
}
}
#[cfg(feature = "kafka_events")]
#[async_trait::async_trait]
impl FraudCheckInterface for super::KafkaStore {
#[instrument(skip_all)]
async fn insert_fraud_check_response(
&self,
_new: storage::FraudCheckNew,
) -> CustomResult<FraudCheck, errors::StorageError> {
Err(errors::StorageError::MockDbError)?
}
#[instrument(skip_all)]
async fn update_fraud_check_response_with_attempt_id(
&self,
_this: FraudCheck,
_fraud_check: FraudCheckUpdate,
) -> CustomResult<FraudCheck, errors::StorageError> {
Err(errors::StorageError::MockDbError)?
}
#[instrument(skip_all)]
async fn find_fraud_check_by_payment_id(
&self,
_payment_id: String,
_merchant_id: String,
) -> CustomResult<FraudCheck, errors::StorageError> {
Err(errors::StorageError::MockDbError)?
}
}

View File

@ -83,7 +83,7 @@ pub struct TenantID(pub String);
#[derive(Clone)]
pub struct KafkaStore {
kafka_producer: KafkaProducer,
pub kafka_producer: KafkaProducer,
pub diesel_store: Store,
pub tenant_id: TenantID,
}

View File

@ -23,6 +23,7 @@ pub mod outgoing_webhook_logs;
#[serde(rename_all = "snake_case")]
pub enum EventType {
PaymentIntent,
FraudCheck,
PaymentAttempt,
Refund,
ApiLogs,

View File

@ -11,11 +11,15 @@ use rdkafka::{
};
#[cfg(feature = "payouts")]
pub mod payout;
use crate::events::EventType;
use diesel_models::fraud_check::FraudCheck;
use crate::{events::EventType, services::kafka::fraud_check_event::KafkaFraudCheckEvent};
mod authentication;
mod authentication_event;
mod dispute;
mod dispute_event;
mod fraud_check;
mod fraud_check_event;
mod payment_attempt;
mod payment_attempt_event;
mod payment_intent;
@ -36,7 +40,7 @@ use self::{
payment_intent_event::KafkaPaymentIntentEvent, refund::KafkaRefund,
refund_event::KafkaRefundEvent,
};
use crate::types::storage::Dispute;
use crate::{services::kafka::fraud_check::KafkaFraudCheck, types::storage::Dispute};
// Using message queue result here to avoid confusion with Kafka result provided by library
pub type MQResult<T> = CustomResult<T, KafkaError>;
@ -139,6 +143,7 @@ impl<'a, T: KafkaMessage> KafkaMessage for KafkaConsolidatedEvent<'a, T> {
#[serde(default)]
pub struct KafkaSettings {
brokers: Vec<String>,
fraud_check_analytics_topic: String,
intent_analytics_topic: String,
attempt_analytics_topic: String,
refund_analytics_topic: String,
@ -246,6 +251,7 @@ impl KafkaSettings {
pub struct KafkaProducer {
producer: Arc<RdKafkaProducer>,
intent_analytics_topic: String,
fraud_check_analytics_topic: String,
attempt_analytics_topic: String,
refund_analytics_topic: String,
api_logs_topic: String,
@ -288,6 +294,7 @@ impl KafkaProducer {
.change_context(KafkaError::InitializationError)?,
)),
fraud_check_analytics_topic: conf.fraud_check_analytics_topic.clone(),
intent_analytics_topic: conf.intent_analytics_topic.clone(),
attempt_analytics_topic: conf.attempt_analytics_topic.clone(),
refund_analytics_topic: conf.refund_analytics_topic.clone(),
@ -321,6 +328,38 @@ impl KafkaProducer {
.map_err(|(error, record)| report!(error).attach_printable(format!("{record:?}")))
.change_context(KafkaError::GenericError)
}
pub async fn log_fraud_check(
&self,
attempt: &FraudCheck,
old_attempt: Option<FraudCheck>,
tenant_id: TenantID,
) -> MQResult<()> {
if let Some(negative_event) = old_attempt {
self.log_event(&KafkaEvent::old(
&KafkaFraudCheck::from_storage(&negative_event),
tenant_id.clone(),
))
.attach_printable_lazy(|| {
format!("Failed to add negative fraud check event {negative_event:?}")
})?;
};
self.log_event(&KafkaEvent::new(
&KafkaFraudCheck::from_storage(attempt),
tenant_id.clone(),
))
.attach_printable_lazy(|| {
format!("Failed to add positive fraud check event {attempt:?}")
})?;
self.log_event(&KafkaConsolidatedEvent::new(
&KafkaFraudCheckEvent::from_storage(attempt),
tenant_id.clone(),
))
.attach_printable_lazy(|| {
format!("Failed to add consolidated fraud check event {attempt:?}")
})
}
pub async fn log_payment_attempt(
&self,
@ -544,6 +583,7 @@ impl KafkaProducer {
pub fn get_topic(&self, event: EventType) -> &str {
match event {
EventType::FraudCheck => &self.fraud_check_analytics_topic,
EventType::ApiLogs => &self.api_logs_topic,
EventType::PaymentAttempt => &self.attempt_analytics_topic,
EventType::PaymentIntent => &self.intent_analytics_topic,

View File

@ -0,0 +1,67 @@
// use diesel_models::enums as storage_enums;
use diesel_models::{
enums as storage_enums,
enums::{FraudCheckLastStep, FraudCheckStatus, FraudCheckType},
fraud_check::FraudCheck,
};
use time::OffsetDateTime;
#[derive(serde::Serialize, Debug)]
pub struct KafkaFraudCheck<'a> {
pub frm_id: &'a String,
pub payment_id: &'a String,
pub merchant_id: &'a String,
pub attempt_id: &'a String,
#[serde(with = "time::serde::timestamp")]
pub created_at: OffsetDateTime,
pub frm_name: &'a String,
pub frm_transaction_id: Option<&'a String>,
pub frm_transaction_type: FraudCheckType,
pub frm_status: FraudCheckStatus,
pub frm_score: Option<i32>,
pub frm_reason: Option<serde_json::Value>,
pub frm_error: Option<&'a String>,
pub payment_details: Option<serde_json::Value>,
pub metadata: Option<serde_json::Value>,
#[serde(with = "time::serde::timestamp")]
pub modified_at: OffsetDateTime,
pub last_step: FraudCheckLastStep,
pub payment_capture_method: Option<storage_enums::CaptureMethod>, // In postFrm, we are updating capture method from automatic to manual. To store the merchant actual capture method, we are storing the actual capture method in payment_capture_method. It will be useful while approving the FRM decision.
}
impl<'a> KafkaFraudCheck<'a> {
pub fn from_storage(check: &'a FraudCheck) -> Self {
Self {
frm_id: &check.frm_id,
payment_id: &check.payment_id,
merchant_id: &check.merchant_id,
attempt_id: &check.attempt_id,
created_at: check.created_at.assume_utc(),
frm_name: &check.frm_name,
frm_transaction_id: check.frm_transaction_id.as_ref(),
frm_transaction_type: check.frm_transaction_type,
frm_status: check.frm_status,
frm_score: check.frm_score,
frm_reason: check.frm_reason.clone(),
frm_error: check.frm_error.as_ref(),
payment_details: check.payment_details.clone(),
metadata: check.metadata.clone(),
modified_at: check.modified_at.assume_utc(),
last_step: check.last_step,
payment_capture_method: check.payment_capture_method,
}
}
}
impl<'a> super::KafkaMessage for KafkaFraudCheck<'a> {
fn key(&self) -> String {
format!(
"{}_{}_{}_{}",
self.merchant_id, self.payment_id, self.attempt_id, self.frm_id
)
}
fn event_type(&self) -> crate::events::EventType {
crate::events::EventType::FraudCheck
}
}

View File

@ -0,0 +1,66 @@
use diesel_models::{
enums as storage_enums,
enums::{FraudCheckLastStep, FraudCheckStatus, FraudCheckType},
fraud_check::FraudCheck,
};
use time::OffsetDateTime;
#[derive(serde::Serialize, Debug)]
pub struct KafkaFraudCheckEvent<'a> {
pub frm_id: &'a String,
pub payment_id: &'a String,
pub merchant_id: &'a String,
pub attempt_id: &'a String,
#[serde(default, with = "time::serde::timestamp::milliseconds")]
pub created_at: OffsetDateTime,
pub frm_name: &'a String,
pub frm_transaction_id: Option<&'a String>,
pub frm_transaction_type: FraudCheckType,
pub frm_status: FraudCheckStatus,
pub frm_score: Option<i32>,
pub frm_reason: Option<serde_json::Value>,
pub frm_error: Option<&'a String>,
pub payment_details: Option<serde_json::Value>,
pub metadata: Option<serde_json::Value>,
#[serde(default, with = "time::serde::timestamp::milliseconds")]
pub modified_at: OffsetDateTime,
pub last_step: FraudCheckLastStep,
pub payment_capture_method: Option<storage_enums::CaptureMethod>, // In postFrm, we are updating capture method from automatic to manual. To store the merchant actual capture method, we are storing the actual capture method in payment_capture_method. It will be useful while approving the FRM decision.
}
impl<'a> KafkaFraudCheckEvent<'a> {
pub fn from_storage(check: &'a FraudCheck) -> Self {
Self {
frm_id: &check.frm_id,
payment_id: &check.payment_id,
merchant_id: &check.merchant_id,
attempt_id: &check.attempt_id,
created_at: check.created_at.assume_utc(),
frm_name: &check.frm_name,
frm_transaction_id: check.frm_transaction_id.as_ref(),
frm_transaction_type: check.frm_transaction_type,
frm_status: check.frm_status,
frm_score: check.frm_score,
frm_reason: check.frm_reason.clone(),
frm_error: check.frm_error.as_ref(),
payment_details: check.payment_details.clone(),
metadata: check.metadata.clone(),
modified_at: check.modified_at.assume_utc(),
last_step: check.last_step,
payment_capture_method: check.payment_capture_method,
}
}
}
impl<'a> super::KafkaMessage for KafkaFraudCheckEvent<'a> {
fn key(&self) -> String {
format!(
"{}_{}_{}_{}",
self.merchant_id, self.payment_id, self.attempt_id, self.frm_id
)
}
fn event_type(&self) -> crate::events::EventType {
crate::events::EventType::FraudCheck
}
}