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
This commit is contained in:
@@ -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.",
|
||||
|
||||
@@ -323,6 +323,12 @@ const getError = ({
|
||||
return error?.message ? (
|
||||
<>
|
||||
{responseVercelIdHeader ?? ""} {t(messageKey, { date, count })}
|
||||
{error.data?.traceId && (
|
||||
<div className="text-subtle mt-2 text-xs">
|
||||
<span className="font-medium">{t("trace_reference_id")}:</span>
|
||||
<code className="ml-1 select-all break-all font-mono">{error.data.traceId}</code>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>{t("can_you_try_again")}</>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export function defaultResponder<T>(
|
||||
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)) {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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<string, unknown> | 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<T extends Error>({ statusCode, cause }: { statusCode: number; cause: T }) {
|
||||
function getHttpError<T extends Error>({
|
||||
statusCode,
|
||||
cause,
|
||||
traceId,
|
||||
tracedData,
|
||||
}: {
|
||||
statusCode: number;
|
||||
cause: T;
|
||||
traceId?: string;
|
||||
tracedData?: Record<string, unknown>;
|
||||
}) {
|
||||
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<string, unknown>
|
||||
) {
|
||||
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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user