diff --git a/apps/web/app/(ee)/api/appsflyer/webhook/route.ts b/apps/web/app/(ee)/api/appsflyer/webhook/route.ts index 6da239323ad..3b9308ce714 100644 --- a/apps/web/app/(ee)/api/appsflyer/webhook/route.ts +++ b/apps/web/app/(ee)/api/appsflyer/webhook/route.ts @@ -1,3 +1,4 @@ +import { captureWebhookLog } from "@/lib/api-logs/capture-webhook-log"; import { trackLead } from "@/lib/api/conversions/track-lead"; import { trackSale } from "@/lib/api/conversions/track-sale"; import { isLocalDev } from "@/lib/api/environment"; @@ -10,7 +11,9 @@ import { isIpInRange } from "@/lib/middleware/utils/is-ip-in-range"; import { trackLeadRequestSchema } from "@/lib/zod/schemas/leads"; import { trackSaleRequestSchema } from "@/lib/zod/schemas/sales"; import { prisma } from "@dub/prisma"; +import { Project } from "@dub/prisma/client"; import { APPSFLYER_INTEGRATION_ID, getSearchParams } from "@dub/utils"; +import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; @@ -22,6 +25,14 @@ const querySchema = z.object({ // GET /api/appsflyer/webhook – listen to Postback events from AppsFlyer export const GET = withAxiom(async (req) => { + const startTime = Date.now(); + let response = "OK"; + let queryParams: Record | null = null; + let workspace: Pick< + Project, + "id" | "stripeConnectId" | "webhookEnabled" + > | null = null; + try { if (!isLocalDev) { const ip = await getIP(); @@ -37,7 +48,7 @@ export const GET = withAxiom(async (req) => { } } - const queryParams = getSearchParams(req.url); + queryParams = getSearchParams(req.url); const { appId, partnerEventId } = querySchema.parse(queryParams); @@ -72,6 +83,8 @@ export const GET = withAxiom(async (req) => { }); } + workspace = installation.project; + // Track lead event if (partnerEventId === "lead") { const { @@ -93,15 +106,15 @@ export const GET = withAxiom(async (req) => { eventQuantity: undefined, mode: undefined, metadata: null, - workspace: installation.project, + workspace, rawBody: queryParams, }); - return NextResponse.json("Lead event tracked successfully."); + response = "Lead event tracked successfully."; } // Track sale event - if (partnerEventId === "sale") { + else if (partnerEventId === "sale") { const amountInCents = appsflyerAmountToDubCents(queryParams.amount); const { eventName, customerExternalId, amount, currency, invoiceId } = trackSaleRequestSchema.parse({ @@ -118,16 +131,46 @@ export const GET = withAxiom(async (req) => { invoiceId, leadEventName: undefined, metadata: null, - workspace: installation.project, + workspace, rawBody: queryParams, }); - return NextResponse.json("Sale event tracked successfully."); + response = "Sale event tracked successfully."; } - return NextResponse.json("OK"); + waitUntil( + captureWebhookLog({ + workspaceId: workspace.id, + method: req.method, + path: "/appsflyer/webhook", + statusCode: 200, + duration: Date.now() - startTime, + requestBody: queryParams, + responseBody: response, + userAgent: req.headers.get("user-agent"), + }), + ); + + return NextResponse.json(response); } catch (error) { - return handleAndReturnErrorResponse(error); + const errorResponse = handleAndReturnErrorResponse(error); + + if (workspace) { + waitUntil( + captureWebhookLog({ + workspaceId: workspace.id, + method: req.method, + path: "/appsflyer/webhook", + statusCode: errorResponse.status, + duration: Date.now() - startTime, + requestBody: queryParams, + responseBody: errorResponse, + userAgent: req.headers.get("user-agent"), + }), + ); + } + + return errorResponse; } }); diff --git a/apps/web/app/(ee)/api/stripe/connect/webhook/account-application-deauthorized.ts b/apps/web/app/(ee)/api/stripe/connect/webhook/account-application-deauthorized.ts index 32f68262140..a0a8199619d 100644 --- a/apps/web/app/(ee)/api/stripe/connect/webhook/account-application-deauthorized.ts +++ b/apps/web/app/(ee)/api/stripe/connect/webhook/account-application-deauthorized.ts @@ -2,7 +2,9 @@ import { recomputePartnerPayoutState } from "@/lib/payouts/recompute-partner-pay import { prisma } from "@dub/prisma"; import type Stripe from "stripe"; -export async function accountApplicationDeauthorized(event: Stripe.Event) { +export async function accountApplicationDeauthorized( + event: Stripe.AccountApplicationDeauthorizedEvent, +) { const stripeAccount = event.account; if (!stripeAccount) { diff --git a/apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts b/apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts index 6e406d514d4..8048d9659a2 100644 --- a/apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts +++ b/apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts @@ -19,8 +19,8 @@ const balanceAvailableQueue = qstash.queue({ queueName: "handle-balance-available", }); -export async function accountUpdated(event: Stripe.Event) { - const account = event.data.object as Stripe.Account; +export async function accountUpdated(event: Stripe.AccountUpdatedEvent) { + const account = event.data.object; const { country, business_type } = account; const partner = await prisma.partner.findUnique({ diff --git a/apps/web/app/(ee)/api/stripe/connect/webhook/balance-available.ts b/apps/web/app/(ee)/api/stripe/connect/webhook/balance-available.ts index 3eade9c9dc1..35a69a4f1ec 100644 --- a/apps/web/app/(ee)/api/stripe/connect/webhook/balance-available.ts +++ b/apps/web/app/(ee)/api/stripe/connect/webhook/balance-available.ts @@ -6,7 +6,11 @@ const queue = qstash.queue({ queueName: "handle-balance-available", }); -export async function balanceAvailable(event: Stripe.Event) { +export async function balanceAvailable( + event: + | Stripe.AccountExternalAccountUpdatedEvent + | Stripe.BalanceAvailableEvent, +) { const stripeAccount = event.account; if (!stripeAccount) { diff --git a/apps/web/app/(ee)/api/stripe/connect/webhook/payout-failed.ts b/apps/web/app/(ee)/api/stripe/connect/webhook/payout-failed.ts index 2a058326f73..cf3ce0d6e59 100644 --- a/apps/web/app/(ee)/api/stripe/connect/webhook/payout-failed.ts +++ b/apps/web/app/(ee)/api/stripe/connect/webhook/payout-failed.ts @@ -6,14 +6,14 @@ const queue = qstash.queue({ queueName: "handle-payout-failed", }); -export async function payoutFailed(event: Stripe.Event) { +export async function payoutFailed(event: Stripe.PayoutFailedEvent) { const stripeAccount = event.account; if (!stripeAccount) { return "No stripeConnectId found in event. Skipping..."; } - const stripePayout = event.data.object as Stripe.Payout; + const stripePayout = event.data.object; const response = await queue.enqueueJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/payout-failed`, diff --git a/apps/web/app/(ee)/api/stripe/connect/webhook/payout-paid.ts b/apps/web/app/(ee)/api/stripe/connect/webhook/payout-paid.ts index 9a376f615c3..462f5221956 100644 --- a/apps/web/app/(ee)/api/stripe/connect/webhook/payout-paid.ts +++ b/apps/web/app/(ee)/api/stripe/connect/webhook/payout-paid.ts @@ -6,14 +6,14 @@ const queue = qstash.queue({ queueName: "handle-payout-paid", }); -export async function payoutPaid(event: Stripe.Event) { +export async function payoutPaid(event: Stripe.PayoutPaidEvent) { const stripeAccount = event.account; if (!stripeAccount) { return "No stripeConnectId found in event. Skipping..."; } - const stripePayout = event.data.object as Stripe.Payout; + const stripePayout = event.data.object; const stripePayoutTraceId = stripePayout.trace_id?.value ?? null; const response = await queue.enqueueJSON({ diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/account-application-deauthorized.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/account-application-deauthorized.ts index 550116531ad..334d301308d 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/account-application-deauthorized.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/account-application-deauthorized.ts @@ -5,13 +5,15 @@ import type Stripe from "stripe"; // Handle event "account.application.deauthorized" export async function accountApplicationDeauthorized( - event: Stripe.Event, + event: Stripe.AccountApplicationDeauthorizedEvent, mode: StripeMode, ) { const stripeAccountId = event.account; if (mode === "test") { - return `Stripe Connect account ${stripeAccountId} deauthorized in test mode. Skipping...`; + return { + response: `Stripe Connect account ${stripeAccountId} deauthorized in test mode. Skipping...`, + }; } const workspace = await prisma.project.findUnique({ @@ -24,7 +26,9 @@ export async function accountApplicationDeauthorized( }); if (!workspace) { - return `Stripe Connect account ${stripeAccountId} deauthorized.`; + return { + response: `Workspace not found for Stripe account ${stripeAccountId}, skipping...`, + }; } await prisma.project.update({ @@ -46,5 +50,8 @@ export async function accountApplicationDeauthorized( }, }); - return `Stripe Connect account ${stripeAccountId} deauthorized for workspace ${workspace.id}`; + return { + response: `Stripe Connect account ${stripeAccountId} deauthorized for workspace ${workspace.id}`, + workspaceId: workspace.id, + }; } diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/charge-refunded.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/charge-refunded.ts index fa1a0dcbb91..c1719e1710d 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/charge-refunded.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/charge-refunded.ts @@ -6,8 +6,11 @@ import { prisma } from "@dub/prisma"; import type Stripe from "stripe"; // Handle event "charge.refunded" -export async function chargeRefunded(event: Stripe.Event, mode: StripeMode) { - const charge = event.data.object as Stripe.Charge; +export async function chargeRefunded( + event: Stripe.ChargeRefundedEvent, + mode: StripeMode, +) { + const charge = event.data.object; const stripeAccountId = event.account as string; const stripe = stripeAppClient({ @@ -31,7 +34,9 @@ export async function chargeRefunded(event: Stripe.Event, mode: StripeMode) { invoicePayments.data.length > 0 ? invoicePayments.data[0] : null; if (!invoicePayment || !invoicePayment.invoice) { - return `Charge ${charge.id} has no invoice, skipping...`; + return { + response: `Charge ${charge.id} has no invoice, skipping...`, + }; } const workspace = await prisma.project.findUnique({ @@ -45,11 +50,18 @@ export async function chargeRefunded(event: Stripe.Event, mode: StripeMode) { }); if (!workspace) { - return `Workspace not found for stripe account ${stripeAccountId}`; + return { + response: `Workspace not found for Stripe account ${stripeAccountId}, skipping...`, + }; } + const workspaceId = workspace.id; + if (!workspace.programs.length) { - return `Workspace ${workspace.id} for stripe account ${stripeAccountId} has no programs, skipping...`; + return { + response: `Workspace ${workspaceId} for stripe account ${stripeAccountId} has no programs, skipping...`, + workspaceId, + }; } const commission = await prisma.commission.findUnique({ @@ -71,11 +83,17 @@ export async function chargeRefunded(event: Stripe.Event, mode: StripeMode) { }); if (!commission) { - return `Commission not found for invoice ${invoicePayment.invoice}`; + return { + response: `Commission not found for invoice ${invoicePayment.invoice}`, + workspaceId, + }; } if (commission.status === "paid") { - return `Commission ${commission.id} is already paid, skipping...`; + return { + response: `Commission ${commission.id} is already paid, skipping...`, + workspaceId, + }; } // if the commission is processed and has a payout, we need to update the payout total @@ -122,5 +140,8 @@ export async function chargeRefunded(event: Stripe.Event, mode: StripeMode) { newStatus: "refunded", }); - return `Commission ${commission.id} updated to status "refunded"`; + return { + response: `Commission ${commission.id} updated to status "refunded"`, + workspaceId, + }; } diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts index 228d5e024f2..2915c5c4a69 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts @@ -27,17 +27,17 @@ import { Customer, Project } from "@dub/prisma/client"; import { COUNTRIES_TO_CONTINENTS, nanoid, pick } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import type Stripe from "stripe"; +import { getCheckoutSessionProductId } from "./utils/get-checkout-session-product-id"; import { getConnectedCustomer } from "./utils/get-connected-customer"; import { getPromotionCode } from "./utils/get-promotion-code"; -import { getCheckoutSessionProductId } from "./utils/get-checkout-session-product-id"; import { updateCustomerWithStripeCustomerId } from "./utils/update-customer-with-stripe-customer-id"; // Handle event "checkout.session.completed" export async function checkoutSessionCompleted( - event: Stripe.Event, + event: Stripe.CheckoutSessionCompletedEvent, mode: StripeMode, ) { - let charge = event.data.object as Stripe.Checkout.Session; + let charge = event.data.object; let dubCustomerExternalId = charge.metadata?.dubCustomerExternalId || charge.metadata?.dubCustomerId; const clientReferenceId = charge.client_reference_id; @@ -70,7 +70,9 @@ export async function checkoutSessionCompleted( }); if (!workspace) { - return `Workspace with stripeConnectId ${stripeAccountId} not found, skipping...`; + return { + response: `Workspace not found for Stripe account ${stripeAccountId}, skipping...`, + }; } /* @@ -85,7 +87,10 @@ export async function checkoutSessionCompleted( clickEvent = await getClickEvent({ clickId: dubClickId }); if (!clickEvent) { - return `Click event with dub_id ${dubClickId} not found, skipping...`; + return { + response: `Click event with dub_id ${dubClickId} not found, skipping...`, + workspaceId: workspace.id, + }; } existingCustomer = await prisma.customer.findFirst({ @@ -199,10 +204,16 @@ export async function checkoutSessionCompleted( if (promoCodeResponse) { ({ linkId, customer, clickEvent, leadEvent } = promoCodeResponse); } else { - return `Failed to attribute via promotion code ${promotionCodeId}, skipping...`; + return { + response: `Failed to attribute via promotion code ${promotionCodeId}, skipping...`, + workspaceId: workspace.id, + }; } } else { - return `dubCustomerExternalId was provided but customer with dubCustomerExternalId ${dubCustomerExternalId} not found on Dub, skipping...`; + return { + response: `dubCustomerExternalId was provided but customer with dubCustomerExternalId ${dubCustomerExternalId} not found on Dub, skipping...`, + workspaceId: workspace.id, + }; } } } else { @@ -247,7 +258,10 @@ export async function checkoutSessionCompleted( stripeCustomerId, }); if (!customer) { - return `dubCustomerExternalId was found on the connected customer ${stripeCustomerId} but customer with dubCustomerExternalId ${dubCustomerExternalId} not found on Dub, skipping...`; + return { + response: `dubCustomerExternalId was found on the connected customer ${stripeCustomerId} but customer with dubCustomerExternalId ${dubCustomerExternalId} not found on Dub, skipping...`, + workspaceId: workspace.id, + }; } } else if (promotionCodeId) { const promoCodeResponse = await attributeViaPromoCode({ @@ -260,10 +274,16 @@ export async function checkoutSessionCompleted( if (promoCodeResponse) { ({ linkId, customer, clickEvent, leadEvent } = promoCodeResponse); } else { - return `Failed to attribute via promotion code ${promotionCodeId}, skipping...`; + return { + response: `Failed to attribute via promotion code ${promotionCodeId}, skipping...`, + workspaceId: workspace.id, + }; } } else { - return `dubCustomerExternalId not found in Stripe checkout session metadata (nor is it available on the connected customer ${stripeCustomerId}), client_reference_id is not a dub_id, and promotion code is not provided, skipping...`; + return { + response: `dubCustomerExternalId not found in Stripe checkout session metadata (nor is it available on the connected customer ${stripeCustomerId}), client_reference_id is not a dub_id, and promotion code is not provided, skipping...`, + workspaceId: workspace.id, + }; } } } @@ -272,7 +292,10 @@ export async function checkoutSessionCompleted( if (!leadEvent) { const leadEventData = await getLeadEvent({ customerId: customer.id }); if (!leadEventData) { - return `No lead event found for customer ${customer.id}, skipping...`; + return { + response: `No lead event found for customer ${customer.id}, skipping...`, + workspaceId: workspace.id, + }; } leadEvent = { ...leadEventData, @@ -281,7 +304,10 @@ export async function checkoutSessionCompleted( linkId = leadEvent.link_id; } } else { - return "No stripeCustomerId or dubCustomerExternalId found in Stripe checkout session metadata, skipping..."; + return { + response: `No stripeCustomerId or dubCustomerExternalId found in Stripe checkout session metadata, skipping...`, + workspaceId: workspace.id, + }; } let chargeAmountTotal = @@ -289,15 +315,24 @@ export async function checkoutSessionCompleted( // should never be below 0, but just in case if (chargeAmountTotal <= 0) { - return `Checkout session completed for Stripe customer ${stripeCustomerId} but amount is 0, skipping...`; + return { + response: `Checkout session completed for Stripe customer ${stripeCustomerId} but amount is 0, skipping...`, + workspaceId: workspace.id, + }; } if (charge.mode === "setup") { - return `Checkout session completed for Stripe customer ${stripeCustomerId} but mode is "setup", skipping...`; + return { + response: `Checkout session completed for Stripe customer ${stripeCustomerId} but mode is "setup", skipping...`, + workspaceId: workspace.id, + }; } if (charge.payment_status !== "paid") { - return `Checkout session completed for Stripe customer ${stripeCustomerId} but payment_status is not "paid", skipping...`; + return { + response: `Checkout session completed for Stripe customer ${stripeCustomerId} but payment_status is not "paid", skipping...`, + workspaceId: workspace.id, + }; } if (invoiceId) { @@ -326,7 +361,10 @@ export async function checkoutSessionCompleted( "[Stripe Webhook] Skipping already processed invoice.", invoiceId, ); - return `Invoice with ID ${invoiceId} already processed, skipping...`; + return { + response: `Invoice with ID ${invoiceId} already processed, skipping...`, + workspaceId: workspace.id, + }; } } @@ -549,7 +587,10 @@ export async function checkoutSessionCompleted( ]), ); - return `Checkout session completed for customer with external ID ${dubCustomerExternalId} and invoice ID ${invoiceId}`; + return { + response: `Checkout session completed for customer with external ID ${dubCustomerExternalId} and invoice ID ${invoiceId}`, + workspaceId: workspace.id, + }; } async function attributeViaPromoCode({ diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/coupon-deleted.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/coupon-deleted.ts index 7c999c0085b..36b20631f46 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/coupon-deleted.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/coupon-deleted.ts @@ -9,8 +9,8 @@ import { waitUntil } from "@vercel/functions"; import type Stripe from "stripe"; // Handle event "coupon.deleted" -export async function couponDeleted(event: Stripe.Event) { - const coupon = event.data.object as Stripe.Coupon; +export async function couponDeleted(event: Stripe.CouponDeletedEvent) { + const coupon = event.data.object; const stripeAccountId = event.account as string; const workspace = await prisma.project.findUnique({ @@ -26,11 +26,16 @@ export async function couponDeleted(event: Stripe.Event) { }); if (!workspace) { - return `Workspace not found for Stripe account ${stripeAccountId}.`; + return { + response: `Workspace not found for Stripe account ${stripeAccountId}, skipping...`, + }; } if (!workspace.defaultProgramId) { - return `Workspace ${workspace.id} for stripe account ${stripeAccountId} has no programs.`; + return { + response: `Workspace ${workspace.id} for stripe account ${stripeAccountId} has no programs.`, + workspaceId: workspace.id, + }; } const discounts = await prisma.discount.findMany({ @@ -44,7 +49,10 @@ export async function couponDeleted(event: Stripe.Event) { }); if (!discounts.length) { - return `Discount not found for Stripe coupon ${coupon.id}.`; + return { + response: `Discount not found for Stripe coupon ${coupon.id}.`, + workspaceId: workspace.id, + }; } const discountIds = discounts.map((d) => d.id); @@ -129,5 +137,8 @@ export async function couponDeleted(event: Stripe.Event) { })(), ); - return `Stripe coupon ${coupon.id} deleted.`; + return { + response: `Stripe coupon ${coupon.id} deleted.`, + workspaceId: workspace.id, + }; } diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/customer-created.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/customer-created.ts index 70e4b7105bb..dd2b82b685a 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/customer-created.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/customer-created.ts @@ -3,15 +3,18 @@ import type Stripe from "stripe"; import { createNewCustomer } from "./utils/create-new-customer"; // Handle event "customer.created" -export async function customerCreated(event: Stripe.Event) { - const stripeCustomer = event.data.object as Stripe.Customer; +export async function customerCreated(event: Stripe.CustomerCreatedEvent) { + const stripeCustomer = event.data.object; const stripeAccountId = event.account as string; const dubCustomerExternalId = stripeCustomer.metadata?.dubCustomerExternalId || stripeCustomer.metadata?.dubCustomerId; if (!dubCustomerExternalId) { - return "External ID not found in Stripe customer metadata, skipping..."; + return { + response: + "External ID not found in Stripe customer metadata, skipping...", + }; } const workspace = await prisma.project.findUnique({ @@ -24,9 +27,13 @@ export async function customerCreated(event: Stripe.Event) { }); if (!workspace) { - return "Workspace not found, skipping..."; + return { + response: `Workspace not found for Stripe account ${stripeAccountId}, skipping...`, + }; } + const workspaceId = workspace.id; + // Check the customer is not already created const customer = await prisma.customer.findFirst({ where: { @@ -57,10 +64,16 @@ export async function customerCreated(event: Stripe.Event) { }, }); - return `Dub customer with ID ${customer.id} updated with Stripe customer ID ${stripeCustomer.id}`; + return { + response: `Dub customer with ID ${customer.id} updated with Stripe customer ID ${stripeCustomer.id}`, + workspaceId, + }; } catch (error) { console.error(error); - return `Error updating Dub customer with ID ${customer.id}: ${error}`; + return { + response: `Error updating Dub customer with ID ${customer.id}: ${error}`, + workspaceId, + }; } } diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/customer-subscription-created.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/customer-subscription-created.ts index eca84f275ca..b37d49fdd72 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/customer-subscription-created.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/customer-subscription-created.ts @@ -11,13 +11,15 @@ import { getConnectedCustomer } from "./utils/get-connected-customer"; // Handle event "customer.subscription.created" // only used for recording free trial creations export async function customerSubscriptionCreated( - event: Stripe.Event, + event: Stripe.CustomerSubscriptionCreatedEvent, mode: StripeMode, ) { - const createdSubscription = event.data.object as Stripe.Subscription; + const createdSubscription = event.data.object; if (createdSubscription.status !== "trialing") { - return "Subscription is not in trialing status, skipping..."; + return { + response: "Subscription is not in trialing status, skipping...", + }; } const stripeAccountId = event.account as string; @@ -41,11 +43,18 @@ export async function customerSubscriptionCreated( }); if (!workspace) { - return `Workspace with stripeConnectId ${stripeAccountId} not found, skipping...`; + return { + response: `Workspace not found for Stripe account ${stripeAccountId}, skipping...`, + }; } + const workspaceId = workspace.id; + if (!workspace.installedIntegrations.length) { - return `Workspace ${workspace.slug} has no Stripe integration installed, skipping...`; + return { + response: `Workspace ${workspace.slug} has no Stripe integration installed, skipping...`, + workspaceId, + }; } const stripeIntegrationSettings = stripeIntegrationSettingsSchema.parse( @@ -53,7 +62,10 @@ export async function customerSubscriptionCreated( ); if (!stripeIntegrationSettings?.freeTrials?.enabled) { - return `Stripe free trial tracking is not enabled for workspace ${workspace.slug}, skipping...`; + return { + response: `Stripe free trial tracking is not enabled for workspace ${workspace.slug}, skipping...`, + workspaceId, + }; } let customer: Customer | null = null; @@ -82,7 +94,10 @@ export async function customerSubscriptionCreated( if (!customer) { // this should never happen, but just in case - return `Customer ${stripeCustomer.id} with email ${stripeCustomer.email} has not been tracked yet, skipping...`; + return { + response: `Customer ${stripeCustomer.id} with email ${stripeCustomer.email} has not been tracked yet, skipping...`, + workspaceId, + }; } // update the customer with the Stripe customer ID (for future reference by invoice.paid) waitUntil( @@ -97,16 +112,25 @@ export async function customerSubscriptionCreated( ); } else { // this should never happen either, but just in case - return `Customer with stripeCustomerId ${stripeCustomerId} ${stripeCustomer ? "does not have an email on Stripe" : "does not exist"}, skipping...`; + return { + response: `Customer with stripeCustomerId ${stripeCustomerId} ${stripeCustomer ? "does not have an email on Stripe" : "does not exist"}, skipping...`, + workspaceId, + }; } } if (!customer.clickId) { - return `Customer ${customer.id} has no clickId, skipping...`; + return { + response: `Customer ${customer.id} has no clickId, skipping...`, + workspaceId, + }; } if (!customer.externalId) { - return `Customer ${customer.id} has no externalId, skipping...`; + return { + response: `Customer ${customer.id} has no externalId, skipping...`, + workspaceId, + }; } // if trackQuantity is enabled, use the quantity from the main subscription item @@ -127,5 +151,8 @@ export async function customerSubscriptionCreated( source: "trial", }); - return `Customer subscription created for customer ${customer.id} with stripeCustomerId ${stripeCustomerId} and workspace ${workspace.slug}`; + return { + response: `Customer subscription created for customer ${customer.id} with stripeCustomerId ${stripeCustomerId} and workspace ${workspace.slug}`, + workspaceId, + }; } diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/customer-subscription-deleted.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/customer-subscription-deleted.ts index 47d3bf2683c..7d67cd0977b 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/customer-subscription-deleted.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/customer-subscription-deleted.ts @@ -2,8 +2,10 @@ import { prisma } from "@dub/prisma"; import type Stripe from "stripe"; // Handle event "customer.subscription.deleted" -export async function customerSubscriptionDeleted(event: Stripe.Event) { - const deletedSubscription = event.data.object as Stripe.Subscription; +export async function customerSubscriptionDeleted( + event: Stripe.CustomerSubscriptionDeletedEvent, +) { + const deletedSubscription = event.data.object; const customer = await prisma.customer.findUnique({ where: { @@ -12,7 +14,9 @@ export async function customerSubscriptionDeleted(event: Stripe.Event) { }); if (!customer) { - return "Customer not found, skipping subscription cancellation..."; + return { + response: "Customer not found, skipping subscription cancellation...", + }; } const updatedCustomer = await prisma.customer.update({ @@ -20,5 +24,8 @@ export async function customerSubscriptionDeleted(event: Stripe.Event) { data: { subscriptionCanceledAt: new Date() }, }); - return `Subscription cancelled, updating customer ${updatedCustomer.id} with subscriptionCanceledAt: ${updatedCustomer.subscriptionCanceledAt}`; + return { + response: `Subscription cancelled, updating customer ${updatedCustomer.id} with subscriptionCanceledAt: ${updatedCustomer.subscriptionCanceledAt}`, + workspaceId: customer.projectId, + }; } diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/customer-updated.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/customer-updated.ts index 7571b1cea35..773f90874ec 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/customer-updated.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/customer-updated.ts @@ -3,15 +3,18 @@ import type Stripe from "stripe"; import { createNewCustomer } from "./utils/create-new-customer"; // Handle event "customer.updated" -export async function customerUpdated(event: Stripe.Event) { - const stripeCustomer = event.data.object as Stripe.Customer; +export async function customerUpdated(event: Stripe.CustomerUpdatedEvent) { + const stripeCustomer = event.data.object; const stripeAccountId = event.account as string; const dubCustomerExternalId = stripeCustomer.metadata?.dubCustomerExternalId || stripeCustomer.metadata?.dubCustomerId; if (!dubCustomerExternalId) { - return "External ID not found in Stripe customer metadata, skipping..."; + return { + response: + "External ID not found in Stripe customer metadata, skipping...", + }; } const workspace = await prisma.project.findUnique({ @@ -24,9 +27,13 @@ export async function customerUpdated(event: Stripe.Event) { }); if (!workspace) { - return "Workspace not found, skipping..."; + return { + response: `Workspace not found for Stripe account ${stripeAccountId}, skipping...`, + }; } + const workspaceId = workspace.id; + const customer = await prisma.customer.findFirst({ where: { OR: [ @@ -56,10 +63,16 @@ export async function customerUpdated(event: Stripe.Event) { }, }); - return `Dub customer with ID ${customer.id} updated.`; + return { + response: `Dub customer with ID ${customer.id} updated.`, + workspaceId, + }; } catch (error) { console.error(error); - return `Error updating Dub customer with ID ${customer.id}: ${error}`; + return { + response: `Error updating Dub customer with ID ${customer.id}: ${error}`, + workspaceId, + }; } } diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts index 71918cd0340..7d55570096e 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts @@ -18,8 +18,11 @@ import type Stripe from "stripe"; import { getConnectedCustomer } from "./utils/get-connected-customer"; // Handle event "invoice.paid" -export async function invoicePaid(event: Stripe.Event, mode: StripeMode) { - const invoice = event.data.object as Stripe.Invoice; +export async function invoicePaid( + event: Stripe.InvoicePaidEvent, + mode: StripeMode, +) { + const invoice = event.data.object; const stripeAccountId = event.account as string; const stripeCustomerId = invoice.customer as string; const invoiceId = invoice.id; @@ -59,14 +62,18 @@ export async function invoicePaid(event: Stripe.Event, mode: StripeMode) { }); } catch (error) { console.log(error); - return `Customer with dubCustomerExternalId ${dubCustomerExternalId} not found, skipping...`; + return { + response: `Customer with dubCustomerExternalId ${dubCustomerExternalId} not found, skipping...`, + }; } } } // if customer is still not found, we skip the event if (!customer) { - return `Customer with stripeCustomerId ${stripeCustomerId} not found on Dub (nor does the connected customer ${stripeCustomerId} have a valid dubCustomerExternalId), skipping...`; + return { + response: `Customer with stripeCustomerId ${stripeCustomerId} not found on Dub (nor does the connected customer ${stripeCustomerId} have a valid dubCustomerExternalId), skipping...`, + }; } // Sale amount excluding tax: use total_excluding_tax only when invoice was paid in full @@ -101,12 +108,18 @@ export async function invoicePaid(event: Stripe.Event, mode: StripeMode) { "[Stripe Webhook] Skipping already processed invoice.", invoiceId, ); - return `Invoice with ID ${invoiceId} already processed, skipping...`; + return { + response: `Invoice with ID ${invoiceId} already processed, skipping...`, + workspaceId: customer.projectId, + }; } // Stripe can sometimes return a negative amount for some reason, so we skip if it's below 0 if (invoiceSaleAmount <= 0) { - return `Invoice with ID ${invoiceId} has an amount of 0, skipping...`; + return { + response: `Invoice with ID ${invoiceId} has an amount of 0, skipping...`, + workspaceId: customer.projectId, + }; } // if currency is not USD, convert it to USD based on the current FX rate @@ -125,7 +138,10 @@ export async function invoicePaid(event: Stripe.Event, mode: StripeMode) { // Find lead const leadEvent = await getLeadEvent({ customerId: customer.id }); if (!leadEvent) { - return `Lead event with customer ID ${customer.id} not found, skipping...`; + return { + response: `Lead event with customer ID ${customer.id} not found, skipping...`, + workspaceId: customer.projectId, + }; } const eventId = nanoid(16); @@ -157,7 +173,10 @@ export async function invoicePaid(event: Stripe.Event, mode: StripeMode) { }); if (!link) { - return `Link with ID ${linkId} not found, skipping...`; + return { + response: `Link with ID ${linkId} not found, skipping...`, + workspaceId: customer.projectId, + }; } const firstConversionFlag = isFirstConversion({ @@ -323,5 +342,8 @@ export async function invoicePaid(event: Stripe.Event, mode: StripeMode) { ]), ); - return `Sale recorded for customer ID ${customer.id} and invoice ID ${invoiceId}`; + return { + response: `Sale recorded for customer ID ${customer.id} and invoice ID ${invoiceId}`, + workspaceId: customer.projectId, + }; } diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/promotion-code-updated.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/promotion-code-updated.ts index 6184792b1a0..121ec1183fb 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/promotion-code-updated.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/promotion-code-updated.ts @@ -2,8 +2,10 @@ import { prisma } from "@dub/prisma"; import type Stripe from "stripe"; // Handle event "promotion_code.updated" -export async function promotionCodeUpdated(event: Stripe.Event) { - const promotionCode = event.data.object as Stripe.PromotionCode; +export async function promotionCodeUpdated( + event: Stripe.PromotionCodeUpdatedEvent, +) { + const promotionCode = event.data.object; const stripeAccountId = event.account as string; const workspace = await prisma.project.findUnique({ @@ -19,15 +21,25 @@ export async function promotionCodeUpdated(event: Stripe.Event) { }); if (!workspace) { - return `Workspace not found for Stripe account ${stripeAccountId}.`; + return { + response: `Workspace not found for Stripe account ${stripeAccountId}, skipping...`, + }; } + const workspaceId = workspace.id; + if (!workspace.defaultProgramId) { - return `Workspace ${workspace.id} for stripe account ${stripeAccountId} has no programs.`; + return { + response: `Workspace ${workspaceId} for stripe account ${stripeAccountId} has no programs.`, + workspaceId, + }; } if (promotionCode.active) { - return `Promotion code ${promotionCode.id} is active.`; + return { + response: `Promotion code ${promotionCode.id} is active.`, + workspaceId, + }; } // If the promotion code is not active, we need to remove them from Dub @@ -41,7 +53,10 @@ export async function promotionCodeUpdated(event: Stripe.Event) { }); if (!discountCode) { - return `Discount code not found for Stripe promotion code ${promotionCode.id}.`; + return { + response: `Discount code not found for Stripe promotion code ${promotionCode.id}.`, + workspaceId, + }; } await prisma.discountCode.delete({ @@ -50,5 +65,8 @@ export async function promotionCodeUpdated(event: Stripe.Event) { }, }); - return `Discount code ${discountCode.id} deleted from the program ${workspace.defaultProgramId}.`; + return { + response: `Discount code ${discountCode.id} deleted from the program ${workspace.defaultProgramId}.`, + workspaceId, + }; } diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts index 1b91d0dc4de..9b514adc03c 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts @@ -1,6 +1,9 @@ +import { captureWebhookLog } from "@/lib/api-logs/capture-webhook-log"; import { withAxiom } from "@/lib/axiom/server"; import { stripe } from "@/lib/stripe"; import { StripeMode } from "@/lib/types"; +import { prisma } from "@dub/prisma"; +import { waitUntil } from "@vercel/functions"; import { logAndRespond } from "app/(ee)/api/cron/utils"; import Stripe from "stripe"; import { accountApplicationDeauthorized } from "./account-application-deauthorized"; @@ -29,6 +32,7 @@ const relevantEvents = new Set([ // POST /api/stripe/integration/webhook – listen to Stripe webhooks (for Stripe Integration) export const POST = withAxiom(async (req: Request) => { + const startTime = Date.now(); const pathname = new URL(req.url).pathname; const buf = await req.text(); const sig = req.headers.get("Stripe-Signature"); @@ -81,40 +85,87 @@ export const POST = withAxiom(async (req: Request) => { ); } - let response = "OK"; + let result: { + response: string; + workspaceId?: string; + } = { + response: "OK", + }; switch (event.type) { case "account.application.deauthorized": - response = await accountApplicationDeauthorized(event, mode); + result = await accountApplicationDeauthorized(event, mode); break; case "charge.refunded": - response = await chargeRefunded(event, mode); + result = await chargeRefunded(event, mode); break; case "checkout.session.completed": - response = await checkoutSessionCompleted(event, mode); + result = await checkoutSessionCompleted(event, mode); break; case "coupon.deleted": - response = await couponDeleted(event); + result = await couponDeleted(event); break; case "customer.created": - response = await customerCreated(event); + result = await customerCreated(event); break; case "customer.updated": - response = await customerUpdated(event); + result = await customerUpdated(event); break; case "customer.subscription.created": - response = await customerSubscriptionCreated(event, mode); + result = await customerSubscriptionCreated(event, mode); break; case "customer.subscription.deleted": - response = await customerSubscriptionDeleted(event); + result = await customerSubscriptionDeleted(event); break; case "invoice.paid": - response = await invoicePaid(event, mode); + result = await invoicePaid(event, mode); break; case "promotion_code.updated": - response = await promotionCodeUpdated(event); + result = await promotionCodeUpdated(event); break; } - return logAndRespond(`[${event.type}]: ${response}`); + const finalResponse = `[${event.type}]: ${result.response}`; + + waitUntil( + (async () => { + // if workspaceId is returned as undefined + // AND the response does not contain "Workspace not found" (indicating the workspace doesn't exist) + // we try to find the workspace ID from the Stripe account ID + if ( + !result.workspaceId && + !result.response.startsWith("Workspace not found") && + event.account + ) { + const workspace = await prisma.project.findUnique({ + where: { + stripeConnectId: event.account, + }, + select: { + id: true, + }, + }); + if (workspace) { + // if workspace exists, we set the workspace ID + result.workspaceId = workspace.id; + } + } + + // if workspace ID exists, we capture the webhook log + if (result.workspaceId) { + await captureWebhookLog({ + workspaceId: result.workspaceId, + method: req.method, + path: "/stripe/integration/webhook", + statusCode: 200, + duration: Date.now() - startTime, + requestBody: event, + responseBody: finalResponse, + userAgent: req.headers.get("user-agent"), + }); + } + })(), + ); + + return logAndRespond(finalResponse); }); diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/utils/create-new-customer.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/utils/create-new-customer.ts index 2740aab740c..46d6f0a60b0 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/utils/create-new-customer.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/utils/create-new-customer.ts @@ -23,13 +23,17 @@ export async function createNewCustomer(event: Stripe.Event) { // The client app should always send dubClickId (dub_id) via metadata if (!clickId) { - return "Click ID not found in Stripe customer metadata, skipping..."; + return { + response: "Click ID not found in Stripe customer metadata, skipping...", + }; } // Find click const clickData = await getClickEvent({ clickId }); if (!clickData) { - return `Click event with ID ${clickId} not found, skipping...`; + return { + response: `Click event with ID ${clickId} not found, skipping...`, + }; } // Find link @@ -41,7 +45,10 @@ export async function createNewCustomer(event: Stripe.Event) { }); if (!link || !link.projectId) { - return `Link with ID ${linkId} not found or does not have a project, skipping...`; + return { + response: `Link with ID ${linkId} not found or does not have a project, skipping...`, + workspaceId: link?.projectId ? link.projectId : undefined, + }; } // Create a customer @@ -168,5 +175,8 @@ export async function createNewCustomer(event: Stripe.Event) { ]), ); - return `New Dub customer created: ${customer.id}. Lead event recorded: ${leadData.event_id}`; + return { + response: `New Dub customer created: ${customer.id}. Lead event recorded: ${leadData.event_id}`, + workspaceId: workspace.id, + }; } diff --git a/apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts b/apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts index 173799e70e5..ec3525e5976 100644 --- a/apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts +++ b/apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts @@ -3,8 +3,8 @@ import Stripe from "stripe"; import { processDomainRenewalFailure } from "./utils/process-domain-renewal-failure"; import { processPayoutInvoiceFailure } from "./utils/process-payout-invoice-failure"; -export async function chargeFailed(event: Stripe.Event) { - const charge = event.data.object as Stripe.Charge; +export async function chargeFailed(event: Stripe.ChargeFailedEvent) { + const charge = event.data.object; const { transfer_group: invoiceId, failure_message: failedReason } = charge; diff --git a/apps/web/app/(ee)/api/stripe/webhook/charge-refunded.ts b/apps/web/app/(ee)/api/stripe/webhook/charge-refunded.ts index f0304a5dd8c..e7011f4eb3c 100644 --- a/apps/web/app/(ee)/api/stripe/webhook/charge-refunded.ts +++ b/apps/web/app/(ee)/api/stripe/webhook/charge-refunded.ts @@ -3,8 +3,8 @@ import { prisma } from "@dub/prisma"; import { Invoice } from "@dub/prisma/client"; import Stripe from "stripe"; -export async function chargeRefunded(event: Stripe.Event) { - const charge = event.data.object as Stripe.Charge; +export async function chargeRefunded(event: Stripe.ChargeRefundedEvent) { + const charge = event.data.object; const { transfer_group: invoiceId } = charge; diff --git a/apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts b/apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts index 554865aa3d7..42876246674 100644 --- a/apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts +++ b/apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts @@ -8,8 +8,8 @@ import { APP_DOMAIN_WITH_NGROK, pluralize } from "@dub/utils"; import { addDays } from "date-fns"; import Stripe from "stripe"; -export async function chargeSucceeded(event: Stripe.Event) { - const charge = event.data.object as Stripe.Charge; +export async function chargeSucceeded(event: Stripe.ChargeSucceededEvent) { + const charge = event.data.object; const { transfer_group: invoiceId } = charge; diff --git a/apps/web/app/(ee)/api/stripe/webhook/checkout-session-completed.ts b/apps/web/app/(ee)/api/stripe/webhook/checkout-session-completed.ts index b8a5b790ce6..54dcdd0f185 100644 --- a/apps/web/app/(ee)/api/stripe/webhook/checkout-session-completed.ts +++ b/apps/web/app/(ee)/api/stripe/webhook/checkout-session-completed.ts @@ -12,8 +12,10 @@ import { Program, User } from "@dub/prisma/client"; import { getPlanAndTierFromPriceId, log, prettyPrint } from "@dub/utils"; import Stripe from "stripe"; -export async function checkoutSessionCompleted(event: Stripe.Event) { - const checkoutSession = event.data.object as Stripe.Checkout.Session; +export async function checkoutSessionCompleted( + event: Stripe.CheckoutSessionCompletedEvent, +) { + const checkoutSession = event.data.object; if ( checkoutSession.mode === "setup" || diff --git a/apps/web/app/(ee)/api/stripe/webhook/customer-subscription-deleted.ts b/apps/web/app/(ee)/api/stripe/webhook/customer-subscription-deleted.ts index 36d6c45770f..692c3d65197 100644 --- a/apps/web/app/(ee)/api/stripe/webhook/customer-subscription-deleted.ts +++ b/apps/web/app/(ee)/api/stripe/webhook/customer-subscription-deleted.ts @@ -14,8 +14,10 @@ import Stripe from "stripe"; import { sendCancellationFeedback } from "./utils/send-cancellation-feedback"; import { updateWorkspacePlan } from "./utils/update-workspace-plan"; -export async function customerSubscriptionDeleted(event: Stripe.Event) { - const subscriptionDeleted = event.data.object as Stripe.Subscription; +export async function customerSubscriptionDeleted( + event: Stripe.CustomerSubscriptionDeletedEvent, +) { + const subscriptionDeleted = event.data.object; const stripeId = subscriptionDeleted.customer.toString(); diff --git a/apps/web/app/(ee)/api/stripe/webhook/customer-subscription-updated.ts b/apps/web/app/(ee)/api/stripe/webhook/customer-subscription-updated.ts index f04d38fa9b3..f6837c0e88d 100644 --- a/apps/web/app/(ee)/api/stripe/webhook/customer-subscription-updated.ts +++ b/apps/web/app/(ee)/api/stripe/webhook/customer-subscription-updated.ts @@ -4,8 +4,10 @@ import Stripe from "stripe"; import { sendCancellationFeedback } from "./utils/send-cancellation-feedback"; import { updateWorkspacePlan } from "./utils/update-workspace-plan"; -export async function customerSubscriptionUpdated(event: Stripe.Event) { - const subscriptionUpdated = event.data.object as Stripe.Subscription; +export async function customerSubscriptionUpdated( + event: Stripe.CustomerSubscriptionUpdatedEvent, +) { + const subscriptionUpdated = event.data.object; const priceId = subscriptionUpdated.items.data[0].price.id; const { plan } = getPlanAndTierFromPriceId({ priceId }); diff --git a/apps/web/app/(ee)/api/stripe/webhook/invoice-payment-failed.tsx b/apps/web/app/(ee)/api/stripe/webhook/invoice-payment-failed.tsx index 1b4e39fd9cc..1156fbd54bb 100644 --- a/apps/web/app/(ee)/api/stripe/webhook/invoice-payment-failed.tsx +++ b/apps/web/app/(ee)/api/stripe/webhook/invoice-payment-failed.tsx @@ -3,12 +3,14 @@ import FailedPayment from "@dub/email/templates/failed-payment"; import { prisma } from "@dub/prisma"; import Stripe from "stripe"; -export async function invoicePaymentFailed(event: Stripe.Event) { +export async function invoicePaymentFailed( + event: Stripe.InvoicePaymentFailedEvent, +) { const { customer: stripeId, attempt_count: attemptCount, amount_due: amountDue, - } = event.data.object as Stripe.Invoice; + } = event.data.object; if (!stripeId) { return "No customer found in invoice.payment_failed event."; diff --git a/apps/web/app/(ee)/api/stripe/webhook/payment-intent-requires-action.ts b/apps/web/app/(ee)/api/stripe/webhook/payment-intent-requires-action.ts index d2e31f33981..75183a30a5d 100644 --- a/apps/web/app/(ee)/api/stripe/webhook/payment-intent-requires-action.ts +++ b/apps/web/app/(ee)/api/stripe/webhook/payment-intent-requires-action.ts @@ -3,9 +3,10 @@ import Stripe from "stripe"; import { processDomainRenewalFailure } from "./utils/process-domain-renewal-failure"; import { processPayoutInvoiceFailure } from "./utils/process-payout-invoice-failure"; -export async function paymentIntentRequiresAction(event: Stripe.Event) { - const { transfer_group: invoiceId, latest_charge: charge } = event.data - .object as Stripe.PaymentIntent; +export async function paymentIntentRequiresAction( + event: Stripe.PaymentIntentRequiresActionEvent, +) { + const { transfer_group: invoiceId } = event.data.object; if (!invoiceId) { return "No transfer group found, skipping..."; diff --git a/apps/web/app/(ee)/api/stripe/webhook/transfer-reversed.ts b/apps/web/app/(ee)/api/stripe/webhook/transfer-reversed.ts index 6bc7146f5d2..b0673d60401 100644 --- a/apps/web/app/(ee)/api/stripe/webhook/transfer-reversed.ts +++ b/apps/web/app/(ee)/api/stripe/webhook/transfer-reversed.ts @@ -2,8 +2,8 @@ import { prisma } from "@dub/prisma"; import { pluralize } from "@dub/utils"; import Stripe from "stripe"; -export async function transferReversed(event: Stripe.Event) { - const stripeTransfer = event.data.object as Stripe.Transfer; +export async function transferReversed(event: Stripe.TransferReversedEvent) { + const stripeTransfer = event.data.object; // when transfer is reversed on Stripe, we update any sent payouts with matching stripeTransferId to: // - set the status to processed (so it can be resent to the partner later) diff --git a/apps/web/app/api/logs/[logId]/route.ts b/apps/web/app/api/logs/[logId]/route.ts new file mode 100644 index 00000000000..0ff378573f8 --- /dev/null +++ b/apps/web/app/api/logs/[logId]/route.ts @@ -0,0 +1,38 @@ +import { getApiLogsDateRange } from "@/lib/api-logs/api-log-retention"; +import { enrichApiLogs } from "@/lib/api-logs/enrich-api-logs"; +import { getApiLogById } from "@/lib/api-logs/get-api-log"; +import { DubApiError } from "@/lib/api/errors"; +import { withWorkspace } from "@/lib/auth/workspace"; +import { NextResponse } from "next/server"; + +// GET /api/logs/:logId +export const GET = withWorkspace( + async ({ workspace, params }) => { + const log = await getApiLogById({ + workspaceId: workspace.id, + id: params.logId, + }); + + if (!log) { + throw new DubApiError({ + code: "not_found", + message: "API log not found.", + }); + } + + const { start } = getApiLogsDateRange(workspace.plan); + + if (new Date(log.timestamp) < new Date(start)) { + throw new DubApiError({ + code: "not_found", + message: + "API log is past your current plan's retention period. Upgrade your plan to view more logs.", + }); + } + + return NextResponse.json(await enrichApiLogs(log)); + }, + { + requiredPermissions: ["workspaces.read"], + }, +); diff --git a/apps/web/app/api/logs/count/route.ts b/apps/web/app/api/logs/count/route.ts new file mode 100644 index 00000000000..84b077a487e --- /dev/null +++ b/apps/web/app/api/logs/count/route.ts @@ -0,0 +1,23 @@ +import { getApiLogsDateRange } from "@/lib/api-logs/api-log-retention"; +import { getApiLogsCount } from "@/lib/api-logs/get-api-logs-count"; +import { getApiLogsCountQuerySchema } from "@/lib/api-logs/schemas"; +import { withWorkspace } from "@/lib/auth/workspace"; +import { NextResponse } from "next/server"; + +// GET /api/logs/count +export const GET = withWorkspace( + async ({ workspace, searchParams }) => { + const filters = getApiLogsCountQuerySchema.parse(searchParams); + + const rows = await getApiLogsCount({ + ...filters, + ...getApiLogsDateRange(workspace.plan), + workspaceId: workspace.id, + }); + + return NextResponse.json(rows); + }, + { + requiredPermissions: ["workspaces.read"], + }, +); diff --git a/apps/web/app/api/logs/route.ts b/apps/web/app/api/logs/route.ts new file mode 100644 index 00000000000..1c85fae2029 --- /dev/null +++ b/apps/web/app/api/logs/route.ts @@ -0,0 +1,24 @@ +import { getApiLogsDateRange } from "@/lib/api-logs/api-log-retention"; +import { enrichApiLogs } from "@/lib/api-logs/enrich-api-logs"; +import { getApiLogs } from "@/lib/api-logs/get-api-logs"; +import { getApiLogsQuerySchema } from "@/lib/api-logs/schemas"; +import { withWorkspace } from "@/lib/auth/workspace"; +import { NextResponse } from "next/server"; + +// GET /api/logs +export const GET = withWorkspace( + async ({ workspace, searchParams }) => { + const filters = getApiLogsQuerySchema.parse(searchParams); + + const logs = await getApiLogs({ + ...filters, + ...getApiLogsDateRange(workspace.plan), + workspaceId: workspace.id, + }); + + return NextResponse.json(await enrichApiLogs(logs)); + }, + { + requiredPermissions: ["workspaces.read"], + }, +); diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/logs/[logId]/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/logs/[logId]/page-client.tsx new file mode 100644 index 00000000000..a16c6525d0b --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/logs/[logId]/page-client.tsx @@ -0,0 +1,255 @@ +"use client"; + +import { METHOD_BADGE_VARIANTS } from "@/lib/api-logs/constants"; +import useWorkspace from "@/lib/swr/use-workspace"; +import { EnrichedApiLog } from "@/lib/types"; +import { PageContent } from "@/ui/layout/page-content"; +import { PageWidthWrapper } from "@/ui/layout/page-width-wrapper"; +import { getStatusCodeBadgeVariant } from "@/ui/logs/log-utils"; +import { UserAvatar } from "@/ui/users/user-avatar"; +import { CopyButton, StatusBadge, TimestampTooltip } from "@dub/ui"; +import { ChevronRight, StackY3 } from "@dub/ui/icons"; +import { fetcher, formatDateTime } from "@dub/utils"; +import Link from "next/link"; +import { useEffect, useState } from "react"; +import type { HighlighterCore } from "shiki"; +import useSWR from "swr"; + +export function LogDetailPageClient({ logId }: { logId: string }) { + const { id: workspaceId, slug } = useWorkspace(); + + const { + data: log, + isLoading, + error, + } = useSWR( + workspaceId && `/api/logs/${logId}?workspaceId=${workspaceId}`, + fetcher, + ); + + return ( + + ) : ( +
+ + + +
+ + + {log?.method} {log?.route_pattern || log?.path} + +
+
+ ) + } + > + + {log ? ( + + ) : isLoading ? ( + + ) : error ? ( +
+

+ Failed to load log +

+

{error.message}

+
+ ) : null} +
+
+ ); +} + +function LogDetailContent({ log }: { log: EnrichedApiLog }) { + const [highlighter, setHighlighter] = useState(null); + const [highlightedRequest, setHighlightedRequest] = useState(""); + const [highlightedResponse, setHighlightedResponse] = useState(""); + + useEffect(() => { + import("shiki").then(({ createHighlighter }) => { + createHighlighter({ + themes: ["min-light"], + langs: ["json"], + }).then(setHighlighter); + }); + }, []); + + useEffect(() => { + if (!highlighter) return; + + const toHighlightedJson = (raw: string) => { + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + value = raw; + } + + const jsonStr = JSON.stringify(value, null, 2) ?? String(value); + return highlighter.codeToHtml(jsonStr, { + theme: "min-light", + lang: "json", + }); + }; + + setHighlightedRequest(toHighlightedJson(log.request_body)); + setHighlightedResponse(toHighlightedJson(log.response_body)); + }, [highlighter, log]); + + const detailRows: Record = { + Path: ( + + {log.path} + + ), + Date: ( + + {formatDateTime(log.timestamp)} + + ), + Status: ( + + {log.status_code} + + ), + Method: ( + + {log.method} + + ), + Duration: `${log.duration}ms`, + ...(log.user_agent && { + "User-agent": log.user_agent, + }), + ...(log.token + ? { + "API Key": ( + + {log.token.partialKey} + {log.token.name && ( + + ({log.token.name}) + + )} + + ), + } + : log.user + ? { + User: ( +
+ + {log.user.name || log.user.email} +
+ ), + } + : {}), + ID: ( +
+ {log.id} + +
+ ), + }; + + return ( +
+ {/* Left: Request & Response bodies */} +
+
+
+

+ Request body +

+ {highlightedRequest ? ( +
+ ) : ( +
+ No request body +
+ )} +
+
+

+ Response body +

+ {highlightedResponse ? ( +
+ ) : ( +
+ No response body +
+ )} +
+
+
+ + {/* Right: Log details sidebar */} +
+
+

+ Log details +

+
+ {Object.entries(detailRows).map(([key, value]) => ( +
+
+ {key} +
+
+ {value} +
+
+ ))} +
+
+
+
+ ); +} + +function LogDetailSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+ ); +} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/logs/[logId]/page.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/logs/[logId]/page.tsx new file mode 100644 index 00000000000..3af87958537 --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/logs/[logId]/page.tsx @@ -0,0 +1,9 @@ +import { LogDetailPageClient } from "./page-client"; + +export default async function LogDetailPage(props: { + params: Promise<{ logId: string }>; +}) { + const params = await props.params; + + return ; +} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/logs/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/logs/page-client.tsx new file mode 100644 index 00000000000..8e3284f3d73 --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/logs/page-client.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { LogsTable } from "@/ui/logs/logs-table"; + +export function LogsPageClient() { + return ; +} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/logs/page.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/logs/page.tsx new file mode 100644 index 00000000000..2a21a828bec --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/logs/page.tsx @@ -0,0 +1,19 @@ +import { PageContent } from "@/ui/layout/page-content"; +import { PageWidthWrapper } from "@/ui/layout/page-width-wrapper"; +import { LogsPageClient } from "./page-client"; + +export default function LogsPage() { + return ( + + + + + + ); +} diff --git a/apps/web/lib/api-logs/api-log-retention.ts b/apps/web/lib/api-logs/api-log-retention.ts new file mode 100644 index 00000000000..adc727ddf5b --- /dev/null +++ b/apps/web/lib/api-logs/api-log-retention.ts @@ -0,0 +1,16 @@ +import { formatUTCDateTimeClickhouse } from "@/lib/analytics/utils/format-utc-datetime-clickhouse"; +import { PlanProps } from "@/lib/types"; +import { subDays } from "date-fns"; +import { API_LOG_RETENTION_DAYS, DEFAULT_RETENTION_DAYS } from "./constants"; + +export function getApiLogsDateRange(plan: PlanProps) { + const days = API_LOG_RETENTION_DAYS[plan] ?? DEFAULT_RETENTION_DAYS; + + const end = new Date(); + const start = subDays(end, days); + + return { + start: formatUTCDateTimeClickhouse(start), + end: formatUTCDateTimeClickhouse(end), + }; +} diff --git a/apps/web/lib/api-logs/capture-request-log.ts b/apps/web/lib/api-logs/capture-request-log.ts new file mode 100644 index 00000000000..51cc227063c --- /dev/null +++ b/apps/web/lib/api-logs/capture-request-log.ts @@ -0,0 +1,101 @@ +import { WorkspaceWithUsers } from "@/lib/types"; +import { waitUntil } from "@vercel/functions"; +import { TokenCacheItem } from "../auth/token-cache"; +import { Session } from "../auth/utils"; +import { HTTP_MUTATION_METHODS, ROUTE_PATTERNS } from "./constants"; +import { recordApiLog } from "./record-api-log"; + +// Precompile route patterns into regexes at module load +const compiledRoutePatterns = ROUTE_PATTERNS.map((pattern) => { + const regexStr = pattern + .split("/") + .map((segment) => { + if (segment.startsWith(":")) { + return "[^/]+"; + } + return segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + }) + .join("/"); + + return { + pattern, + regex: new RegExp(`^${regexStr}$`), + }; +}); + +export function getRoutePattern(path: string): string { + const normalized = path.startsWith("/api/") + ? path.replace("/api/", "/") + : path; + + for (const { pattern, regex } of compiledRoutePatterns) { + if (regex.test(normalized)) { + return pattern; + } + } + + return "unknown"; +} + +export function captureRequestLog({ + req, + response, + workspace, + session, + token, + url, + requestHeaders, + startTime, +}: { + req: Request; + response: Response; + workspace: WorkspaceWithUsers; + session: Session | undefined; + token: TokenCacheItem | null; + url: URL; + requestHeaders: Headers; + startTime: number; +}) { + const isMutation = HTTP_MUTATION_METHODS.includes( + req.method as (typeof HTTP_MUTATION_METHODS)[number], + ); + + const routePattern = getRoutePattern(url.pathname); + + if (!isMutation || routePattern === "unknown") { + return; + } + + const duration = Date.now() - startTime; + const responseClone = response.clone(); + + waitUntil( + (async () => { + let requestBody = null; + let responseBody = null; + + try { + requestBody = await req.json(); + } catch {} + + try { + responseBody = await responseClone.json(); + } catch {} + + await recordApiLog({ + workspaceId: workspace.id, + method: req.method, + path: url.pathname, + routePattern, + statusCode: response.status, + duration, + userAgent: requestHeaders.get("user-agent"), + requestBody, + responseBody, + tokenId: token?.id ?? null, + userId: session?.user?.id ?? null, + requestType: "api", + }); + })(), + ); +} diff --git a/apps/web/lib/api-logs/capture-webhook-log.ts b/apps/web/lib/api-logs/capture-webhook-log.ts new file mode 100644 index 00000000000..49a6fc99655 --- /dev/null +++ b/apps/web/lib/api-logs/capture-webhook-log.ts @@ -0,0 +1,50 @@ +import { WEBHOOK_REQUEST_ACTORS_BY_PATH } from "./constants"; +import { recordApiLog } from "./record-api-log"; + +async function parseResponseBody(responseBody: unknown): Promise { + if (responseBody instanceof Response) { + try { + return await responseBody.clone().json(); + } catch { + return null; + } + } + + return responseBody; +} + +export async function captureWebhookLog({ + workspaceId, + method, + path, + statusCode, + duration, + requestBody, + responseBody, + userAgent, +}: { + workspaceId: string; + method: string; + path: keyof typeof WEBHOOK_REQUEST_ACTORS_BY_PATH; + statusCode: number; + duration: number; + requestBody: unknown; + responseBody: unknown; + userAgent: string | null; +}) { + const actor = WEBHOOK_REQUEST_ACTORS_BY_PATH[path]; + return await recordApiLog({ + workspaceId, + method, + path, + routePattern: path, + statusCode, + duration, + userAgent, + requestBody, + responseBody: await parseResponseBody(responseBody), + tokenId: null, + userId: actor.id, + requestType: "webhook", + }); +} diff --git a/apps/web/lib/api-logs/constants.ts b/apps/web/lib/api-logs/constants.ts new file mode 100644 index 00000000000..c9acb399f30 --- /dev/null +++ b/apps/web/lib/api-logs/constants.ts @@ -0,0 +1,118 @@ +import type { PlanProps } from "@/lib/types"; +import { + APPSFLYER_INTEGRATION_ID, + SHOPIFY_INTEGRATION_ID, + STRIPE_INTEGRATION_ID, +} from "@dub/utils"; + +// Route patterns for parameterized path matching. +// Used both for logging eligibility and route pattern extraction. +// Order matters: more specific patterns must come before less specific ones. +export const ROUTE_PATTERNS = [ + // Track + "/track/lead", + "/track/sale", + "/track/open", + + // Partners + "/partners/links/upsert", + "/partners/links", + "/partners/ban", + "/partners/deactivate", + "/partners/:partnerId", + "/partners", + + // Links + "/links/bulk", + "/links/upsert", + "/links/:linkId", + "/links", + + // Customers + "/customers/:id", + "/customers", + + // Commissions + "/commissions/bulk", + "/commissions/:commissionId", + "/commissions", + + // Bounties + "/bounties/:bountyId/submissions/:submissionId/approve", + "/bounties/:bountyId/submissions/:submissionId/reject", + + // Tokens + "/tokens/embed/referrals", +] as const; + +export const REQUEST_TYPES = [ + { value: "api", label: "API" }, + { value: "webhook", label: "Webhook" }, +] as const; + +export const HTTP_STATUS_CODES = [ + { value: 200, label: "200 OK" }, + { value: 201, label: "201 Created" }, + { value: 400, label: "400 Bad Request" }, + { value: 401, label: "401 Unauthorized" }, + { value: 403, label: "403 Forbidden" }, + { value: 404, label: "404 Not Found" }, + { value: 409, label: "409 Conflict" }, + { value: 422, label: "422 Unprocessable" }, + { value: 429, label: "429 Rate Limited" }, + { value: 500, label: "500 Server Error" }, +] as const; + +export const HTTP_MUTATION_METHODS = [ + "POST", + "PATCH", + "PUT", + "DELETE", +] as const; + +export const HTTP_METHODS = ["POST", "PATCH", "PUT", "DELETE", "GET"] as const; + +export const API_LOGS_MAX_PAGE_SIZE = 50; + +export const API_LOG_RETENTION_DAYS: Record = { + free: 30, + pro: 30, + business: 60, + "business plus": 60, + "business extra": 60, + "business max": 60, + advanced: 60, + enterprise: 90, +}; + +// Default when plan is missing from workspace data (should not happen for known plans). +export const DEFAULT_RETENTION_DAYS = 30; + +export const METHOD_BADGE_VARIANTS: Record = { + POST: "new", + PATCH: "warning", + PUT: "pending", + DELETE: "error", + GET: "success", +} as const; + +export const WEBHOOK_REQUEST_ACTORS_BY_PATH = { + "/appsflyer/webhook": { + id: APPSFLYER_INTEGRATION_ID, + name: "AppsFlyer", + image: + "https://dubassets.com/integrations/int_1KN8JP7ET3VQQRF7ZQEVNFPJ5_2Geprc8", + }, + "/stripe/integration/webhook": { + id: STRIPE_INTEGRATION_ID, + name: "Stripe", + image: + "https://dubassets.com/integrations/clzra1ya60001wnj4a89zcg9h_jtyaGa7", + }, + "/shopify/integration/webhook": { + id: SHOPIFY_INTEGRATION_ID, + name: "Shopify", + image: + "https://dubassets.com/integrations/int_iWOtrZgmcyU6XDwKr4AYYqLN_jUmF77W", + }, +} as const; diff --git a/apps/web/lib/api-logs/enrich-api-logs.ts b/apps/web/lib/api-logs/enrich-api-logs.ts new file mode 100644 index 00000000000..b349eb3c343 --- /dev/null +++ b/apps/web/lib/api-logs/enrich-api-logs.ts @@ -0,0 +1,70 @@ +import { prisma } from "@dub/prisma"; +import { ApiLogTB, EnrichedApiLog } from "../types"; +import { WEBHOOK_REQUEST_ACTORS_BY_PATH } from "./constants"; + +export async function enrichApiLogs( + logs: ApiLogTB | ApiLogTB[], +): Promise { + const isSingle = !Array.isArray(logs); + const logsArray: ApiLogTB[] = isSingle ? [logs] : logs; + + if (logsArray.length === 0) { + return []; + } + + const tokenIds = [ + ...new Set(logsArray.map((l) => l.token_id).filter(Boolean)), + ]; + + const userIds = [...new Set(logsArray.map((l) => l.user_id).filter(Boolean))]; + + const [tokens, users] = await Promise.all([ + prisma.restrictedToken.findMany({ + where: { + id: { + in: tokenIds, + }, + }, + select: { + id: true, + name: true, + partialKey: true, + }, + }), + + prisma.user.findMany({ + where: { + id: { + in: userIds, + }, + }, + select: { + id: true, + name: true, + email: true, + image: true, + }, + }), + ]); + + const tokenMap = new Map(tokens.map((t) => [t.id, t] as const)); + const userMap = new Map(users.map((u) => [u.id, u] as const)); + const webhookRequestActorsMap = new Map( + Object.values(WEBHOOK_REQUEST_ACTORS_BY_PATH).map((actor) => [ + actor.id as string, // coerce back to string from const type + { ...actor, email: null }, + ]), + ); + + const enriched = logsArray.map((log) => ({ + ...log, + token: log.token_id ? tokenMap.get(log.token_id) ?? null : null, + user: log.user_id + ? webhookRequestActorsMap.get(log.user_id) ?? + userMap.get(log.user_id) ?? + null + : null, + })); + + return isSingle ? enriched[0] : enriched; +} diff --git a/apps/web/lib/api-logs/get-api-log.ts b/apps/web/lib/api-logs/get-api-log.ts new file mode 100644 index 00000000000..066bb728a41 --- /dev/null +++ b/apps/web/lib/api-logs/get-api-log.ts @@ -0,0 +1,23 @@ +import { tb } from "@/lib/tinybird"; +import * as z from "zod/v4"; +import { apiLogByIdFilterSchemaTB, apiLogSchemaTB } from "./schemas"; + +type GetApiLogByIdParams = z.infer; + +export const getApiLogById = async ({ + workspaceId, + id, +}: GetApiLogByIdParams) => { + const pipe = tb.buildPipe({ + pipe: "get_api_log_by_id", + parameters: apiLogByIdFilterSchemaTB, + data: apiLogSchemaTB, + }); + + const logs = await pipe({ + workspaceId, + id, + }); + + return logs.data[0] || null; +}; diff --git a/apps/web/lib/api-logs/get-api-logs-count.ts b/apps/web/lib/api-logs/get-api-logs-count.ts new file mode 100644 index 00000000000..14e82e4645e --- /dev/null +++ b/apps/web/lib/api-logs/get-api-logs-count.ts @@ -0,0 +1,60 @@ +import { tb } from "@/lib/tinybird"; +import * as z from "zod/v4"; +import { + apiLogCountAggregateRowSchemaTB, + apiLogCountFilterSchemaTB, + apiLogsCountResponseSchema, + getApiLogsCountQuerySchema, +} from "./schemas"; + +type GetApiLogsCountParams = z.infer & { + workspaceId: string; + start: string; + end: string; +}; + +export async function getApiLogsCount(params: GetApiLogsCountParams) { + const { + workspaceId, + routePattern, + method, + statusCode, + tokenId, + requestId, + requestType, + start, + end, + groupBy, + } = params; + + const baseParams = { + workspaceId, + ...(groupBy && { groupBy }), + // if we're grouping by routePattern, omit routePattern filter (so all routes are returned) + ...(routePattern && groupBy !== "routePattern" && { routePattern }), + ...(method && { method }), + ...(statusCode && { statusCode }), + ...(tokenId && { tokenId }), + ...(requestId && { requestId }), + ...(requestType && { requestType }), + ...(start && { start }), + ...(end && { end }), + }; + + const pipe = tb.buildPipe({ + pipe: "get_api_logs_count", + parameters: apiLogCountFilterSchemaTB, + data: z.any(), + }); + + const result = await pipe(baseParams); + + if (groupBy === "routePattern") { + return apiLogsCountResponseSchema.parse(result.data); + } + + const aggregate = apiLogCountAggregateRowSchemaTB.safeParse(result.data[0]); + const count = aggregate.success ? aggregate.data.count : 0; + + return apiLogsCountResponseSchema.parse([{ routePattern: "all", count }]); +} diff --git a/apps/web/lib/api-logs/get-api-logs.ts b/apps/web/lib/api-logs/get-api-logs.ts new file mode 100644 index 00000000000..4d2471779c3 --- /dev/null +++ b/apps/web/lib/api-logs/get-api-logs.ts @@ -0,0 +1,49 @@ +import { tb } from "@/lib/tinybird"; +import * as z from "zod/v4"; +import { + apiLogFilterSchemaTB, + apiLogSchemaTB, + getApiLogsQuerySchema, +} from "./schemas"; + +type GetApiLogsParams = z.infer & { + workspaceId: string; + start: string; + end: string; +}; + +export const getApiLogs = async ({ + workspaceId, + routePattern, + method, + statusCode, + tokenId, + requestId, + requestType, + start, + end, + page, + pageSize, +}: GetApiLogsParams) => { + const pipe = tb.buildPipe({ + pipe: "get_api_logs", + parameters: apiLogFilterSchemaTB, + data: apiLogSchemaTB, + }); + + const logs = await pipe({ + workspaceId, + ...(routePattern && { routePattern }), + ...(method && { method }), + ...(statusCode && { statusCode }), + ...(tokenId && { tokenId }), + ...(requestId && { requestId }), + ...(requestType && { requestType }), + ...(start && { start }), + ...(end && { end }), + ...(page && { offset: (page - 1) * pageSize }), + ...(pageSize && { limit: pageSize }), + }); + + return logs.data; +}; diff --git a/apps/web/lib/api-logs/record-api-log.ts b/apps/web/lib/api-logs/record-api-log.ts new file mode 100644 index 00000000000..b6fafd0df7b --- /dev/null +++ b/apps/web/lib/api-logs/record-api-log.ts @@ -0,0 +1,88 @@ +import { tb } from "@/lib/tinybird"; +import { log } from "@dub/utils"; +import * as z from "zod/v4"; +import { createId } from "../api/create-id"; +import { RequestType } from "../types"; +import { apiLogSchemaTB } from "./schemas"; + +const ingestionApiLogSchemaTB = apiLogSchemaTB.extend({ + workspace_id: z.string(), +}); + +type ApiLogInput = z.infer; + +type RecordApiLogParams = { + workspaceId: string; + method: string; + path: string; + routePattern: string; + statusCode: number; + duration: number; + userAgent: string | null; + requestBody: unknown; + responseBody: unknown; + tokenId: string | null; + userId: string | null; + requestType: RequestType; +}; + +const recordApiLogTB = tb.buildIngestEndpoint({ + datasource: "dub_api_logs", + event: ingestionApiLogSchemaTB, + wait: true, +}); + +export const recordApiLog = async ({ + workspaceId, + method, + path, + routePattern, + statusCode, + duration, + userAgent, + requestBody, + responseBody, + tokenId, + userId, + requestType, +}: RecordApiLogParams) => { + const apiLog: ApiLogInput = { + id: createId({ prefix: "req_" }), + timestamp: new Date().toISOString(), + workspace_id: workspaceId, + method, + path: path.replace("/api/", "/"), // remove the /api/ prefix from the path + route_pattern: routePattern, + status_code: statusCode, + duration, + user_agent: userAgent ?? "", + request_body: JSON.stringify(requestBody), + response_body: JSON.stringify(responseBody), + token_id: tokenId ?? "", + user_id: userId ?? "", + request_type: requestType, + }; + + const maxRetries = 3; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await recordApiLogTB(apiLog); + } catch (error) { + if (attempt < maxRetries) { + await new Promise((resolve) => + setTimeout(resolve, 100 * Math.pow(2, attempt)), + ); + continue; + } + + console.error("Failed to record API log", error, JSON.stringify(apiLog)); + + await log({ + message: "Failed to record API log. See logs for more details.", + type: "errors", + mention: true, + }); + } + } +}; diff --git a/apps/web/lib/api-logs/schemas.ts b/apps/web/lib/api-logs/schemas.ts new file mode 100644 index 00000000000..9d52b8ccee9 --- /dev/null +++ b/apps/web/lib/api-logs/schemas.ts @@ -0,0 +1,99 @@ +import { getPaginationQuerySchema } from "@/lib/zod/schemas/misc"; +import { tokenSchema } from "@/lib/zod/schemas/token"; +import { UserSchema } from "@/lib/zod/schemas/users"; +import * as z from "zod/v4"; +import { API_LOGS_MAX_PAGE_SIZE } from "./constants"; + +export const requestTypeSchema = z.enum(["api", "webhook"]); + +export const apiLogSchemaTB = z.object({ + id: z.string(), + timestamp: z.string(), + method: z.string(), + path: z.string(), + route_pattern: z.string(), + status_code: z.number(), + duration: z.number(), + user_agent: z.string(), + request_body: z.string(), + response_body: z.string(), + token_id: z.string(), + user_id: z.string(), + request_type: requestTypeSchema, +}); + +// Schema for query filter params +export const apiLogFilterSchemaTB = z.object({ + workspaceId: z.string(), + routePattern: z.string().optional(), + method: z.string().optional(), + statusCode: z.number().optional(), + tokenId: z.string().optional(), + requestId: z.string().optional(), + requestType: requestTypeSchema.optional(), + start: z.string().optional(), + end: z.string().optional(), + limit: z.number().optional(), + offset: z.number().optional(), +}); + +export const apiLogCountFilterSchemaTB = apiLogFilterSchemaTB + .omit({ + limit: true, + offset: true, + }) + .extend({ + groupBy: z.enum(["routePattern"]).optional(), + }); + +// Raw Tinybird shape for the non-grouped count node +export const apiLogCountAggregateRowSchemaTB = z.object({ + count: z.number(), +}); + +// Single row for GET /api/logs/count (aggregate uses routePattern `"all"`) +// TODO: extend this to support other groupBy values +export const apiLogCountRowSchema = z.object({ + routePattern: z.string(), + count: z.number(), +}); + +export const apiLogsCountResponseSchema = z.array(apiLogCountRowSchema); + +export const apiLogByIdFilterSchemaTB = z.object({ + workspaceId: z.string(), + id: z.string(), +}); + +// Schema for enriched API log (with resolved token and user) +export const apiLogEnrichedSchema = apiLogSchemaTB.extend({ + token: tokenSchema + .pick({ + id: true, + name: true, + partialKey: true, + }) + .nullable(), + user: UserSchema.nullable(), +}); + +export const getApiLogsQuerySchema = z + .object({ + routePattern: z.string().optional(), + method: z.enum(["POST", "PATCH", "PUT", "DELETE"]).optional(), + statusCode: z.coerce.number().int().optional(), + tokenId: z.string().optional(), + requestId: z.string().optional(), + requestType: requestTypeSchema.optional(), + }) + .extend( + getPaginationQuerySchema({ + pageSize: API_LOGS_MAX_PAGE_SIZE, + }), + ); + +export const getApiLogsCountQuerySchema = getApiLogsQuerySchema + .omit({ page: true, pageSize: true }) + .extend({ + groupBy: z.enum(["routePattern"]).optional(), + }); diff --git a/apps/web/lib/api/create-id.ts b/apps/web/lib/api/create-id.ts index 1931d5c55cb..8d53ac857d9 100644 --- a/apps/web/lib/api/create-id.ts +++ b/apps/web/lib/api/create-id.ts @@ -43,6 +43,7 @@ const prefixes = [ "frg_", // fraud event group "ref_", // referral "pb_", // partner postback + "req_", // api request log ] as const; // ULID uses base32 encoding diff --git a/apps/web/lib/auth/token-cache.ts b/apps/web/lib/auth/token-cache.ts index 9375c2d8b08..7f24886615f 100644 --- a/apps/web/lib/auth/token-cache.ts +++ b/apps/web/lib/auth/token-cache.ts @@ -5,6 +5,7 @@ const CACHE_EXPIRATION = 60 * 60 * 24; // 24 hours const CACHE_KEY_PREFIX = "dubTokenCache"; const tokenCacheItemSchema = z.object({ + id: z.string().nullish(), expires: z.date().nullish(), user: z.object({ id: z.string(), diff --git a/apps/web/lib/auth/workspace.ts b/apps/web/lib/auth/workspace.ts index 11ff92f775e..a476deb2e9e 100644 --- a/apps/web/lib/auth/workspace.ts +++ b/apps/web/lib/auth/workspace.ts @@ -6,6 +6,7 @@ import { WorkspaceRole } from "@dub/prisma/client"; import { API_DOMAIN, getSearchParams } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { headers } from "next/headers"; +import { captureRequestLog } from "../api-logs/capture-request-log"; import { getRatelimitForPlan } from "../api/get-ratelimit-for-plan"; import { PermissionAction, @@ -86,6 +87,7 @@ export const withWorkspace = ( // Clone the request early so handlers can read the body without cloning // Keep the original for withAxiomBodyLog to read in onSuccess const clonedReq = req.clone(); + const reqForLog = clonedReq.clone(); const params = (await initialParams) || {}; const searchParams = getSearchParams(req.url); @@ -94,6 +96,11 @@ export const withWorkspace = ( let requestHeaders = await headers(); let responseHeaders = new Headers(); let workspace: WorkspaceWithUsers | undefined; + let session: Session | undefined; + let token: TokenCacheItem | null = null; + + const startTime = Date.now(); + const url = new URL(req.url || "", API_DOMAIN); try { const authorizationHeader = requestHeaders.get("Authorization"); @@ -108,13 +115,9 @@ export const withWorkspace = ( apiKey = authorizationHeader.replace("Bearer ", ""); } - const url = new URL(req.url || "", API_DOMAIN); - - let session: Session | undefined; let workspaceId: string | undefined; let workspaceSlug: string | undefined; let permissions: PermissionAction[] = []; - let token: TokenCacheItem | null = null; const isRestrictedToken = apiKey?.startsWith("dub_"); const idOrSlug = @@ -183,6 +186,7 @@ export const withWorkspace = ( hashedKey, }, select: { + id: true, expires: true, ...(isRestrictedToken && { scopes: true, @@ -463,7 +467,7 @@ export const withWorkspace = ( }); } - return await handler({ + const response = await handler({ req: clonedReq, params, searchParams, @@ -473,6 +477,21 @@ export const withWorkspace = ( permissions, token, }); + + if (workspace) { + captureRequestLog({ + req: reqForLog, + response, + workspace, + session, + token, + url, + requestHeaders, + startTime, + }); + } + + return response; } catch (error) { // Log the conversion events for debugging purposes waitUntil( @@ -489,7 +508,25 @@ export const withWorkspace = ( })(), ); - return handleAndReturnErrorResponse(error, responseHeaders); + const errorResponse = handleAndReturnErrorResponse( + error, + responseHeaders, + ); + + if (workspace) { + captureRequestLog({ + req: reqForLog, + response: errorResponse, + workspace, + session, + token, + url, + requestHeaders, + startTime, + }); + } + + return errorResponse; } }, ); diff --git a/apps/web/lib/integrations/appsflyer/schema.ts b/apps/web/lib/integrations/appsflyer/schema.ts index 011dba0e322..ef9a41e017e 100644 --- a/apps/web/lib/integrations/appsflyer/schema.ts +++ b/apps/web/lib/integrations/appsflyer/schema.ts @@ -2,20 +2,18 @@ import * as z from "zod/v4"; import { APPSFLYER_MACRO_VALUES } from "./constants"; import { isValidAppsFlyerMacroTemplate } from "./macro-template"; -export const appsFlyerMacroExactValueSchema = z.string().refine( - (v) => APPSFLYER_MACRO_VALUES.includes(v), - { +export const appsFlyerMacroExactValueSchema = z + .string() + .refine((v) => APPSFLYER_MACRO_VALUES.includes(v), { message: `Value must be one of: ${APPSFLYER_MACRO_VALUES.join(", ")}`, - }, -); + }); /** Free-form value; every `{{...}}` token must be a known macro. */ -export const appsFlyerMacroTemplateValueSchema = z.string().refine( - (v) => isValidAppsFlyerMacroTemplate(v), - { +export const appsFlyerMacroTemplateValueSchema = z + .string() + .refine((v) => isValidAppsFlyerMacroTemplate(v), { message: `Invalid macro in value. Use only: ${APPSFLYER_MACRO_VALUES.join(", ")}`, - }, -); + }); export const appsFlyerRequiredParameterSchema = z.object({ key: z.string().min(1), diff --git a/apps/web/lib/swr/use-api-logs-count.ts b/apps/web/lib/swr/use-api-logs-count.ts new file mode 100644 index 00000000000..25d89b42844 --- /dev/null +++ b/apps/web/lib/swr/use-api-logs-count.ts @@ -0,0 +1,47 @@ +import type { ApiLogsCountRow } from "@/lib/types"; +import { useRouterStuff } from "@dub/ui"; +import { fetcher } from "@dub/utils"; +import useSWR from "swr"; +import useWorkspace from "./use-workspace"; + +export function useApiLogsCount({ + groupBy, + enabled = true, +}: { + groupBy?: "routePattern"; + enabled?: boolean; +} = {}) { + const { id: workspaceId } = useWorkspace(); + const { getQueryString } = useRouterStuff(); + + const queryString = getQueryString( + { + workspaceId, + ...(groupBy && { groupBy }), + }, + { + include: [ + "method", + "statusCode", + "routePattern", + "tokenId", + "requestId", + "requestType", + ], + }, + ); + + const { data, error } = useSWR( + workspaceId && enabled ? `/api/logs/count${queryString}` : null, + fetcher, + { + keepPreviousData: true, + }, + ); + + return { + data, + error, + isLoading: !error && data === undefined, + }; +} diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts index 8ae6ede6605..216215143cd 100644 --- a/apps/web/lib/types.ts +++ b/apps/web/lib/types.ts @@ -33,6 +33,12 @@ import { } from "@dub/prisma/client"; import * as z from "zod/v4"; import { RESOURCE_COLORS } from "../ui/colors"; +import { + apiLogCountRowSchema, + apiLogEnrichedSchema, + apiLogSchemaTB, + requestTypeSchema, +} from "./api-logs/schemas"; import { PAID_TRAFFIC_PLATFORMS } from "./api/fraud/constants"; import { BOUNTY_SUBMISSION_REQUIREMENTS } from "./bounty/constants"; import { BOUNTY_SOCIAL_PLATFORMS } from "./bounty/social-content"; @@ -898,3 +904,13 @@ export type CommissionActivitySnapshot = Pick< Commission, "amount" | "earnings" | "status" >; + +export type EnrichedApiLog = z.infer; + +export type ApiLogsCountRow = z.infer; + +export type ApiLogsCountByRoutePattern = ApiLogsCountRow; + +export type RequestType = z.infer; + +export type ApiLogTB = z.infer; diff --git a/apps/web/tests/commissions/bulk-updates.test.ts b/apps/web/tests/commissions/bulk-updates.test.ts index 521ec8c316a..a029e6a0bdb 100644 --- a/apps/web/tests/commissions/bulk-updates.test.ts +++ b/apps/web/tests/commissions/bulk-updates.test.ts @@ -7,7 +7,9 @@ describe.sequential("/commissions/bulk - bulk updates", async () => { const { http } = await h.init(); const getCommissionsByStatus = async (status: string) => { - const { status: responseStatus, data } = await http.get({ + const { status: responseStatus, data } = await http.get< + CommissionResponse[] + >({ path: "/commissions", query: { status, @@ -49,7 +51,9 @@ describe.sequential("/commissions/bulk - bulk updates", async () => { }); expect(status).toEqual(404); - expect(data.error.message).toContain("One or more commissions were not found"); + expect(data.error.message).toContain( + "One or more commissions were not found", + ); }); test("PATCH /commissions/bulk - returns bad_request for paid commissions", async () => { @@ -90,7 +94,9 @@ describe.sequential("/commissions/bulk - bulk updates", async () => { const commissionIds = pendingCommissions.slice(0, 2).map((c) => c.id); - const { status, data } = await http.patch>({ + const { status, data } = await http.patch< + Array<{ id: string; status: string }> + >({ path: "/commissions/bulk", body: { commissionIds, diff --git a/apps/web/ui/layout/sidebar/app-sidebar-nav.tsx b/apps/web/ui/layout/sidebar/app-sidebar-nav.tsx index 45107100dde..6212203d096 100644 --- a/apps/web/ui/layout/sidebar/app-sidebar-nav.tsx +++ b/apps/web/ui/layout/sidebar/app-sidebar-nav.tsx @@ -37,6 +37,7 @@ import { ShieldCheck, ShieldKeyhole, Sliders, + StackY3, Tag, Trophy, UserCheck, @@ -78,11 +79,7 @@ type SidebarNavData = { partnerNetworkEnabled?: boolean; }; -const NAV_GROUPS: SidebarNavGroups = ({ - slug, - pathname, - defaultProgramId, -}) => [ +const NAV_GROUPS: SidebarNavGroups = ({ slug, pathname }) => [ { name: "Short Links", description: @@ -410,6 +407,11 @@ const NAV_AREAS: SidebarNavAreas = { icon: Key, href: `/${slug}/settings/tokens`, }, + { + name: "Logs", + icon: StackY3, + href: `/${slug}/settings/logs`, + }, { name: "Tracking", icon: MarketingTarget, diff --git a/apps/web/ui/logs/log-utils.ts b/apps/web/ui/logs/log-utils.ts new file mode 100644 index 00000000000..77032cdfd85 --- /dev/null +++ b/apps/web/ui/logs/log-utils.ts @@ -0,0 +1,5 @@ +export function getStatusCodeBadgeVariant(statusCode: number) { + if (statusCode >= 200 && statusCode < 300) return "success"; + if (statusCode >= 400 && statusCode < 500) return "error"; + return "error"; +} diff --git a/apps/web/ui/logs/logs-table.tsx b/apps/web/ui/logs/logs-table.tsx new file mode 100644 index 00000000000..8ac5775ef92 --- /dev/null +++ b/apps/web/ui/logs/logs-table.tsx @@ -0,0 +1,316 @@ +"use client"; + +import { + API_LOGS_MAX_PAGE_SIZE, + METHOD_BADGE_VARIANTS, +} from "@/lib/api-logs/constants"; +import { useApiLogsCount } from "@/lib/swr/use-api-logs-count"; +import useWorkspace from "@/lib/swr/use-workspace"; +import { EnrichedApiLog } from "@/lib/types"; +import { AnimatedEmptyState } from "@/ui/shared/animated-empty-state"; +import { FilterButtonTableRow } from "@/ui/shared/filter-button-table-row"; +import { SearchBoxPersisted } from "@/ui/shared/search-box"; +import { UserAvatar } from "@/ui/users/user-avatar"; +import { + AnimatedSizeContainer, + Filter, + StatusBadge, + Table, + TimestampTooltip, + usePagination, + useRouterStuff, + useTable, +} from "@dub/ui"; +import { StackY3 } from "@dub/ui/icons"; +import { fetcher } from "@dub/utils"; +import { Cell, Row } from "@tanstack/react-table"; +import { useRouter } from "next/navigation"; +import { useMemo } from "react"; +import useSWR from "swr"; +import { getStatusCodeBadgeVariant } from "./log-utils"; +import { useLogFilters } from "./use-log-filters"; + +export function LogsTable() { + const router = useRouter(); + const { id: workspaceId, slug } = useWorkspace(); + const { searchParamsObj } = useRouterStuff(); + + const { + filters, + activeFilters, + onSelect, + onRemove, + onRemoveAll, + searchQuery, + setSelectedFilter, + } = useLogFilters(); + + const { pagination, setPagination } = usePagination(API_LOGS_MAX_PAGE_SIZE); + + const logsQuery = searchParamsObj.page + ? `${searchQuery}&page=${searchParamsObj.page}` + : searchQuery; + + const { + data: logs, + error, + isLoading, + } = useSWR( + workspaceId && `/api/logs?${logsQuery}`, + fetcher, + { + keepPreviousData: true, + }, + ); + + const { data: logsCountRows } = useApiLogsCount(); + + const logsCount = + logsCountRows?.find((row) => row.routePattern === "all")?.count ?? 0; + + const isFiltered = activeFilters.length > 0; + + const columns = useMemo( + () => [ + { + id: "path", + header: "Endpoint", + cell: ({ row }: { row: Row }) => ( + + {row.original.path} + + ), + meta: { + filterParams: ({ row }: { row: Row }) => ({ + routePattern: row.original.route_pattern, + }), + }, + size: 300, + }, + { + id: "method", + header: "Method", + cell: ({ row }: { row: Row }) => ( + + {row.original.method} + + ), + meta: { + filterParams: ({ row }: { row: Row }) => ({ + method: row.original.method, + }), + }, + size: 100, + }, + { + id: "status_code", + header: "Status", + cell: ({ row }: { row: Row }) => ( + + {row.original.status_code} + + ), + meta: { + filterParams: ({ row }: { row: Row }) => ({ + statusCode: row.original.status_code, + }), + }, + size: 100, + }, + { + id: "actor", + header: "Actor", + cell: ({ row }: { row: Row }) => { + const { token, user } = row.original; + + if (token) { + return ( + + {token.partialKey} + + ); + } + + if (user) { + return ( +
+ + + {user.name || user.email} + +
+ ); + } + + return ; + }, + size: 200, + }, + { + id: "duration", + header: "Duration", + cell: ({ row }: { row: Row }) => ( + + {row.original.duration}ms + + ), + size: 100, + }, + { + id: "timestamp", + header: "Time", + cell: ({ row }: { row: Row }) => ( + + + {new Date(row.original.timestamp).toLocaleString("en-us", { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + second: "2-digit", + })} + + + ), + size: 180, + }, + ], + [], + ); + + const getLogUrl = (row: Row) => + `/${slug}/settings/logs/${row.original.id}`; + + const { table, ...tableProps } = useTable({ + data: logs || [], + columns, + pagination, + onPaginationChange: setPagination, + onRowClick: (row, e) => { + const url = getLogUrl(row); + if (e.metaKey || e.ctrlKey) { + window.open(url, "_blank"); + } else { + router.push(url); + } + }, + onRowAuxClick: (row) => window.open(getLogUrl(row), "_blank"), + cellRight: (cell: Cell) => { + const meta = cell.column.columnDef.meta as + | { + filterParams?: ( + cell: Cell, + ) => Record; + } + | undefined; + + return ( + meta?.filterParams && ( + + ) + ); + }, + thClassName: "border-l-0", + tdClassName: "border-l-0", + resourceName: (p) => `log${p ? "s" : ""}`, + rowCount: logsCount || 0, + loading: isLoading, + error: error ? "Failed to load API logs" : undefined, + }); + + return ( +
+ + {logs?.length !== 0 ? ( + + ) : ( + ( + <> + +
+ + )} + /> + )} +
+ ); +} + +function LogsFilters({ + filters, + activeFilters, + onSelect, + onRemove, + onRemoveAll, + setSelectedFilter, +}: { + filters: any[]; + activeFilters: any[]; + onSelect: (key: string, value: any) => void; + onRemove: (key: string) => void; + onRemoveAll: () => void; + setSelectedFilter: (f: string | null) => void; +}) { + return ( +
+
+ + +
+ +
+ {activeFilters.length > 0 && ( +
+ +
+ )} +
+
+
+ ); +} diff --git a/apps/web/ui/logs/use-log-filters.ts b/apps/web/ui/logs/use-log-filters.ts new file mode 100644 index 00000000000..d31d6fe5549 --- /dev/null +++ b/apps/web/ui/logs/use-log-filters.ts @@ -0,0 +1,167 @@ +"use client"; + +import { + HTTP_METHODS, + HTTP_STATUS_CODES, + REQUEST_TYPES, +} from "@/lib/api-logs/constants"; +import { useApiLogsCount } from "@/lib/swr/use-api-logs-count"; +import useWorkspace from "@/lib/swr/use-workspace"; +import { TokenProps } from "@/lib/types"; +import { useRouterStuff } from "@dub/ui"; +import { + ArrowsOppositeDirectionX, + CircleCheck, + Globe, + Key, + Webhook, +} from "@dub/ui/icons"; +import { cn, fetcher, nFormatter } from "@dub/utils"; +import { createElement, useCallback, useMemo, useState } from "react"; +import useSWR from "swr"; + +export function useLogFilters() { + const { searchParamsObj, queryParams } = useRouterStuff(); + const { id: workspaceId } = useWorkspace(); + const [selectedFilter, setSelectedFilter] = useState(null); + + const { data: tokens } = useSWR( + selectedFilter === "tokenId" + ? `/api/tokens?workspaceId=${workspaceId}` + : null, + fetcher, + ); + + const activeFilters = useMemo(() => { + const { method, statusCode, routePattern, tokenId, requestType } = + searchParamsObj; + + return [ + ...(method ? [{ key: "method", value: method }] : []), + ...(statusCode ? [{ key: "statusCode", value: statusCode }] : []), + ...(routePattern ? [{ key: "routePattern", value: routePattern }] : []), + ...(tokenId ? [{ key: "tokenId", value: tokenId }] : []), + ...(requestType ? [{ key: "requestType", value: requestType }] : []), + ]; + }, [searchParamsObj]); + + const { data: routePatterns } = useApiLogsCount({ + groupBy: "routePattern", + enabled: + selectedFilter === "routePattern" || searchParamsObj.routePattern + ? true + : false, + }); + + const filters = useMemo( + () => [ + { + key: "statusCode", + icon: CircleCheck, + label: "Status", + options: HTTP_STATUS_CODES.map(({ value, label }) => { + const icon = createElement(CircleCheck, { + className: cn( + "h-4 w-4", + value >= 200 && value < 300 ? "text-green-600" : "text-red-600", + ), + }); + + return { + value, + label, + icon, + }; + }), + }, + { + key: "routePattern", + icon: Globe, + label: "Endpoint", + options: routePatterns?.map(({ routePattern, count }) => ({ + value: routePattern, + label: routePattern, + right: nFormatter(count, { full: true }), + })), + }, + { + key: "method", + icon: ArrowsOppositeDirectionX, + label: "Method", + options: HTTP_METHODS.map((m) => ({ + value: m, + label: m, + })), + }, + { + key: "tokenId", + icon: Key, + label: "API Key", + options: (tokens || []).map(({ id, name, partialKey }) => ({ + value: id, + label: `${name} (${partialKey})`, + })), + }, + { + key: "requestType", + icon: Webhook, + label: "Request Type", + options: REQUEST_TYPES.map(({ value, label }) => ({ + value, + label, + })), + }, + ], + [tokens, routePatterns], + ); + + const onSelect = useCallback( + (key: string, value: any) => + queryParams({ + set: { [key]: value }, + del: "page", + }), + [queryParams], + ); + + const onRemove = useCallback( + (key: string) => + queryParams({ + del: [key, "page"], + }), + [queryParams], + ); + + const onRemoveAll = useCallback( + () => + queryParams({ + del: ["method", "statusCode", "routePattern", "tokenId", "requestType"], + }), + [queryParams], + ); + + const searchQuery = useMemo(() => { + const params: Record = { + workspaceId: workspaceId || "", + ...Object.fromEntries( + activeFilters.map(({ key, value }) => [key, value]), + ), + }; + + if (searchParamsObj.requestId) { + params.requestId = searchParamsObj.requestId; + } + + return new URLSearchParams(params).toString(); + }, [activeFilters, workspaceId, searchParamsObj.requestId]); + + return { + filters, + activeFilters, + onSelect, + onRemove, + onRemoveAll, + searchQuery, + setSelectedFilter, + }; +} diff --git a/apps/web/ui/modals/reject-partner-application-modal.tsx b/apps/web/ui/modals/reject-partner-application-modal.tsx index ba67ac154f3..92e4e5abc20 100644 --- a/apps/web/ui/modals/reject-partner-application-modal.tsx +++ b/apps/web/ui/modals/reject-partner-application-modal.tsx @@ -184,7 +184,8 @@ export function RejectPartnerApplicationModal({ Additional notes (optional) - {rejectionNote.length}/{PROGRAM_APPLICATION_REJECTION_NOTE_MAX_LENGTH} + {rejectionNote.length}/ + {PROGRAM_APPLICATION_REJECTION_NOTE_MAX_LENGTH}
diff --git a/apps/web/ui/partners/partner-application-details.tsx b/apps/web/ui/partners/partner-application-details.tsx index 40107d9e15d..a0254ad3c9c 100644 --- a/apps/web/ui/partners/partner-application-details.tsx +++ b/apps/web/ui/partners/partner-application-details.tsx @@ -338,7 +338,7 @@ function PartnerApplicationDetailsSkeleton() { {[...Array(3)].map((_, idx) => (
diff --git a/apps/web/ui/shared/search-box.tsx b/apps/web/ui/shared/search-box.tsx index fdf5b7cd6d2..dd97cbd90df 100644 --- a/apps/web/ui/shared/search-box.tsx +++ b/apps/web/ui/shared/search-box.tsx @@ -124,7 +124,7 @@ export function SearchBoxPersisted({ queryParams( debouncedValue === "" ? { del: [urlParam, "page"] } - : { set: { search: debouncedValue }, del: "page" }, + : { set: { [urlParam]: debouncedValue }, del: "page" }, ); }, [debouncedValue]); diff --git a/packages/tinybird/datasources/dub_api_logs.datasource b/packages/tinybird/datasources/dub_api_logs.datasource new file mode 100644 index 00000000000..53fb77bedcf --- /dev/null +++ b/packages/tinybird/datasources/dub_api_logs.datasource @@ -0,0 +1,22 @@ +TOKEN "dub_tinybird_token" APPEND + +SCHEMA > + `id` String `json:$.id`, + `timestamp` DateTime64(3) `json:$.timestamp`, + `workspace_id` String `json:$.workspace_id`, + `method` LowCardinality(String) `json:$.method`, + `path` String `json:$.path`, + `route_pattern` LowCardinality(String) `json:$.route_pattern`, + `status_code` UInt16 `json:$.status_code`, + `duration` UInt32 `json:$.duration`, + `user_agent` String `json:$.user_agent`, + `request_body` String `json:$.request_body`, + `response_body` String `json:$.response_body`, + `token_id` String `json:$.token_id`, + `user_id` String `json:$.user_id`, + `request_type` LowCardinality(String) `json:$.request_type` + +ENGINE "MergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(timestamp)" +ENGINE_SORTING_KEY "workspace_id, timestamp" +ENGINE_TTL "toDateTime(timestamp) + toIntervalDay(90)" diff --git a/packages/tinybird/datasources/dub_api_logs_id.datasource b/packages/tinybird/datasources/dub_api_logs_id.datasource new file mode 100644 index 00000000000..b5e8342a29b --- /dev/null +++ b/packages/tinybird/datasources/dub_api_logs_id.datasource @@ -0,0 +1,23 @@ +# Data Source created from Pipe 'dub_api_logs_id_pipe' + +SCHEMA > + `id` String, + `timestamp` DateTime64(3), + `workspace_id` String, + `method` LowCardinality(String), + `path` String, + `route_pattern` LowCardinality(String), + `status_code` UInt16, + `duration` UInt32, + `user_agent` String, + `request_body` String, + `response_body` String, + `token_id` String, + `user_id` String, + `request_type` LowCardinality(String) + +ENGINE "MergeTree" +ENGINE_PARTITION_KEY "tuple()" +ENGINE_SORTING_KEY "id" +ENGINE_SETTINGS "index_granularity = 256" +ENGINE_TTL "toDateTime(timestamp) + toIntervalDay(90)" diff --git a/packages/tinybird/pipes/dub_api_logs_id_pipe.pipe b/packages/tinybird/pipes/dub_api_logs_id_pipe.pipe new file mode 100644 index 00000000000..670b0a96501 --- /dev/null +++ b/packages/tinybird/pipes/dub_api_logs_id_pipe.pipe @@ -0,0 +1,11 @@ +TAGS "Dub MV Pipes" + +NODE mv +SQL > + + SELECT * FROM dub_api_logs + +TYPE materialized +DATASOURCE dub_api_logs_id + + diff --git a/packages/tinybird/pipes/get_api_log_by_id.pipe b/packages/tinybird/pipes/get_api_log_by_id.pipe new file mode 100644 index 00000000000..408e796663b --- /dev/null +++ b/packages/tinybird/pipes/get_api_log_by_id.pipe @@ -0,0 +1,22 @@ +NODE endpoint +SQL > + % + SELECT + id, + timestamp, + method, + path, + route_pattern, + status_code, + duration, + user_agent, + request_body, + response_body, + token_id, + user_id, + request_type + FROM dub_api_logs_id + WHERE + id = {{ String(id) }} + AND workspace_id = {{ String(workspaceId) }} + LIMIT 1 diff --git a/packages/tinybird/pipes/get_api_logs.pipe b/packages/tinybird/pipes/get_api_logs.pipe new file mode 100644 index 00000000000..44856eac7aa --- /dev/null +++ b/packages/tinybird/pipes/get_api_logs.pipe @@ -0,0 +1,33 @@ +NODE endpoint +SQL > + % + SELECT + id, + timestamp, + method, + path, + route_pattern, + status_code, + duration, + user_agent, + request_body, + response_body, + token_id, + user_id, + request_type + FROM dub_api_logs + WHERE + workspace_id = {{ String(workspaceId) }} + {% if defined(start) and defined(end) %} + AND timestamp >= {{ DateTime(start, '2024-06-01 00:00:00') }} + AND timestamp < {{ DateTime(end, '2024-06-07 00:00:00') }} + {% end %} + {% if defined(routePattern) %} AND route_pattern = {{ String(routePattern) }} {% end %} + {% if defined(method) %} AND method = {{ String(method) }} {% end %} + {% if defined(statusCode) %} AND status_code = {{ UInt16(statusCode) }} {% end %} + {% if defined(tokenId) %} AND token_id = {{ String(tokenId) }} {% end %} + {% if defined(requestId) %} AND id = {{ String(requestId) }} {% end %} + {% if defined(requestType) %} AND request_type = {{ String(requestType) }} {% end %} + ORDER BY timestamp DESC + LIMIT {{ Int32(limit, 100) }} + OFFSET {{ Int32(offset, 0) }} diff --git a/packages/tinybird/pipes/get_api_logs_count.pipe b/packages/tinybird/pipes/get_api_logs_count.pipe new file mode 100644 index 00000000000..67f50c44a9f --- /dev/null +++ b/packages/tinybird/pipes/get_api_logs_count.pipe @@ -0,0 +1,51 @@ +NODE count +SQL > + % + SELECT + count() as count + FROM dub_api_logs + WHERE + workspace_id = {{ String(workspaceId) }} + {% if defined(start) and defined(end) %} + AND timestamp >= {{ DateTime(start, '2024-06-01 00:00:00') }} + AND timestamp < {{ DateTime(end, '2024-06-07 00:00:00') }} + {% end %} + {% if defined(routePattern) %} AND route_pattern = {{ String(routePattern) }} {% end %} + {% if defined(method) %} AND method = {{ String(method) }} {% end %} + {% if defined(statusCode) %} AND status_code = {{ UInt16(statusCode) }} {% end %} + {% if defined(tokenId) %} AND token_id = {{ String(tokenId) }} {% end %} + {% if defined(requestId) %} AND id = {{ String(requestId) }} {% end %} + {% if defined(requestType) %} AND request_type = {{ String(requestType) }} {% end %} + +NODE count_by_route_pattern +SQL > + % + SELECT + route_pattern as routePattern, + count() as count + FROM dub_api_logs + WHERE + workspace_id = {{ String(workspaceId) }} + {% if defined(start) and defined(end) %} + AND timestamp >= {{ DateTime(start, '2024-06-01 00:00:00') }} + AND timestamp < {{ DateTime(end, '2024-06-07 00:00:00') }} + {% end %} + {% if defined(routePattern) %} AND route_pattern = {{ String(routePattern) }} {% end %} + {% if defined(method) %} AND method = {{ String(method) }} {% end %} + {% if defined(statusCode) %} AND status_code = {{ UInt16(statusCode) }} {% end %} + {% if defined(tokenId) %} AND token_id = {{ String(tokenId) }} {% end %} + {% if defined(requestId) %} AND id = {{ String(requestId) }} {% end %} + {% if defined(requestType) %} AND request_type = {{ String(requestType) }} {% end %} + AND route_pattern != '' + GROUP BY route_pattern + ORDER BY count DESC + LIMIT 100 + +NODE endpoint +SQL > + % + SELECT * + FROM + {% if defined(groupBy) and groupBy == 'routePattern' %} count_by_route_pattern + {% else %} count + {% end %} diff --git a/packages/ui/src/hooks/use-router-stuff.ts b/packages/ui/src/hooks/use-router-stuff.ts index 72a58d7effe..a532d3e4cb5 100644 --- a/packages/ui/src/hooks/use-router-stuff.ts +++ b/packages/ui/src/hooks/use-router-stuff.ts @@ -88,10 +88,7 @@ export function useRouterStuff() { if (getNewPath) return newPath; // Nested overflow container scroll is not preserved by Next's `scroll: false` (window-only). - if ( - scroll === false && - typeof document !== "undefined" - ) { + if (scroll === false && typeof document !== "undefined") { const el = document.getElementById(DUB_DASHBOARD_MAIN_SCROLL_ID); if (el) pendingDashboardScrollTop = el.scrollTop; } diff --git a/packages/ui/src/icons/index.tsx b/packages/ui/src/icons/index.tsx index f045ceeec04..393e558b1d2 100644 --- a/packages/ui/src/icons/index.tsx +++ b/packages/ui/src/icons/index.tsx @@ -25,8 +25,8 @@ export * from "./photo"; export * from "./sort-order"; export * from "./success"; export * from "./tick"; -export * from "./verified-badge"; export * from "./user-clock"; +export * from "./verified-badge"; // loaders export * from "./loading-circle"; diff --git a/packages/ui/src/icons/nucleo/index.ts b/packages/ui/src/icons/nucleo/index.ts index eacb7be190c..37225059e23 100644 --- a/packages/ui/src/icons/nucleo/index.ts +++ b/packages/ui/src/icons/nucleo/index.ts @@ -221,6 +221,7 @@ export * from "./square-layout-grid5"; export * from "./square-layout-grid6"; export * from "./square-user-sparkle2"; export * from "./square-xmark"; +export * from "./stack-y-3"; export * from "./star"; export * from "./star-fill"; export * from "./stars2"; diff --git a/packages/ui/src/icons/nucleo/stack-y-3.tsx b/packages/ui/src/icons/nucleo/stack-y-3.tsx new file mode 100644 index 00000000000..ff515029dfc --- /dev/null +++ b/packages/ui/src/icons/nucleo/stack-y-3.tsx @@ -0,0 +1,36 @@ +import { SVGProps } from "react"; + +export function StackY3(props: SVGProps) { + return ( + + + + + + ); +}