From 3fc92de5fcd82b14198f91b0e72c3d8e16403d3e Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 9 Apr 2026 14:17:21 +0545 Subject: [PATCH 1/3] feat(OUT-3542): add Sentry context tags, breadcrumbs, and error categorization Enrich Sentry error reports with structured context for easier debugging: - Add portalId, workspaceId, entityType, eventType, errorCategory tags - Add sync breadcrumbs at key decision points (invoice/customer/product/payment flows) - Expand FailedRecordCategoryType with RATE_LIMIT, VALIDATION, QB_API_ERROR, MAPPING_NOT_FOUND - Enhance error source tracking (intuit/copilot) in getMessageAndCodeFromError - Isolate Sentry scope per portal in Trigger.dev cron flow to prevent breadcrumb bleed - Fix ANSI color codes leaking into Vercel/New Relic logs Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/core/types/log.ts | 4 + src/app/api/core/utils/withErrorHandler.ts | 24 +- src/app/api/quickbooks/cron/cron.service.ts | 46 +- .../quickbooks/customer/customer.service.ts | 8 + .../api/quickbooks/invoice/invoice.service.ts | 21 + .../api/quickbooks/payment/payment.service.ts | 9 + .../api/quickbooks/product/product.service.ts | 11 + src/app/api/quickbooks/sync/sync.service.ts | 11 +- .../quickbooks/webhook/webhook.controller.ts | 4 + .../api/quickbooks/webhook/webhook.service.ts | 5 + ...70000_add_new_failed_record_categories.sql | 4 + .../meta/20260409070000_snapshot.json | 986 ++++++++++++++++++ src/db/migrations/meta/_journal.json | 7 + src/utils/error.ts | 37 +- src/utils/logger.ts | 6 +- src/utils/sentry.ts | 13 + src/utils/synclog.ts | 15 + 17 files changed, 1176 insertions(+), 35 deletions(-) create mode 100644 src/db/migrations/20260409070000_add_new_failed_record_categories.sql create mode 100644 src/db/migrations/meta/20260409070000_snapshot.json create mode 100644 src/utils/sentry.ts diff --git a/src/app/api/core/types/log.ts b/src/app/api/core/types/log.ts index 0ff23eff..1e1c6ce3 100644 --- a/src/app/api/core/types/log.ts +++ b/src/app/api/core/types/log.ts @@ -30,5 +30,9 @@ export enum EventType { export enum FailedRecordCategoryType { AUTH = 'auth', ACCOUNT = 'account', + RATE_LIMIT = 'rate_limit', + VALIDATION = 'validation', + QB_API_ERROR = 'qb_api_error', + MAPPING_NOT_FOUND = 'mapping_not_found', OTHERS = 'others', } diff --git a/src/app/api/core/utils/withErrorHandler.ts b/src/app/api/core/utils/withErrorHandler.ts index 838b8bf5..27c65f00 100644 --- a/src/app/api/core/utils/withErrorHandler.ts +++ b/src/app/api/core/utils/withErrorHandler.ts @@ -4,12 +4,14 @@ import { StatusableError, } from '@/type/CopilotApiError' import APIError from '@/app/api/core/exceptions/api' +import { FailedRecordCategoryType } from '@/app/api/core/types/log' import httpStatus from 'http-status' import { NextRequest, NextResponse } from 'next/server' import { ZodError, ZodFormattedError } from 'zod' import { isAxiosError } from '@/app/api/core/exceptions/custom' import * as Sentry from '@sentry/nextjs' -import { RetryableError } from '@/utils/error' +import { getMessageAndCodeFromError, RetryableError } from '@/utils/error' +import { getCategory } from '@/utils/synclog' type RequestHandler = (req: NextRequest, params: any) => Promise @@ -74,13 +76,27 @@ export const withErrorHandler = (handler: RequestHandler): RequestHandler => { status = error.response.status } - // if error is from Copilot or Intuit API (API error), then send the error message to Sentry - if ( + // Send categorized errors to Sentry with context tags + if (error instanceof ZodError) { + Sentry.withScope((scope) => { + scope.setTag('errorCategory', FailedRecordCategoryType.VALIDATION) + Sentry.captureException(error) + }) + } else if ( error instanceof APIError || error instanceof CopilotApiError || isAxiosError(error) ) { - Sentry.captureException(error) + const errorWithCode = getMessageAndCodeFromError(error) + const category = getCategory(errorWithCode) + + Sentry.withScope((scope) => { + scope.setTag('errorCategory', category) + if (errorWithCode.source) { + scope.setTag('errorSource', errorWithCode.source) + } + Sentry.captureException(error) + }) } return NextResponse.json({ error: message, errors }, { status }) diff --git a/src/app/api/quickbooks/cron/cron.service.ts b/src/app/api/quickbooks/cron/cron.service.ts index dc3b8be2..3c159537 100644 --- a/src/app/api/quickbooks/cron/cron.service.ts +++ b/src/app/api/quickbooks/cron/cron.service.ts @@ -10,6 +10,7 @@ import { getAllActivePortalConnections } from '@/db/service/token.service' import { CopilotAPI } from '@/utils/copilotAPI' import { encodePayload } from '@/utils/crypto' import CustomLogger from '@/utils/logger' +import * as Sentry from '@sentry/nextjs' import dayjs from 'dayjs' import { and, eq, lt } from 'drizzle-orm' @@ -18,29 +19,34 @@ export default class CronService { workspaceId: string, qbConnectionTokens: QBConnectionProperties, ) { - const payload = { - workspaceId, - } - const token = encodePayload(copilotAPIKey, payload) + return await Sentry.withScope(async (scope) => { + scope.setTag('portalId', workspaceId) + scope.setTag('workspaceId', workspaceId) - // check if token is valid or not - const copilot = new CopilotAPI(token) - const tokenPayload = await copilot.getTokenPayload() - CustomLogger.info({ - obj: { copilotApiCronToken: token, tokenPayload }, - message: - 'CronService#_scheduleSinglePortal | Copilot API token and payload', - }) - if (!tokenPayload) throw new APIError(500, 'Encoded token is not valid') // this should trigger p-retry and re-run the function + const payload = { + workspaceId, + } + const token = encodePayload(copilotAPIKey, payload) - const user = new User(token, tokenPayload) - user.qbConnection = qbConnectionTokens - const syncService = new SyncService(user) + // check if token is valid or not + const copilot = new CopilotAPI(token) + const tokenPayload = await copilot.getTokenPayload() + CustomLogger.info({ + obj: { copilotApiCronToken: token, tokenPayload }, + message: + 'CronService#_scheduleSinglePortal | Copilot API token and payload', + }) + if (!tokenPayload) throw new APIError(500, 'Encoded token is not valid') // this should trigger p-retry and re-run the function + + const user = new User(token, tokenPayload) + user.qbConnection = qbConnectionTokens + const syncService = new SyncService(user) - // TODO: update this on QB tech debt milestone - // const { suspended } = await syncService.checkAndSuspendAccount() - // if (suspended) return - return await syncService.syncFailedRecords() + // TODO: update this on QB tech debt milestone + // const { suspended } = await syncService.checkAndSuspendAccount() + // if (suspended) return + return await syncService.syncFailedRecords() + }) } async rerunFailedSync() { diff --git a/src/app/api/quickbooks/customer/customer.service.ts b/src/app/api/quickbooks/customer/customer.service.ts index 457993d8..80d2165d 100644 --- a/src/app/api/quickbooks/customer/customer.service.ts +++ b/src/app/api/quickbooks/customer/customer.service.ts @@ -15,6 +15,7 @@ import { QBCustomerCreatePayloadType } from '@/type/dto/intuitAPI.dto' import { InvoiceCreatedResponseType } from '@/type/dto/webhook.dto' import { CopilotAPI } from '@/utils/copilotAPI' import IntuitAPI from '@/utils/intuitAPI' +import { addSyncBreadcrumb } from '@/utils/sentry' import { replaceSpecialCharsForQB } from '@/utils/string' import { and, eq, isNull } from 'drizzle-orm' import httpStatus from 'http-status' @@ -365,6 +366,10 @@ export class CustomerService extends BaseService { customer = undefined } + addSyncBreadcrumb('Customer search in QBO', { + found: !!customer ? 'true' : 'false', + }) + // 3. if not found, create a new client in the QB if (!customer) { console.info( @@ -390,6 +395,9 @@ export class CustomerService extends BaseService { const customerRes = await intuitApiService.createCustomer(customerPayload) customer = customerRes + addSyncBreadcrumb('Customer created in QBO', { + qbCustomerId: customer.Id, + }) console.info( `InvoiceService#WebhookInvoiceCreated | Customer created in QB with ID: ${customer.Id}.`, ) diff --git a/src/app/api/quickbooks/invoice/invoice.service.ts b/src/app/api/quickbooks/invoice/invoice.service.ts index dc2a5798..a4a53826 100644 --- a/src/app/api/quickbooks/invoice/invoice.service.ts +++ b/src/app/api/quickbooks/invoice/invoice.service.ts @@ -47,6 +47,7 @@ import { and, eq, isNull } from 'drizzle-orm' import { convert } from 'html-to-text' import httpStatus from 'http-status' import { z } from 'zod' +import { addSyncBreadcrumb } from '@/utils/sentry' import { replaceSpecialCharsForQB } from '@/utils/string' import { AccountTypeObj } from '@/constant/qbConnection' @@ -538,6 +539,10 @@ export class InvoiceService extends BaseService { qbTokenInfo: IntuitAPITokensType, ): Promise { const invoiceResource = payload.data + addSyncBreadcrumb('Invoice creation started', { + invoiceNumber: invoiceResource.number, + portalId: this.user.workspaceId, + }) // Check if the invoice with ID already exists in the db. This check is done in this function as it is also called from re-sync failed function const existingInvoice = await this.getInvoiceByNumber( @@ -575,6 +580,10 @@ export class InvoiceService extends BaseService { intuitApiService, ) + addSyncBreadcrumb('Customer resolved', { + existingMapping: !!existingCustomer ? 'true' : 'false', + }) + let customer, existingCustomerMapId = existingCustomer?.id if (!existingCustomer) { @@ -731,6 +740,9 @@ export class InvoiceService extends BaseService { } // 6. create invoice in QB + addSyncBreadcrumb('Creating invoice in QBO', { + invoiceNumber: invoiceResource.number, + }) const invoiceRes = await intuitApiService.createInvoice(qbInvoicePayload) const invoicePayload = { @@ -804,6 +816,9 @@ export class InvoiceService extends BaseService { payload: InvoiceResponseType, qbTokenInfo: IntuitAPITokensType, ): Promise { + addSyncBreadcrumb('Invoice paid flow started', { + invoiceNumber: payload.data.number, + }) // 1. check if the status of invoice is already paid in sync table const invoiceSync = await this.getInvoiceByNumber(payload.data.number, [ 'id', @@ -926,6 +941,9 @@ export class InvoiceService extends BaseService { payload: InvoiceVoidedResponse, qbTokenInfo: IntuitAPITokensType, ): Promise { + addSyncBreadcrumb('Invoice voided flow started', { + invoiceNumber: payload.number, + }) // 1. check if the status of invoice is already paid in sync table const invoiceSync = await this.getInvoiceByNumber(payload.number, [ 'id', @@ -1013,6 +1031,9 @@ export class InvoiceService extends BaseService { payload: InvoiceDeletedResponse, qbTokenInfo: IntuitAPITokensType, ): Promise { + addSyncBreadcrumb('Invoice deleted flow started', { + invoiceNumber: payload.number, + }) const syncedInvoice = await this.getInvoiceByNumber(payload.number, [ 'id', 'qbInvoiceId', diff --git a/src/app/api/quickbooks/payment/payment.service.ts b/src/app/api/quickbooks/payment/payment.service.ts index 627611ae..af20bed5 100644 --- a/src/app/api/quickbooks/payment/payment.service.ts +++ b/src/app/api/quickbooks/payment/payment.service.ts @@ -33,6 +33,7 @@ import { getDeletedAtForAuthAccountCategoryLog, getCategory, } from '@/utils/synclog' +import { addSyncBreadcrumb } from '@/utils/sentry' import dayjs from 'dayjs' import { z } from 'zod' import httpStatus from 'http-status' @@ -95,6 +96,9 @@ export class PaymentService extends BaseService { }, ): Promise { const parsedQbPayload = QBPaymentCreatePayloadSchema.parse(qbPaymentPayload) + addSyncBreadcrumb('Creating payment in QBO', { + invoiceNumber: invoiceInfo.invoiceNumber, + }) // to save error sync log when payment creation fails in QB try { const qbPaymentRes = await intuitApi.createPayment(parsedQbPayload) @@ -152,6 +156,7 @@ export class PaymentService extends BaseService { ) { const parsedPayload = QBPurchaseCreatePayloadSchema.parse(payload) + addSyncBreadcrumb('Creating expense for absorbed fees') console.info( 'PaymentService#webhookPaymentSucceeded | Creating expense for absorbed fees', ) @@ -192,6 +197,10 @@ export class PaymentService extends BaseService { invoice: InvoiceResponse | undefined, ): Promise { const paymentResource = parsedPaymentSucceedResource.data + addSyncBreadcrumb('Payment succeeded flow started', { + paymentId: paymentResource.id, + invoiceId: paymentResource.invoiceId, + }) if (!paymentResource.feeAmount) throw new APIError(httpStatus.BAD_REQUEST, 'Fee amount is not found') diff --git a/src/app/api/quickbooks/product/product.service.ts b/src/app/api/quickbooks/product/product.service.ts index f2ac6869..0ef37504 100644 --- a/src/app/api/quickbooks/product/product.service.ts +++ b/src/app/api/quickbooks/product/product.service.ts @@ -33,6 +33,7 @@ import httpStatus from 'http-status' import { QBSyncLog, QBSyncLogWithEntityType } from '@/db/schema/qbSyncLogs' import User from '@/app/api/core/models/User.model' import { SettingService } from '@/app/api/quickbooks/setting/setting.service' +import { addSyncBreadcrumb } from '@/utils/sentry' import { replaceBeforeParens, replaceSpecialCharsForQB } from '@/utils/string' import { AccountTypeObj } from '@/constant/qbConnection' import { TokenService } from '@/app/api/quickbooks/token/token.service' @@ -365,6 +366,9 @@ export class ProductService extends BaseService { qbTokenInfo: IntuitAPITokensType, ): Promise { const productResource = resource.data + addSyncBreadcrumb('Product updated flow started', { + productId: productResource.id, + }) // 01. get all the mapped product ids with qb id const mappedConditions = @@ -506,6 +510,10 @@ export class ProductService extends BaseService { qbTokenInfo: IntuitAPITokensType, ): Promise { const priceResource = resource.data + addSyncBreadcrumb('Price created flow started', { + priceId: priceResource.id, + productId: priceResource.productId, + }) const intuitApi = new IntuitAPI(qbTokenInfo) await this.db.transaction(async (tx) => { @@ -527,6 +535,9 @@ export class ProductService extends BaseService { (product) => product.priceId === priceResource.id, ).length + addSyncBreadcrumb('Product mapping check', { + alreadyMapped: productWithPriceCount ? 'true' : 'false', + }) if (productWithPriceCount && productWithPriceCount > 0) { console.info('Product already mapped with price') return diff --git a/src/app/api/quickbooks/sync/sync.service.ts b/src/app/api/quickbooks/sync/sync.service.ts index 9e3fcfa3..0fb18abd 100644 --- a/src/app/api/quickbooks/sync/sync.service.ts +++ b/src/app/api/quickbooks/sync/sync.service.ts @@ -558,10 +558,17 @@ export class SyncService extends BaseService { `SyncService#checkAndUpdateAttempt | Records exceeded max retry count. Portal Id: ${this.user.workspaceId}.`, { tags: { - key: 'exceedMaxAttempts', // can be used to search like "key:exceedMaxAttempts" + key: 'exceedMaxAttempts', + portalId: this.user.workspaceId, + entityType: log.entityType, + eventType: log.eventType, + errorCategory: log.category, }, extra: { - LogId: log.id, // shown in "Additional Data" section in Sentry + LogId: log.id, + invoiceNumber: log.invoiceNumber, + errorMessage: log.errorMessage, + attempt, }, level: 'error', }, diff --git a/src/app/api/quickbooks/webhook/webhook.controller.ts b/src/app/api/quickbooks/webhook/webhook.controller.ts index fb3d709d..ba7215cc 100644 --- a/src/app/api/quickbooks/webhook/webhook.controller.ts +++ b/src/app/api/quickbooks/webhook/webhook.controller.ts @@ -1,6 +1,7 @@ import authenticate from '@/app/api/core/utils/authenticate' import { AuthService } from '@/app/api/quickbooks/auth/auth.service' import { WebhookService } from '@/app/api/quickbooks/webhook/webhook.service' +import * as Sentry from '@sentry/nextjs' import { NextRequest, NextResponse } from 'next/server' export const maxDuration = 300 // 5 minutes @@ -8,6 +9,9 @@ export const maxDuration = 300 // 5 minutes export async function captureWebhookEvent(req: NextRequest) { console.info('\n\n####### Webhook triggered #######') const user = await authenticate(req) + Sentry.setTag('portalId', user.workspaceId) + Sentry.setTag('workspaceId', user.workspaceId) + const authService = new AuthService(user) const payload = await req.json() diff --git a/src/app/api/quickbooks/webhook/webhook.service.ts b/src/app/api/quickbooks/webhook/webhook.service.ts index 41e00b42..2629a8ee 100644 --- a/src/app/api/quickbooks/webhook/webhook.service.ts +++ b/src/app/api/quickbooks/webhook/webhook.service.ts @@ -29,6 +29,7 @@ import { getDeletedAtForAuthAccountCategoryLog, getCategory, } from '@/utils/synclog' +import { addSyncBreadcrumb } from '@/utils/sentry' import { and, eq } from 'drizzle-orm' import httpStatus from 'http-status' @@ -46,6 +47,10 @@ export class WebhookService extends BaseService { } const payload = parsedBody.data + addSyncBreadcrumb('Webhook event received', { + eventType: payload.eventType, + portalId: this.user.workspaceId, + }) CustomLogger.info({ obj: { payload }, message: 'WebhookService#handleWebhookEvent | Webhook payload received', diff --git a/src/db/migrations/20260409070000_add_new_failed_record_categories.sql b/src/db/migrations/20260409070000_add_new_failed_record_categories.sql new file mode 100644 index 00000000..dbfc63f9 --- /dev/null +++ b/src/db/migrations/20260409070000_add_new_failed_record_categories.sql @@ -0,0 +1,4 @@ +ALTER TYPE "public"."failed_record_category_types" ADD VALUE IF NOT EXISTS 'rate_limit';--> statement-breakpoint +ALTER TYPE "public"."failed_record_category_types" ADD VALUE IF NOT EXISTS 'validation';--> statement-breakpoint +ALTER TYPE "public"."failed_record_category_types" ADD VALUE IF NOT EXISTS 'qb_api_error';--> statement-breakpoint +ALTER TYPE "public"."failed_record_category_types" ADD VALUE IF NOT EXISTS 'mapping_not_found'; diff --git a/src/db/migrations/meta/20260409070000_snapshot.json b/src/db/migrations/meta/20260409070000_snapshot.json new file mode 100644 index 00000000..5c4e1807 --- /dev/null +++ b/src/db/migrations/meta/20260409070000_snapshot.json @@ -0,0 +1,986 @@ +{ + "id": "a3c7d892-5e1f-4b8a-9c6d-2f4e8a1b3d5c", + "prevId": "1f41b71d-f21a-44c6-af6a-4fda7762d670", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.qb_connection_logs": { + "name": "qb_connection_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection_status": { + "name": "connection_status", + "type": "connection_statuses", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_customers": { + "name": "qb_customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "client_company_id": { + "name": "client_company_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "given_name": { + "name": "given_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "family_name": { + "name": "family_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "company_name": { + "name": "company_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "customer_type": { + "name": "customer_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'client'" + }, + "qb_sync_token": { + "name": "qb_sync_token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "qb_customer_id": { + "name": "qb_customer_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_qb_customers_client_company_id_type_active_idx": { + "name": "uq_qb_customers_client_company_id_type_active_idx", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "customer_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"qb_customers\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_invoice_sync": { + "name": "qb_invoice_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "invoice_number": { + "name": "invoice_number", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "qb_invoice_id": { + "name": "qb_invoice_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "qb_sync_token": { + "name": "qb_sync_token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "recipient_id": { + "name": "recipient_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "invoice_statuses", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "qb_invoice_sync_customer_id_qb_customers_id_fk": { + "name": "qb_invoice_sync_customer_id_qb_customers_id_fk", + "tableFrom": "qb_invoice_sync", + "tableTo": "qb_customers", + "columnsFrom": [ + "customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_payment_sync": { + "name": "qb_payment_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "invoice_number": { + "name": "invoice_number", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "total_amount": { + "name": "total_amount", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "qb_payment_id": { + "name": "qb_payment_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "qb_sync_token": { + "name": "qb_sync_token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_portal_connections": { + "name": "qb_portal_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "intuit_realm_id": { + "name": "intuit_realm_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "expires_in": { + "name": "expires_in", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "x_refresh_token_expires_in": { + "name": "x_refresh_token_expires_in", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_type": { + "name": "token_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "token_set_time": { + "name": "token_set_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "intiated_by": { + "name": "intiated_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "income_account_ref": { + "name": "income_account_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "asset_account_ref": { + "name": "asset_account_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "expense_account_ref": { + "name": "expense_account_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "client_fee_ref": { + "name": "client_fee_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "service_item_ref": { + "name": "service_item_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_suspended": { + "name": "is_suspended", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_qb_portal_connections_portal_id_idx": { + "name": "uq_qb_portal_connections_portal_id_idx", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_product_sync": { + "name": "qb_product_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "price_id": { + "name": "price_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_name": { + "name": "copilot_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "unit_price": { + "name": "unit_price", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "copilot_unit_price": { + "name": "copilot_unit_price", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "qb_item_id": { + "name": "qb_item_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "qb_sync_token": { + "name": "qb_sync_token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_excluded": { + "name": "is_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_settings": { + "name": "qb_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "absorbed_fee_flag": { + "name": "absorbed_fee_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "company_name_flag": { + "name": "company_name_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "create_new_product_flag": { + "name": "create_new_product_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "initial_invoice_setting_map": { + "name": "initial_invoice_setting_map", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "initial_product_setting_map": { + "name": "initial_product_setting_map", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sync_flag": { + "name": "sync_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "qb_settings_portal_id_qb_portal_connections_portal_id_fk": { + "name": "qb_settings_portal_id_qb_portal_connections_portal_id_fk", + "tableFrom": "qb_settings", + "tableTo": "qb_portal_connections", + "columnsFrom": [ + "portal_id" + ], + "columnsTo": [ + "portal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_sync_logs": { + "name": "qb_sync_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "entity_types", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'invoice'" + }, + "event_type": { + "name": "event_type", + "type": "event_types", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'created'" + }, + "status": { + "name": "status", + "type": "log_statuses", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'success'" + }, + "sync_at": { + "name": "sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "copilot_id": { + "name": "copilot_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "quickbooks_id": { + "name": "quickbooks_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "invoice_number": { + "name": "invoice_number", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "remark": { + "name": "remark", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "customer_name": { + "name": "customer_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "customer_email": { + "name": "customer_email", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "tax_amount": { + "name": "tax_amount", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "fee_amount": { + "name": "fee_amount", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "product_name": { + "name": "product_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "product_price": { + "name": "product_price", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "qb_item_name": { + "name": "qb_item_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "copilot_price_id": { + "name": "copilot_price_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "failed_record_category_types", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'others'" + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.connection_statuses": { + "name": "connection_statuses", + "schema": "public", + "values": [ + "pending", + "success", + "error" + ] + }, + "public.invoice_statuses": { + "name": "invoice_statuses", + "schema": "public", + "values": [ + "draft", + "open", + "paid", + "void", + "deleted" + ] + }, + "public.entity_types": { + "name": "entity_types", + "schema": "public", + "values": [ + "invoice", + "product", + "payment" + ] + }, + "public.event_types": { + "name": "event_types", + "schema": "public", + "values": [ + "created", + "updated", + "paid", + "voided", + "deleted", + "succeeded", + "mapped", + "unmapped" + ] + }, + "public.failed_record_category_types": { + "name": "failed_record_category_types", + "schema": "public", + "values": [ + "auth", + "account", + "rate_limit", + "validation", + "qb_api_error", + "mapping_not_found", + "others" + ] + }, + "public.log_statuses": { + "name": "log_statuses", + "schema": "public", + "values": [ + "success", + "failed", + "info" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index e05b8225..707be347 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -99,6 +99,13 @@ "when": 1775465579546, "tag": "20260406085259_add_customer_type_column_in_customers_table", "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1775955600000, + "tag": "20260409070000_add_new_failed_record_categories", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/utils/error.ts b/src/utils/error.ts index f4043689..33259698 100644 --- a/src/utils/error.ts +++ b/src/utils/error.ts @@ -3,6 +3,7 @@ import { isAxiosError } from '@/app/api/core/exceptions/custom' import { CopilotApiError, MessagableError } from '@/type/CopilotApiError' import { IntuitAPIErrorMessage } from '@/utils/intuitAPI' import httpStatus from 'http-status' +import { ZodError } from 'zod' export type IntuitErrorType = { Message: string @@ -14,6 +15,8 @@ export type IntuitErrorType = { export type ErrorMessageAndCode = { message: string code: number + source?: 'intuit' | 'copilot' | 'unknown' + isValidationError?: boolean } export const getMessageAndCodeFromError = ( @@ -25,20 +28,40 @@ export const getMessageAndCodeFromError = ( const code: number = httpStatus.INTERNAL_SERVER_ERROR // Build a proper response based on the type of Error encountered - if (error instanceof CopilotApiError) { - return { message: error.body.message || message, code: error.status } + if (error instanceof ZodError) { + return { + message: error.message, + code: httpStatus.UNPROCESSABLE_ENTITY, + source: 'unknown', + isValidationError: true, + } + } else if (error instanceof CopilotApiError) { + return { + message: error.body.message || message, + code: error.status, + source: 'copilot', + } } else if (error instanceof APIError) { let errorMessage = error.message || message - if (error.message.includes(IntuitAPIErrorMessage)) { + const isIntuitError = error.message.includes(IntuitAPIErrorMessage) + if (isIntuitError) { errorMessage = (error.errors?.[0] as IntuitErrorType).Detail } - return { message: errorMessage, code: error.status } + return { + message: errorMessage, + code: error.status, + source: isIntuitError ? 'intuit' : 'unknown', + } } else if (error instanceof Error && error.message) { - return { message: error.message, code } + return { message: error.message, code, source: 'unknown' } } else if (isAxiosError(error)) { - return { message: error.response.data.error, code: error.response.status } + return { + message: error.response.data.error, + code: error.response.status, + source: 'unknown', + } } - return { message, code } + return { message, code, source: 'unknown' } } export class RetryableError extends Error { diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 0926c74c..56f7bf8a 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,15 +1,17 @@ import util from 'util' +const isDev = process.env.NODE_ENV === 'development' + const CustomLogger = { info({ message, obj }: { message: string; obj?: any }) { const consoleBody = [message] - if (obj) consoleBody.push(util.inspect(obj, { depth: null, colors: true })) + if (obj) consoleBody.push(util.inspect(obj, { depth: null, colors: isDev })) console.info(...consoleBody) }, error({ message, obj }: { message: string; obj?: any }) { const consoleBody = [message] - if (obj) consoleBody.push(util.inspect(obj, { depth: null, colors: true })) + if (obj) consoleBody.push(util.inspect(obj, { depth: null, colors: isDev })) console.error(...consoleBody) }, diff --git a/src/utils/sentry.ts b/src/utils/sentry.ts new file mode 100644 index 00000000..46a429f0 --- /dev/null +++ b/src/utils/sentry.ts @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/nextjs' + +export function addSyncBreadcrumb( + message: string, + data?: Record, +) { + Sentry.addBreadcrumb({ + category: 'sync', + message, + data, + level: 'info', + }) +} diff --git a/src/utils/synclog.ts b/src/utils/synclog.ts index 504f2cd3..a39836f5 100644 --- a/src/utils/synclog.ts +++ b/src/utils/synclog.ts @@ -3,6 +3,9 @@ import { AccountErrorCodes } from '@/constant/intuitErrorCode' import { refreshTokenExpireMessage } from '@/utils/auth' import { ErrorMessageAndCode } from '@/utils/error' +const MAPPING_NOT_FOUND_PATTERN = + /(?:mapping|customer|product|invoice|item|price).*not found/i + export function getCategory(errorWithCode?: ErrorMessageAndCode) { if (!errorWithCode) return FailedRecordCategoryType.OTHERS if (errorWithCode.code && AccountErrorCodes.includes(errorWithCode.code)) { @@ -11,6 +14,18 @@ export function getCategory(errorWithCode?: ErrorMessageAndCode) { if (errorWithCode.message === refreshTokenExpireMessage) { return FailedRecordCategoryType.AUTH } + if (errorWithCode.code === 429) { + return FailedRecordCategoryType.RATE_LIMIT + } + if (errorWithCode.isValidationError) { + return FailedRecordCategoryType.VALIDATION + } + if (errorWithCode.source === 'intuit') { + return FailedRecordCategoryType.QB_API_ERROR + } + if (MAPPING_NOT_FOUND_PATTERN.test(errorWithCode.message)) { + return FailedRecordCategoryType.MAPPING_NOT_FOUND + } return FailedRecordCategoryType.OTHERS } From 336a877931eb3a1208d58236248d4e0aaf28b9cf Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 9 Apr 2026 14:37:45 +0545 Subject: [PATCH 2/3] fix(OUT-3542): move Sentry threshold report after processing for breadcrumb capture Move captureMessage from checkAndUpdateAttempt (before processing) to intiateSync (after processing) so breadcrumbs from the actual execution path are included in the Sentry event. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/quickbooks/sync/sync.service.ts | 50 ++++++++++----------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/src/app/api/quickbooks/sync/sync.service.ts b/src/app/api/quickbooks/sync/sync.service.ts index 0fb18abd..502eb030 100644 --- a/src/app/api/quickbooks/sync/sync.service.ts +++ b/src/app/api/quickbooks/sync/sync.service.ts @@ -430,6 +430,29 @@ export class SyncService extends BaseService { }) break } + + // Report to Sentry after the final attempt so breadcrumbs from processing are included + if (resyncAttemtps.isLastAttempt) { + captureMessage( + `SyncService#intiateSync | Records exceeded max retry count. Portal Id: ${this.user.workspaceId}.`, + { + tags: { + key: 'exceedMaxAttempts', + portalId: this.user.workspaceId, + entityType: log.entityType, + eventType: log.eventType, + errorCategory: log.category, + }, + extra: { + LogId: log.id, + invoiceNumber: log.invoiceNumber, + errorMessage: log.errorMessage, + attempt: MAX_ATTEMPTS, + }, + level: 'error', + }, + ) + } } } @@ -541,7 +564,7 @@ export class SyncService extends BaseService { message: `SyncService#checkAndUpdateAttempt | Reached max attempts. Not syncing the record with assembly id: ${log.copilotId}`, obj: { workspaceId: this.user.workspaceId }, }) - return { maxAttempts: true } + return { maxAttempts: true, isLastAttempt: false } } const attempt = log.attempt + 1 @@ -552,35 +575,12 @@ export class SyncService extends BaseService { eq(QBSyncLog.id, log.id), ) - // report to sentry if any records has exceeded max retry count - if (attempt == MAX_ATTEMPTS) { - captureMessage( - `SyncService#checkAndUpdateAttempt | Records exceeded max retry count. Portal Id: ${this.user.workspaceId}.`, - { - tags: { - key: 'exceedMaxAttempts', - portalId: this.user.workspaceId, - entityType: log.entityType, - eventType: log.eventType, - errorCategory: log.category, - }, - extra: { - LogId: log.id, - invoiceNumber: log.invoiceNumber, - errorMessage: log.errorMessage, - attempt, - }, - level: 'error', - }, - ) - } - CustomLogger.info({ message: `SyncService#checkAndUpdateAttempt | Attempt: ${attempt}`, obj: { workspaceId: this.user.workspaceId }, }) - return { maxAttempts: false } + return { maxAttempts: false, isLastAttempt: attempt === MAX_ATTEMPTS } } private async updateFailedSyncLog( From 391f1065d6741db946ce37a377e6560b650f13e1 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 9 Apr 2026 15:25:20 +0545 Subject: [PATCH 3/3] fix(OUT-3542): address code review findings - Only fire Sentry threshold report if record is still FAILED after final attempt (prevents spurious alerts on successful last retry) - Use Sentry.withScope in webhook controller to isolate tags per request (prevents portalId contamination in concurrent webhooks) - Skip setting errorSource tag when value is 'unknown' (reduces noise) - Use actual booleans in breadcrumb data instead of string 'true'/'false' Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/core/utils/withErrorHandler.ts | 2 +- .../quickbooks/customer/customer.service.ts | 2 +- .../api/quickbooks/invoice/invoice.service.ts | 2 +- .../api/quickbooks/product/product.service.ts | 2 +- src/app/api/quickbooks/sync/sync.service.ts | 41 +++++++++++-------- .../quickbooks/webhook/webhook.controller.ts | 32 ++++++++------- 6 files changed, 45 insertions(+), 36 deletions(-) diff --git a/src/app/api/core/utils/withErrorHandler.ts b/src/app/api/core/utils/withErrorHandler.ts index 27c65f00..c84eeefb 100644 --- a/src/app/api/core/utils/withErrorHandler.ts +++ b/src/app/api/core/utils/withErrorHandler.ts @@ -92,7 +92,7 @@ export const withErrorHandler = (handler: RequestHandler): RequestHandler => { Sentry.withScope((scope) => { scope.setTag('errorCategory', category) - if (errorWithCode.source) { + if (errorWithCode.source && errorWithCode.source !== 'unknown') { scope.setTag('errorSource', errorWithCode.source) } Sentry.captureException(error) diff --git a/src/app/api/quickbooks/customer/customer.service.ts b/src/app/api/quickbooks/customer/customer.service.ts index 80d2165d..2c3e2052 100644 --- a/src/app/api/quickbooks/customer/customer.service.ts +++ b/src/app/api/quickbooks/customer/customer.service.ts @@ -367,7 +367,7 @@ export class CustomerService extends BaseService { } addSyncBreadcrumb('Customer search in QBO', { - found: !!customer ? 'true' : 'false', + found: !!customer, }) // 3. if not found, create a new client in the QB diff --git a/src/app/api/quickbooks/invoice/invoice.service.ts b/src/app/api/quickbooks/invoice/invoice.service.ts index a4a53826..33494e79 100644 --- a/src/app/api/quickbooks/invoice/invoice.service.ts +++ b/src/app/api/quickbooks/invoice/invoice.service.ts @@ -581,7 +581,7 @@ export class InvoiceService extends BaseService { ) addSyncBreadcrumb('Customer resolved', { - existingMapping: !!existingCustomer ? 'true' : 'false', + existingMapping: !!existingCustomer, }) let customer, diff --git a/src/app/api/quickbooks/product/product.service.ts b/src/app/api/quickbooks/product/product.service.ts index 0ef37504..176a2fa4 100644 --- a/src/app/api/quickbooks/product/product.service.ts +++ b/src/app/api/quickbooks/product/product.service.ts @@ -536,7 +536,7 @@ export class ProductService extends BaseService { ).length addSyncBreadcrumb('Product mapping check', { - alreadyMapped: productWithPriceCount ? 'true' : 'false', + alreadyMapped: !!productWithPriceCount, }) if (productWithPriceCount && productWithPriceCount > 0) { console.info('Product already mapped with price') diff --git a/src/app/api/quickbooks/sync/sync.service.ts b/src/app/api/quickbooks/sync/sync.service.ts index 502eb030..6d1f78aa 100644 --- a/src/app/api/quickbooks/sync/sync.service.ts +++ b/src/app/api/quickbooks/sync/sync.service.ts @@ -433,25 +433,30 @@ export class SyncService extends BaseService { // Report to Sentry after the final attempt so breadcrumbs from processing are included if (resyncAttemtps.isLastAttempt) { - captureMessage( - `SyncService#intiateSync | Records exceeded max retry count. Portal Id: ${this.user.workspaceId}.`, - { - tags: { - key: 'exceedMaxAttempts', - portalId: this.user.workspaceId, - entityType: log.entityType, - eventType: log.eventType, - errorCategory: log.category, - }, - extra: { - LogId: log.id, - invoiceNumber: log.invoiceNumber, - errorMessage: log.errorMessage, - attempt: MAX_ATTEMPTS, - }, - level: 'error', - }, + const currentLog = await this.syncLogService.getOne( + eq(QBSyncLog.id, log.id), ) + if (currentLog?.status === LogStatus.FAILED) { + captureMessage( + `SyncService#intiateSync | Records exceeded max retry count. Portal Id: ${this.user.workspaceId}.`, + { + tags: { + key: 'exceedMaxAttempts', + portalId: this.user.workspaceId, + entityType: log.entityType, + eventType: log.eventType, + errorCategory: currentLog.category, + }, + extra: { + LogId: log.id, + invoiceNumber: log.invoiceNumber, + errorMessage: currentLog.errorMessage, + attempt: MAX_ATTEMPTS, + }, + level: 'error', + }, + ) + } } } } diff --git a/src/app/api/quickbooks/webhook/webhook.controller.ts b/src/app/api/quickbooks/webhook/webhook.controller.ts index ba7215cc..fedab968 100644 --- a/src/app/api/quickbooks/webhook/webhook.controller.ts +++ b/src/app/api/quickbooks/webhook/webhook.controller.ts @@ -7,21 +7,25 @@ import { NextRequest, NextResponse } from 'next/server' export const maxDuration = 300 // 5 minutes export async function captureWebhookEvent(req: NextRequest) { - console.info('\n\n####### Webhook triggered #######') - const user = await authenticate(req) - Sentry.setTag('portalId', user.workspaceId) - Sentry.setTag('workspaceId', user.workspaceId) + return Sentry.withScope(async (scope) => { + console.info('\n\n####### Webhook triggered #######') + const user = await authenticate(req) + scope.setTag('portalId', user.workspaceId) + scope.setTag('workspaceId', user.workspaceId) - const authService = new AuthService(user) - const payload = await req.json() + const authService = new AuthService(user) + const payload = await req.json() - const qbTokenInfo = await authService.getQBPortalConnection(user.workspaceId) - user.qbConnection = { - serviceItemRef: qbTokenInfo.serviceItemRef, - clientFeeRef: qbTokenInfo.clientFeeRef, - } - const webhookService = new WebhookService(user) - await webhookService.handleWebhookEvent(payload, qbTokenInfo) + const qbTokenInfo = await authService.getQBPortalConnection( + user.workspaceId, + ) + user.qbConnection = { + serviceItemRef: qbTokenInfo.serviceItemRef, + clientFeeRef: qbTokenInfo.clientFeeRef, + } + const webhookService = new WebhookService(user) + await webhookService.handleWebhookEvent(payload, qbTokenInfo) - return NextResponse.json({ ok: true }) + return NextResponse.json({ ok: true }) + }) }