From 843329ccce541c5fd994b69e1f266031cd666587 Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Thu, 4 Dec 2025 11:21:26 +0530 Subject: [PATCH] feat: distributed tracing - 3 (#25092) * feat: distributed tracing 2 * feat: distributed tracing 2 * refactor: feedback * refactor: feedback * fix: type error * fix: trpc error * feat: distributed tracing - 3 * chore: translation * refactor: improvements * fix: feedback * chore: remove --- apps/web/public/static/locales/en/common.json | 1 + .../BookEventForm/BookEventForm.tsx | 6 + .../Booker/components/hooks/useBookings.ts | 6 + .../lib/service/RegularBookingService.ts | 8 -- packages/lib/server/defaultResponder.ts | 2 +- .../server/getServerErrorFromUnknown.test.ts | 134 ++++++++++++++++++ .../lib/server/getServerErrorFromUnknown.ts | 77 ++++++++-- 7 files changed, 210 insertions(+), 24 deletions(-) diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index c9ba919564..611bb69b66 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -32,6 +32,7 @@ "verify_email_change_success_toast": "Updated your email to {{email}}", "verify_email_change_failure_toast": "Failed to update email.", "change_of_email": "Verify your new {{appName}} email address", + "trace_reference_id": "Trace reference ID", "change_of_email_toast": "We have sent a verification link to {{email}}. We will update your email address once you click this link.", "copy_somewhere_safe": "Save this API key somewhere safe. You will not be able to view it again.", "verify_email_email_body": "Please verify your email address by clicking the button below.", diff --git a/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx b/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx index a9abddc0cb..49c1063708 100644 --- a/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx +++ b/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx @@ -323,6 +323,12 @@ const getError = ({ return error?.message ? ( <> {responseVercelIdHeader ?? ""} {t(messageKey, { date, count })} + {error.data?.traceId && ( +
+ {t("trace_reference_id")}: + {error.data.traceId} +
+ )} ) : ( <>{t("can_you_try_again")} diff --git a/packages/features/bookings/Booker/components/hooks/useBookings.ts b/packages/features/bookings/Booker/components/hooks/useBookings.ts index 37095979b7..80877bdcfe 100644 --- a/packages/features/bookings/Booker/components/hooks/useBookings.ts +++ b/packages/features/bookings/Booker/components/hooks/useBookings.ts @@ -400,6 +400,7 @@ export const useBookings = ({ event, hashedLink, bookingForm, metadata, isBookin const error = err as Error & { data: { rescheduleUid: string; startTime: string; attendees: string[] }; + traceId?: string; }; if (error.message === ErrorCode.BookerLimitExceededReschedule && error.data?.rescheduleUid) { @@ -521,6 +522,11 @@ export const useBookings = ({ event, hashedLink, bookingForm, metadata, isBookin : event?.data?.forwardParamsSuccessRedirect, }); }, + onError: (err, _, ctx) => { + console.error("Error creating recurring booking", err); + // eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- It is only called when user takes an action in embed + bookerFormErrorRef && bookerFormErrorRef.current?.scrollIntoView({ behavior: "smooth" }); + }, }); const handleBookEvent = useHandleBookEvent({ diff --git a/packages/features/bookings/lib/service/RegularBookingService.ts b/packages/features/bookings/lib/service/RegularBookingService.ts index 9201950b05..9b1de598fb 100644 --- a/packages/features/bookings/lib/service/RegularBookingService.ts +++ b/packages/features/bookings/lib/service/RegularBookingService.ts @@ -580,7 +580,6 @@ async function handler( throw new HttpError({ statusCode: 400, message: "email_verification_required", - data: { traceId: traceContext.traceId }, }); } @@ -590,7 +589,6 @@ async function handler( throw new HttpError({ statusCode: 400, message: "invalid_verification_code", - data: { traceId: traceContext.traceId }, }); } } @@ -608,14 +606,12 @@ async function handler( throw new HttpError({ statusCode: 404, message: "event_type_not_found", - data: { traceId: traceContext.traceId }, }); if (eventType.seatsPerTimeSlot && eventType.recurringEvent) { throw new HttpError({ statusCode: 400, message: "recurring_event_seats_error", - data: { traceId: traceContext.traceId }, }); } @@ -757,7 +753,6 @@ async function handler( throw new HttpError({ statusCode: 400, message: "Missing routingFormResponseId", - data: { traceId: traceContext.traceId }, }); } routingFormResponse = await deps.prismaClient.app_RoutingForms_FormResponse.findUnique({ @@ -1858,7 +1853,6 @@ async function handler( throw new HttpError({ statusCode: 409, message: ErrorCode.BookingConflict, - data: { traceId: traceContext.traceId }, }); } throw err; @@ -2105,7 +2099,6 @@ async function handler( const error = { errorCode: "BookingCreatingMeetingFailed", message: "Booking failed", - data: { traceId: traceContext.traceId }, }; tracingLogger.error( @@ -2329,7 +2322,6 @@ async function handler( throw new HttpError({ statusCode: 400, message: "Missing payment credentials", - data: { traceId: traceContext.traceId }, }); } diff --git a/packages/lib/server/defaultResponder.ts b/packages/lib/server/defaultResponder.ts index 454a1e7673..f28e9b064d 100644 --- a/packages/lib/server/defaultResponder.ts +++ b/packages/lib/server/defaultResponder.ts @@ -61,7 +61,7 @@ export function defaultResponder( const statusCode = getHTTPStatusCodeFromError(err); error = new HttpError({ statusCode, message: err.message }); } else { - error = getServerErrorFromUnknown(err); + error = getServerErrorFromUnknown(tracedError); } // we don't want to report Bad Request errors to Sentry / console if (!(error.statusCode >= 400 && error.statusCode < 500)) { diff --git a/packages/lib/server/getServerErrorFromUnknown.test.ts b/packages/lib/server/getServerErrorFromUnknown.test.ts index 10996e37e4..b922312b0b 100644 --- a/packages/lib/server/getServerErrorFromUnknown.test.ts +++ b/packages/lib/server/getServerErrorFromUnknown.test.ts @@ -1,9 +1,14 @@ +import { Prisma } from "@calcom/prisma/client"; import { describe, expect, test } from "vitest"; +import { ZodError } from "zod"; import { ErrorCode } from "@calcom/lib/errorCodes"; import { ErrorWithCode } from "@calcom/lib/errors"; +import { TRPCError } from "@trpc/server"; + import { HttpError } from "../http-error"; +import { TracedError } from "../tracing/error"; import { getServerErrorFromUnknown } from "./getServerErrorFromUnknown"; const test400Codes = [ @@ -113,6 +118,135 @@ describe("getServerErrorFromUnknown", () => { }); }); +describe("TracedError handling", () => { + test("should extract traceId and tracedData from TracedError", () => { + const originalError = new Error("Original error message"); + const tracedData = { userId: "123", operation: "booking" }; + const traceContext = { + traceId: "trace_abc123", + spanId: "span_123", + operation: "test_operation", + }; + + const tracedError = new TracedError(originalError, traceContext, tracedData); + + const result = getServerErrorFromUnknown(tracedError); + + expect(result.statusCode).toBe(500); + expect(result.message).toBe("Original error message"); + expect(result.data).toEqual({ ...tracedData, traceId: traceContext.traceId }); + }); + + test("should handle TracedError wrapping ErrorWithCode", () => { + const originalError = new ErrorWithCode(ErrorCode.BookingNotFound, "Booking not found"); + const tracedData = { bookingId: "456" }; + const traceContext = { + traceId: "trace_def456", + spanId: "span_456", + operation: "booking_lookup", + }; + + const tracedError = new TracedError(originalError, traceContext, tracedData); + + const result = getServerErrorFromUnknown(tracedError); + + expect(result.statusCode).toBe(404); + expect(result.message).toBe("Booking not found"); + expect(result.data).toEqual({ ...tracedData, traceId: traceContext.traceId }); + }); +}); + +describe("TRPCError handling", () => { + test("should handle TRPCError with BAD_REQUEST", () => { + const trpcError = new TRPCError({ + code: "BAD_REQUEST", + message: "Invalid input data", + }); + + const result = getServerErrorFromUnknown(trpcError); + + expect(result.statusCode).toBe(400); + expect(result.message).toBe("Invalid input data"); + expect(result.data).toBeUndefined(); + }); + + test("should handle TracedError wrapping TRPCError", () => { + const trpcError = new TRPCError({ + code: "NOT_FOUND", + message: "Resource not found", + }); + const tracedData = { resourceId: "789" }; + const traceContext = { + traceId: "trace_trpc123", + spanId: "span_trpc123", + operation: "resource_lookup", + }; + + const tracedError = new TracedError(trpcError, traceContext, tracedData); + + const result = getServerErrorFromUnknown(tracedError); + + expect(result.statusCode).toBe(404); + expect(result.message).toBe("Resource not found"); + expect(result.data).toEqual({ ...tracedData, traceId: traceContext.traceId }); + }); +}); + +describe("ZodError handling", () => { + test("should handle ZodError with validation issues", () => { + const zodError = new ZodError([ + { + code: "invalid_type", + expected: "string", + received: "number", + path: ["name"], + message: "Expected string, received number", + }, + ]); + + const result = getServerErrorFromUnknown(zodError); + + expect(result.statusCode).toBe(400); + expect(result.message).toBe("invalid_type in 'name': Expected string, received number"); + expect(result.cause).toBe(zodError); + }); +}); + +describe("Prisma error handling", () => { + test("should handle Prisma P2025 error (record not found)", () => { + const prismaError = new Error("Record to delete does not exist.") as any; + prismaError.code = "P2025"; + prismaError.clientVersion = "5.0.0"; + Object.setPrototypeOf(prismaError, Prisma.PrismaClientKnownRequestError.prototype); + + const result = getServerErrorFromUnknown(prismaError); + + expect(result.statusCode).toBe(404); + expect(result.message).toBe("Record to delete does not exist."); + expect(result.cause).toBe(prismaError); + }); + + test("should handle other Prisma errors as 400", () => { + const prismaError = new Error("Foreign key constraint failed") as any; + prismaError.code = "P2003"; + prismaError.clientVersion = "5.0.0"; + Object.setPrototypeOf(prismaError, Prisma.PrismaClientKnownRequestError.prototype); + + const result = getServerErrorFromUnknown(prismaError); + + expect(result.statusCode).toBe(400); + expect(result.message).toBe("Foreign key constraint failed"); + }); +}); + +test("should handle unknown type fallback", () => { + const result = getServerErrorFromUnknown({ someProperty: "value" }); + + expect(result.statusCode).toBe(500); + expect(result.message).toBe("Unhandled error of type 'object'. Please reach out for our customer support."); + expect(result.data).toBeUndefined(); +}); + test400Codes.forEach((errorCode) => { test(`${errorCode} should return 400 Bad Request`, () => { const error = new ErrorWithCode(errorCode, `Test message for ${errorCode}`); diff --git a/packages/lib/server/getServerErrorFromUnknown.ts b/packages/lib/server/getServerErrorFromUnknown.ts index 197e7bd076..41ba8e0451 100644 --- a/packages/lib/server/getServerErrorFromUnknown.ts +++ b/packages/lib/server/getServerErrorFromUnknown.ts @@ -5,9 +5,13 @@ import { ErrorCode } from "@calcom/lib/errorCodes"; import { ErrorWithCode } from "@calcom/lib/errors"; import { Prisma } from "@calcom/prisma/client"; +import { TRPCError } from "@trpc/server"; +import { getHTTPStatusCodeFromError } from "@trpc/server/http"; + import { HttpError } from "../http-error"; import { redactError } from "../redactError"; import { stripeInvalidRequestErrorSchema } from "../stripe-error"; +import { TracedError } from "../tracing/error"; function hasName(cause: unknown): cause is { name: string } { return !!cause && typeof cause === "object" && "name" in cause; @@ -40,61 +44,85 @@ function parseZodErrorIssues(issues: ZodIssue[]): string { * For client-side code, use getErrorFromUnknown from @calcom/lib/errors instead. */ export function getServerErrorFromUnknown(cause: unknown): HttpError { + let traceId: string | undefined; + let tracedData: Record | undefined; + + if (cause instanceof TracedError) { + traceId = cause.traceId; + tracedData = cause.data; + cause = cause.originalError; + } + + if (cause instanceof TRPCError) { + const statusCode = getHTTPStatusCodeFromError(cause); + return new HttpError({ + statusCode, + message: cause.message, + cause, + data: traceId ? { ...tracedData, traceId } : undefined, + }); + } if (isZodError(cause)) { return new HttpError({ statusCode: 400, message: parseZodErrorIssues(cause.issues), cause, + data: traceId ? { ...tracedData, traceId } : undefined, }); } if (cause instanceof SyntaxError) { return new HttpError({ statusCode: 500, message: "Unexpected error, please reach out for our customer support.", + cause, + data: traceId ? { ...tracedData, traceId } : undefined, }); } if (isPrismaError(cause)) { - return getServerErrorFromPrismaError(cause); + return getServerErrorFromPrismaError(cause, traceId, tracedData); } const parsedStripeError = stripeInvalidRequestErrorSchema.safeParse(cause); if (parsedStripeError.success) { - return getHttpError({ statusCode: 400, cause: parsedStripeError.data }); + const stripeErrorObj = new Error(parsedStripeError.data.message || "Stripe error"); + stripeErrorObj.name = parsedStripeError.data.type || "StripeInvalidRequestError"; + return getHttpError({ statusCode: 400, cause: stripeErrorObj, traceId, tracedData }); } if (cause instanceof ErrorWithCode) { const statusCode = getHttpStatusCode(cause); return new HttpError({ statusCode, message: cause.message ?? "", - data: cause.data, + data: traceId ? { ...cause.data, ...tracedData, traceId } : cause.data, cause, }); } if (cause instanceof HttpError) { - const redactedCause = redactError(cause); - return { - ...redactedCause, - name: cause.name, + const originalData = cause.data; + return new HttpError({ + statusCode: cause.statusCode, message: cause.message ?? "", cause: cause.cause, url: cause.url, - statusCode: cause.statusCode, method: cause.method, - }; + data: traceId ? { ...originalData, ...tracedData, traceId } : originalData, + }); } if (cause instanceof Error) { const statusCode = getHttpStatusCode(cause); - return getHttpError({ statusCode, cause }); + return getHttpError({ statusCode, cause, traceId, tracedData }); } if (typeof cause === "string") { return new HttpError({ statusCode: 500, message: cause, + data: traceId ? { ...tracedData, traceId } : undefined, }); } return new HttpError({ statusCode: 500, message: `Unhandled error of type '${typeof cause}'. Please reach out for our customer support.`, + data: traceId ? { ...tracedData, traceId } : tracedData, }); } @@ -152,14 +180,33 @@ export function getHttpStatusCode(cause: Error | ErrorWithCode): number { } } -function getHttpError({ statusCode, cause }: { statusCode: number; cause: T }) { +function getHttpError({ + statusCode, + cause, + traceId, + tracedData, +}: { + statusCode: number; + cause: T; + traceId?: string; + tracedData?: Record; +}) { const redacted = redactError(cause); - return new HttpError({ statusCode, message: redacted.message, cause: redacted }); + return new HttpError({ + statusCode, + message: redacted.message, + cause: redacted, + data: traceId ? { ...tracedData, traceId } : undefined, + }); } -function getServerErrorFromPrismaError(cause: Prisma.PrismaClientKnownRequestError) { +function getServerErrorFromPrismaError( + cause: Prisma.PrismaClientKnownRequestError, + traceId?: string, + tracedData?: Record +) { if (cause.code === "P2025") { - return getHttpError({ statusCode: 404, cause }); + return getHttpError({ statusCode: 404, cause, traceId, tracedData }); } - return getHttpError({ statusCode: 400, cause }); + return getHttpError({ statusCode: 400, cause, traceId, tracedData }); }