feat: Add RoutingFormTrace (#27316)

* Add DB schema

* Init zod schema

* Init RoutingTrace and PendingRoutingTrace repository interfaces

* Create PrismaPendingRoutingTraceRepository

* Init RoutingTraceService

* Create RoutingTraceService container

* User routing trace service in routing

* Create RoutingTraceRepository and PrismaRoutingTraceRepoistory

* Add findByFormResponseId and findByQueuedFormResponseId to PendingRoutingTraceRepository

* Update DI containers

* RoutingTraceService create process booking method

* Use pending routing trace rather than URL params

* Fix schema

* Fix writing assignment reason for routed booking

* Remove  from  service

* Refactor RoutingTraceService to not rely on async local storage

* Pass RoutingTraceService through routing call

* Add attribute-logic-evaluated to routing trace step

* Add routing trace to trpc endpoint

* Add CRM routing trace step

* Fix extracting routing trace to assignment reason

* Add back CRM params to prevent refetching

* test: Add unit tests for RoutingTraceService and repositories

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* test: Add missing mock for RoutingTraceService in getRoutedUrl tests

Also fix pre-existing lint issues in the test file:
- Add explicit types to mockForm and mockSerializableForm variables
- Add explicit type to url parameter in mockContext
- Replace 'as any' with 'as unknown as InstanceType<typeof UserRepository>'

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Link pending form submission to routing trace

* Clean up

* Add lookup field assignment reasons

* Rename to PendingRoutingTrace

* Add migration file

* fix: Update RoutingTraceService tests to use assignmentReasonRepository mock

- Add getStepsCount() method back to RoutingTraceService
- Add queuedFormResponseId support to processForBooking method
- Update tests to use mockAssignmentReasonRepository instead of prisma mock
- Remove test for missing routingTraceRepository (all deps now required)

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: Remove PII (organizer email) from log payload in RoutingTraceService

Addresses Cubic AI review feedback with confidence 9/10.
Logging PII violates sensitive information logging rules.

Co-Authored-By: unknown <>

* Write field values at the time of routing

* Write attributes used to route

* fix: Add CHECK constraint to ensure at least one response ID is set in routing trace tables

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: Address PR review comments for routing trace feature

- Add checkedFallback to routing trace step data (Comment 11)
- Extract hardcoded domain/step strings to constants (Comment 13)
- Use @default(now()) for createdAt in PendingRoutingTrace and RoutingTrace (Comment 15)
- Add DEFAULT CURRENT_TIMESTAMP to migration for createdAt fields

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: Use UTC-safe timezone expression for createdAt defaults in routing trace tables

Addresses Cubic AI review feedback (confidence 9/10) to prevent timezone
issues by using dbgenerated("timezone('UTC', now())") instead of bare now()
for the createdAt fields in PendingRoutingTrace and RoutingTrace models.

Co-Authored-By: unknown <>

* fix: Align migration SQL with Prisma's expected timezone syntax

Use 'UTC'::text cast in timezone() function to match Prisma's generated SQL.

Co-Authored-By: unknown <>

* fix: Revert migration SQL to match Prisma schema timezone syntax

Remove ::text cast from timezone() function to match what Prisma generates
from the schema definition.

Co-Authored-By: unknown <>

* fix: Use explicit ::text cast in timezone() for PostgreSQL compatibility

PostgreSQL normalizes timezone('UTC', now()) to timezone('UTC'::text, now())
internally. Update both schema and migration to use the explicit cast to
ensure they match and pass the migration check.

Co-Authored-By: unknown <>

* feat: Add RoutingFormTraceService domain class for type-safe routing traces

- Create RoutingFormTraceService class with routeMatched, fallbackRouteUsed,
  attributeLogicEvaluated, and attributeFallbackUsed methods
- Add ROUTING_FORM_STEPS constants
- Add unit tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: Integrate RoutingFormTraceService for routing form tracing

- Add routingFormTraceService parameter to findMatchingRoute
- Record route_matched or fallback_route_used step when route is selected
- Use "Default Route" as name for form-level fallback routes
- Refactor findTeamMembersMatchingAttributeLogic to use RoutingFormTraceService
- Update handleResponse to accept and pass routingFormTraceService
- Create RoutingFormTraceService in getRoutedUrl and pass through the flow

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test: Add test coverage for RoutingFormTraceService integration

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* feat: Integrate RoutingFormTraceService for routing form tracing

- Add routingFormTraceService parameter to findMatchingRoute
- Record route_matched or fallback_route_used step when route is selected
- Use "Default Route" as name for form-level fallback routes
- Refactor findTeamMembersMatchingAttributeLogic to use RoutingFormTraceService
- Update handleResponse to accept and pass routingFormTraceService
- Create RoutingFormTraceService in getRoutedUrl and pass through the flow

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: Restore schema and migration from main branch

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: Use correct parameter name routingFormTraceService in tests

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: Use 'default_route' instead of 'Default Route' for fallback route name

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Joe Au-Yeung
2026-01-28 17:15:22 -03:00
committed by GitHub
co-authored by Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Claude Opus 4.5
parent 906ba99d3c
commit f536100224
8 changed files with 694 additions and 57 deletions
@@ -0,0 +1,214 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RoutingFormTraceService } from "@calcom/features/routing-trace/domains/RoutingFormTraceService";
import { RaqbLogicResult } from "@calcom/lib/raqb/evaluateRaqbLogic";
import type { FormResponse, SerializableForm } from "../types/types";
import { findMatchingRoute } from "./processRoute";
vi.mock("@calcom/lib/raqb/evaluateRaqbLogic", () => ({
evaluateRaqbLogic: vi.fn(),
RaqbLogicResult: {
MATCH: "MATCH",
NO_MATCH: "NO_MATCH",
LOGIC_NOT_FOUND_SO_MATCHED: "LOGIC_NOT_FOUND_SO_MATCHED",
},
}));
vi.mock("./getQueryBuilderConfig", () => ({
getQueryBuilderConfigForFormFields: vi.fn().mockReturnValue({}),
}));
const { evaluateRaqbLogic } = await import("@calcom/lib/raqb/evaluateRaqbLogic");
describe("findMatchingRoute", () => {
let mockRoutingFormTrace: RoutingFormTraceService;
beforeEach(() => {
vi.clearAllMocks();
mockRoutingFormTrace = {
routeMatched: vi.fn(),
fallbackRouteUsed: vi.fn(),
attributeLogicEvaluated: vi.fn(),
attributeFallbackUsed: vi.fn(),
} as unknown as RoutingFormTraceService;
});
const createMockForm = (
routes: Array<{
id: string;
name?: string;
isFallback?: boolean;
queryValue?: unknown;
}>
): Pick<SerializableForm<never>, "routes" | "fields"> => ({
routes: routes.map((route) => ({
id: route.id,
name: route.name,
isFallback: route.isFallback ?? false,
queryValue: route.queryValue ?? { type: "group" },
action: { type: "customPageMessage", value: "test" },
})) as never,
fields: [],
});
const createMockResponse = (): Record<string, Pick<FormResponse[string], "value">> => ({});
it("should throw error if fallback route is missing", () => {
const form = createMockForm([{ id: "route-1", name: "Route 1" }]);
expect(() =>
findMatchingRoute({
form,
response: createMockResponse(),
})
).toThrow("Fallback route is missing");
});
it("should return null if no route matches", () => {
vi.mocked(evaluateRaqbLogic).mockReturnValue(RaqbLogicResult.NO_MATCH);
const form = createMockForm([
{ id: "route-1", name: "Route 1" },
{ id: "fallback", name: "Fallback", isFallback: true },
]);
const result = findMatchingRoute({
form,
response: createMockResponse(),
});
expect(result).toBeNull();
});
describe("tracing", () => {
it("should call routeMatched when a non-fallback route matches", () => {
vi.mocked(evaluateRaqbLogic).mockReturnValue(RaqbLogicResult.MATCH);
const form = createMockForm([
{ id: "route-1", name: "Sales Route" },
{ id: "fallback", name: "Default", isFallback: true },
]);
const result = findMatchingRoute({
form,
response: createMockResponse(),
routingFormTraceService: mockRoutingFormTrace,
});
expect(result).not.toBeNull();
expect(mockRoutingFormTrace.routeMatched).toHaveBeenCalledWith({
routeId: "route-1",
routeName: "Sales Route",
});
expect(mockRoutingFormTrace.fallbackRouteUsed).not.toHaveBeenCalled();
});
it("should call fallbackRouteUsed when fallback route is used", () => {
vi.mocked(evaluateRaqbLogic)
.mockReturnValueOnce(RaqbLogicResult.NO_MATCH)
.mockReturnValueOnce(RaqbLogicResult.MATCH);
const form = createMockForm([
{ id: "route-1", name: "Sales Route" },
{ id: "fallback", name: "Default Route", isFallback: true },
]);
const result = findMatchingRoute({
form,
response: createMockResponse(),
routingFormTraceService: mockRoutingFormTrace,
});
expect(result).not.toBeNull();
expect(mockRoutingFormTrace.fallbackRouteUsed).toHaveBeenCalledWith({
routeId: "fallback",
routeName: "Default Route",
});
expect(mockRoutingFormTrace.routeMatched).not.toHaveBeenCalled();
});
it("should use 'default_route' as name when fallback route has no name", () => {
vi.mocked(evaluateRaqbLogic)
.mockReturnValueOnce(RaqbLogicResult.NO_MATCH)
.mockReturnValueOnce(RaqbLogicResult.MATCH);
const form = createMockForm([
{ id: "route-1", name: "Sales Route" },
{ id: "fallback", isFallback: true },
]);
const result = findMatchingRoute({
form,
response: createMockResponse(),
routingFormTraceService: mockRoutingFormTrace,
});
expect(result).not.toBeNull();
expect(mockRoutingFormTrace.fallbackRouteUsed).toHaveBeenCalledWith({
routeId: "fallback",
routeName: "default_route",
});
});
it("should use route id as name when route has no name", () => {
vi.mocked(evaluateRaqbLogic).mockReturnValue(RaqbLogicResult.MATCH);
const form = createMockForm([
{ id: "route-123" },
{ id: "fallback", isFallback: true },
]);
const result = findMatchingRoute({
form,
response: createMockResponse(),
routingFormTraceService: mockRoutingFormTrace,
});
expect(result).not.toBeNull();
expect(mockRoutingFormTrace.routeMatched).toHaveBeenCalledWith({
routeId: "route-123",
routeName: "route-123",
});
});
it("should not call trace methods when routingFormTrace is not provided", () => {
vi.mocked(evaluateRaqbLogic).mockReturnValue(RaqbLogicResult.MATCH);
const form = createMockForm([
{ id: "route-1", name: "Sales Route" },
{ id: "fallback", isFallback: true },
]);
const result = findMatchingRoute({
form,
response: createMockResponse(),
});
expect(result).not.toBeNull();
expect(mockRoutingFormTrace.routeMatched).not.toHaveBeenCalled();
expect(mockRoutingFormTrace.fallbackRouteUsed).not.toHaveBeenCalled();
});
it("should handle LOGIC_NOT_FOUND_SO_MATCHED as a match", () => {
vi.mocked(evaluateRaqbLogic).mockReturnValue(RaqbLogicResult.LOGIC_NOT_FOUND_SO_MATCHED);
const form = createMockForm([
{ id: "route-1", name: "Auto Match Route" },
{ id: "fallback", isFallback: true },
]);
const result = findMatchingRoute({
form,
response: createMockResponse(),
routingFormTraceService: mockRoutingFormTrace,
});
expect(result).not.toBeNull();
expect(mockRoutingFormTrace.routeMatched).toHaveBeenCalledWith({
routeId: "route-1",
routeName: "Auto Match Route",
});
});
});
});
@@ -1,11 +1,10 @@
"use client";
import type { JsonTree } from "react-awesome-query-builder";
import type { z } from "zod";
import type { RoutingFormTraceService } from "@calcom/features/routing-trace/domains/RoutingFormTraceService";
import { evaluateRaqbLogic, RaqbLogicResult } from "@calcom/lib/raqb/evaluateRaqbLogic";
import type { App_RoutingForms_Form } from "@calcom/prisma/client";
import type { JsonTree } from "react-awesome-query-builder";
import type { z } from "zod";
import type { FormResponse, Route, SerializableForm } from "../types/types";
import type { zodNonRouterRoute } from "../zod";
import { getQueryBuilderConfigForFormFields } from "./getQueryBuilderConfig";
@@ -15,9 +14,11 @@ import isRouter from "./isRouter";
export function findMatchingRoute({
form,
response,
routingFormTraceService,
}: {
form: Pick<SerializableForm<App_RoutingForms_Form>, "routes" | "fields">;
response: Record<string, Pick<FormResponse[string], "value">>;
routingFormTraceService?: RoutingFormTraceService;
}) {
const queryBuilderConfig = getQueryBuilderConfigForFormFields(form);
@@ -67,5 +68,21 @@ export function findMatchingRoute({
return null;
}
if (routingFormTraceService) {
let routeName: string;
if ("name" in chosenRoute && chosenRoute.name) {
routeName = chosenRoute.name;
} else if (isFallbackRoute(chosenRoute)) {
routeName = "default_route";
} else {
routeName = chosenRoute.id;
}
if (isFallbackRoute(chosenRoute)) {
routingFormTraceService.fallbackRouteUsed({ routeId: chosenRoute.id, routeName });
} else {
routingFormTraceService.routeMatched({ routeId: chosenRoute.id, routeName });
}
}
return chosenRoute;
}
@@ -1256,4 +1256,250 @@ describe("findTeamMembersMatchingAttributeLogic", () => {
// Should not match anyone as the option ID doesn't exist
expect(result).toEqual([]);
});
describe("routingFormTrace integration", () => {
it("should call attributeLogicEvaluated when routingFormTrace is provided and main logic matches", async () => {
const Option1OfAttribute1 = { id: "opt1", value: "Option 1", slug: "option-1" };
const Attribute1 = {
id: "attr1",
name: "Attribute 1",
type: "SINGLE_SELECT" as const,
slug: "attribute-1",
options: [Option1OfAttribute1],
};
mockAttributesScenario({
attributes: [Attribute1],
teamMembersWithAttributeOptionValuePerAttribute: [
{ userId: 1, attributes: { [Attribute1.id]: Option1OfAttribute1.value } },
],
});
const attributesQueryValue = buildSelectTypeFieldQueryValue({
rules: [
{
raqbFieldId: Attribute1.id,
value: [Option1OfAttribute1.id],
operator: "select_equals",
},
],
}) as AttributesQueryValue;
const mockRoutingFormTrace = {
attributeLogicEvaluated: vi.fn(),
attributeFallbackUsed: vi.fn(),
routeMatched: vi.fn(),
fallbackRouteUsed: vi.fn(),
};
await findTeamMembersMatchingAttributeLogic(
{
dynamicFieldValueOperands: {
fields: [],
response: {},
},
attributesQueryValue,
teamId: 1,
orgId,
routeName: "Test Route",
routeIsFallback: false,
},
{
routingFormTraceService: mockRoutingFormTrace as never,
}
);
expect(mockRoutingFormTrace.attributeLogicEvaluated).toHaveBeenCalledWith(
expect.objectContaining({
routeName: "Test Route",
routeIsFallback: false,
checkedFallback: false,
})
);
});
it("should call attributeLogicEvaluated with checkedFallback=true when fallback is used", async () => {
const Option1OfAttribute1 = { id: "opt1", value: "Option 1", slug: "option-1" };
const Option2OfAttribute1 = { id: "opt2", value: "Option 2", slug: "option-2" };
const Attribute1 = {
id: "attr1",
name: "Attribute 1",
type: "SINGLE_SELECT" as const,
slug: "attribute-1",
options: [Option1OfAttribute1, Option2OfAttribute1],
};
mockAttributesScenario({
attributes: [Attribute1],
teamMembersWithAttributeOptionValuePerAttribute: [
{ userId: 1, attributes: { [Attribute1.id]: Option1OfAttribute1.value } },
],
});
const failingAttributesQueryValue = buildSelectTypeFieldQueryValue({
rules: [
{
raqbFieldId: Attribute1.id,
value: [Option2OfAttribute1.id],
operator: "select_equals",
},
],
}) as AttributesQueryValue;
const matchingFallbackQueryValue = buildSelectTypeFieldQueryValue({
rules: [
{
raqbFieldId: Attribute1.id,
value: [Option1OfAttribute1.id],
operator: "select_equals",
},
],
}) as AttributesQueryValue;
const mockRoutingFormTrace = {
attributeLogicEvaluated: vi.fn(),
attributeFallbackUsed: vi.fn(),
routeMatched: vi.fn(),
fallbackRouteUsed: vi.fn(),
};
await findTeamMembersMatchingAttributeLogic(
{
dynamicFieldValueOperands: {
fields: [],
response: {},
},
attributesQueryValue: failingAttributesQueryValue,
fallbackAttributesQueryValue: matchingFallbackQueryValue,
teamId: 1,
orgId,
routeName: "Test Route",
routeIsFallback: false,
},
{
routingFormTraceService: mockRoutingFormTrace as never,
}
);
expect(mockRoutingFormTrace.attributeLogicEvaluated).toHaveBeenCalledWith(
expect.objectContaining({
routeName: "Test Route",
routeIsFallback: false,
checkedFallback: true,
})
);
});
it("should not call attributeLogicEvaluated when routingFormTrace is not provided", async () => {
const Option1OfAttribute1 = { id: "opt1", value: "Option 1", slug: "option-1" };
const Attribute1 = {
id: "attr1",
name: "Attribute 1",
type: "SINGLE_SELECT" as const,
slug: "attribute-1",
options: [Option1OfAttribute1],
};
mockAttributesScenario({
attributes: [Attribute1],
teamMembersWithAttributeOptionValuePerAttribute: [
{ userId: 1, attributes: { [Attribute1.id]: Option1OfAttribute1.value } },
],
});
const attributesQueryValue = buildSelectTypeFieldQueryValue({
rules: [
{
raqbFieldId: Attribute1.id,
value: [Option1OfAttribute1.id],
operator: "select_equals",
},
],
}) as AttributesQueryValue;
const mockRoutingFormTrace = {
attributeLogicEvaluated: vi.fn(),
attributeFallbackUsed: vi.fn(),
routeMatched: vi.fn(),
fallbackRouteUsed: vi.fn(),
};
await findTeamMembersMatchingAttributeLogic(
{
dynamicFieldValueOperands: {
fields: [],
response: {},
},
attributesQueryValue,
teamId: 1,
orgId,
},
{}
);
expect(mockRoutingFormTrace.attributeLogicEvaluated).not.toHaveBeenCalled();
});
it("should include attributeRoutingDetails in trace when attributes are used", async () => {
const Option1OfAttribute1 = { id: "opt1", value: "Enterprise", slug: "enterprise" };
const Attribute1 = {
id: "attr1",
name: "Company Size",
type: "SINGLE_SELECT" as const,
slug: "company-size",
options: [Option1OfAttribute1],
};
mockAttributesScenario({
attributes: [Attribute1],
teamMembersWithAttributeOptionValuePerAttribute: [
{ userId: 1, attributes: { [Attribute1.id]: Option1OfAttribute1.value } },
],
});
const attributesQueryValue = buildSelectTypeFieldQueryValue({
rules: [
{
raqbFieldId: Attribute1.id,
value: [Option1OfAttribute1.id],
operator: "select_equals",
},
],
}) as AttributesQueryValue;
const mockRoutingFormTrace = {
attributeLogicEvaluated: vi.fn(),
attributeFallbackUsed: vi.fn(),
routeMatched: vi.fn(),
fallbackRouteUsed: vi.fn(),
};
await findTeamMembersMatchingAttributeLogic(
{
dynamicFieldValueOperands: {
fields: [],
response: {},
},
attributesQueryValue,
teamId: 1,
orgId,
routeName: "Enterprise Route",
routeIsFallback: false,
},
{
routingFormTraceService: mockRoutingFormTrace as never,
}
);
expect(mockRoutingFormTrace.attributeLogicEvaluated).toHaveBeenCalledWith(
expect.objectContaining({
attributeRoutingDetails: expect.arrayContaining([
expect.objectContaining({
attributeName: "Company Size",
}),
]),
})
);
});
});
});
@@ -1,20 +1,15 @@
import { acrossQueryValueCompatiblity, raqbQueryValueUtils } from "@calcom/app-store/_utils/raqb/raqbUtils";
import type { Attribute } from "@calcom/app-store/routing-forms/types/types";
import { getAttributesAssignmentData } from "@calcom/features/attributes/lib/getAttributes";
import type { RoutingFormTraceService } from "@calcom/features/routing-trace/domains/RoutingFormTraceService";
import { RaqbLogicResult } from "@calcom/lib/raqb/evaluateRaqbLogic";
import jsonLogic from "@calcom/lib/raqb/jsonLogic";
import type { AttributesQueryValue, dynamicFieldValueOperands } from "@calcom/lib/raqb/types";
import async from "async";
import type { ImmutableTree, JsonLogicResult, JsonTree } from "react-awesome-query-builder";
import type { Config } from "react-awesome-query-builder/lib";
import { Utils as QbUtils } from "react-awesome-query-builder/lib";
import { acrossQueryValueCompatiblity, raqbQueryValueUtils } from "@calcom/app-store/_utils/raqb/raqbUtils";
import type { Attribute } from "@calcom/app-store/routing-forms/types/types";
import { getAttributesAssignmentData } from "@calcom/features/attributes/lib/getAttributes";
import type { RoutingTraceService } from "@calcom/features/routing-trace/services/RoutingTraceService";
import {
ROUTING_TRACE_DOMAINS,
ROUTING_TRACE_STEPS,
} from "@calcom/features/routing-trace/services/RoutingTraceService";
import { RaqbLogicResult } from "@calcom/lib/raqb/evaluateRaqbLogic";
import jsonLogic from "@calcom/lib/raqb/jsonLogic";
import type { dynamicFieldValueOperands, AttributesQueryValue } from "@calcom/lib/raqb/types";
const {
getAttributesData: getAttributes,
getAttributesQueryBuilderConfigHavingListofLabels,
@@ -41,7 +36,7 @@ type RunAttributeLogicOptions = {
enableTroubleshooter: boolean;
};
export const enum TroubleshooterCase {
export enum TroubleshooterCase {
IS_A_ROUTER = "is-a-router",
NO_LOGIC_FOUND = "no-logic-found",
MATCH_RESULTS_READY = "match-results-ready",
@@ -467,11 +462,11 @@ export async function findTeamMembersMatchingAttributeLogic(
enablePerf?: boolean;
concurrency?: number;
enableTroubleshooter?: boolean;
routingTraceService?: RoutingTraceService;
routingFormTraceService?: RoutingFormTraceService;
} = {}
) {
// Higher value of concurrency might not be performant as it might overwhelm the system. So, use a lower value as default.
const { enablePerf = false, concurrency = 2, enableTroubleshooter = false, routingTraceService } = options;
const { enablePerf = false, concurrency = 2, enableTroubleshooter = false, routingFormTraceService } = options;
// Any explicit value being passed should cause fallback to be considered. Even undefined
const considerFallback = "fallbackAttributesQueryValue" in data;
@@ -528,22 +523,18 @@ export async function findTeamMembersMatchingAttributeLogic(
// Helper to add trace step for attribute logic evaluation
const addTraceStep = (checkedFallback: boolean) => {
if (routingTraceService) {
if (routingFormTraceService) {
const attributeRoutingDetails = extractAttributeRoutingDetails({
resolvedAttributesQueryValue,
attributesOfTheOrg,
dynamicFieldValueOperands,
});
routingTraceService.addStep({
domain: ROUTING_TRACE_DOMAINS.ROUTING_FORM,
step: ROUTING_TRACE_STEPS.ATTRIBUTE_LOGIC_EVALUATED,
data: {
routeName,
routeIsFallback,
checkedFallback,
attributeRoutingDetails,
},
routingFormTraceService.attributeLogicEvaluated({
routeName,
routeIsFallback,
checkedFallback,
attributeRoutingDetails,
});
}
};
@@ -1,10 +1,6 @@
// !IMPORTANT! changes to this file requires publishing new version of platform libraries in order for the changes to be applied to APIV2
import { createHash } from "node:crypto";
import type { GetServerSidePropsContext } from "next";
import { stringify } from "node:querystring";
import { v4 as uuidv4 } from "uuid";
import z from "zod";
import { enrichFormWithMigrationData } from "@calcom/app-store/routing-forms/enrichFormWithMigrationData";
import { getAbsoluteEventTypeRedirectUrlWithEmbedSupport } from "@calcom/app-store/routing-forms/getEventTypeRedirectUrl";
import { getResponseToStore } from "@calcom/app-store/routing-forms/lib/getResponseToStore";
@@ -17,6 +13,7 @@ import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomain
import { isAuthorizedToViewFormOnOrgDomain } from "@calcom/features/routing-forms/lib/isAuthorizedToViewForm";
import { PrismaRoutingFormRepository } from "@calcom/features/routing-forms/repositories/PrismaRoutingFormRepository";
import { getRoutingTraceService } from "@calcom/features/routing-trace/di/RoutingTraceService.container";
import { RoutingFormTraceService } from "@calcom/features/routing-trace/domains/RoutingFormTraceService";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { HttpError } from "@calcom/lib/http-error";
@@ -24,9 +21,10 @@ import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { withReporting } from "@calcom/lib/sentryWrapper";
import prisma from "@calcom/prisma";
import { TRPCError } from "@trpc/server";
import type { GetServerSidePropsContext } from "next";
import { v4 as uuidv4 } from "uuid";
import z from "zod";
import { getUrlSearchParamsToForward } from "./getUrlSearchParamsToForward";
import { handleResponse } from "./handleResponse";
@@ -37,9 +35,7 @@ const querySchema = z
})
.catchall(z.string().or(z.array(z.string())));
const getDeterministicHashForResponse = (
fieldsResponses: Record<string, unknown>
) => {
const getDeterministicHashForResponse = (fieldsResponses: Record<string, unknown>) => {
const sortedFields = Object.keys(fieldsResponses)
.sort()
.reduce((obj: Record<string, unknown>, key) => {
@@ -56,10 +52,7 @@ export function hasEmbedPath(pathWithQuery: string) {
return onlyPath.endsWith("/embed") || onlyPath.endsWith("/embed/");
}
const _getRoutedUrl = async (
context: Pick<GetServerSidePropsContext, "query" | "req">,
fetchCrm = true
) => {
const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" | "req">, fetchCrm = true) => {
// Initialize trace service for tracking routing decisions
const routingTraceService = getRoutingTraceService();
@@ -96,9 +89,7 @@ const _getRoutedUrl = async (
const paramsToBeForwardedAsIs = {
...fieldsResponses,
// Must be forwarded if present to Booking Page. Setting it explicitly here as it is critical to be present in the URL.
...(isBookingDryRunParam
? { "cal.isBookingDryRun": isBookingDryRunParam }
: null),
...(isBookingDryRunParam ? { "cal.isBookingDryRun": isBookingDryRunParam } : null),
};
const { currentOrgDomain } = orgDomainConfig(context.req);
@@ -106,8 +97,7 @@ const _getRoutedUrl = async (
let timeTaken: Record<string, number | null> = {};
const formQueryStart = performance.now();
const form =
await PrismaRoutingFormRepository.findFormByIdIncludeUserTeamAndOrg(formId);
const form = await PrismaRoutingFormRepository.findFormByIdIncludeUserTeamAndOrg(formId);
timeTaken.formQuery = performance.now() - formQueryStart;
if (!form) {
@@ -150,7 +140,11 @@ const _getRoutedUrl = async (
fieldsResponses,
});
const matchingRoute = findMatchingRoute({ form: serializableForm, response });
let routingFormTraceService: RoutingFormTraceService | undefined;
if (!isBookingDryRun) {
routingFormTraceService = new RoutingFormTraceService(routingTraceService);
}
const matchingRoute = findMatchingRoute({ form: serializableForm, response, routingFormTraceService });
if (!matchingRoute) {
throw new Error("No matching route could be found");
}
@@ -175,9 +169,9 @@ const _getRoutedUrl = async (
queueFormResponse: shouldQueueFormResponse,
fetchCrm,
traceService: isBookingDryRun ? undefined : routingTraceService,
routingFormTraceService,
});
teamMembersMatchingAttributeLogic =
result.teamMembersMatchingAttributeLogic;
teamMembersMatchingAttributeLogic = result.teamMembersMatchingAttributeLogic;
formResponseId = result.formResponse?.id;
queuedFormResponseId = result.queuedFormResponse?.id;
attributeRoutingConfig = result.attributeRoutingConfig;
@@ -266,9 +260,7 @@ const _getRoutedUrl = async (
} else if (decidedAction.type === "externalRedirectUrl") {
return {
redirect: {
destination: `${decidedAction.value}?${stringify(
context.query
)}&cal.action=externalRedirectUrl`,
destination: `${decidedAction.value}?${stringify(context.query)}&cal.action=externalRedirectUrl`,
permanent: false,
},
};
@@ -1,11 +1,10 @@
import { z } from "zod";
import routerGetCrmContactOwnerEmail from "@calcom/app-store/routing-forms/lib/crmRouting/routerGetCrmContactOwnerEmail";
import {
onSubmissionOfFormResponse,
type TargetRoutingFormForResponse,
} from "@calcom/app-store/routing-forms/lib/formSubmissionUtils";
import isRouter from "@calcom/app-store/routing-forms/lib/isRouter";
import type { RoutingFormTraceService } from "@calcom/features/routing-trace/domains/RoutingFormTraceService";
import type { RoutingTraceService } from "@calcom/features/routing-trace/services/RoutingTraceService";
import { emailSchema } from "@calcom/lib/emailSchema";
import { HttpError } from "@calcom/lib/http-error";
@@ -15,7 +14,7 @@ import { withReporting } from "@calcom/lib/sentryWrapper";
import { RoutingFormResponseRepository } from "@calcom/lib/server/repository/formResponse";
import { prisma } from "@calcom/prisma";
import { Prisma } from "@calcom/prisma/client";
import { z } from "zod";
import { findTeamMembersMatchingAttributeLogic } from "./findTeamMembersMatchingAttributeLogic";
const moduleLogger = logger.getSubLogger({ prefix: ["routing-forms/lib/handleResponse"] });
@@ -31,6 +30,7 @@ const _handleResponse = async ({
queueFormResponse,
fetchCrm,
traceService,
routingFormTraceService,
}: {
response: Record<
string,
@@ -48,6 +48,7 @@ const _handleResponse = async ({
queueFormResponse?: boolean;
fetchCrm?: boolean;
traceService?: RoutingTraceService;
routingFormTraceService?: RoutingFormTraceService;
}) => {
try {
if (!form.fields) {
@@ -151,7 +152,7 @@ const _handleResponse = async ({
},
{
enablePerf: true,
routingTraceService: traceService,
routingFormTraceService,
}
)
: null;
@@ -0,0 +1,126 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RoutingTraceService } from "../services/RoutingTraceService";
import { ROUTING_TRACE_DOMAINS } from "../services/RoutingTraceService";
import { ROUTING_FORM_STEPS, RoutingFormTraceService } from "./RoutingFormTraceService";
describe("RoutingFormTraceService", () => {
let mockTraceService: RoutingTraceService;
let routingFormTraceService: RoutingFormTraceService;
beforeEach(() => {
vi.clearAllMocks();
mockTraceService = {
addStep: vi.fn(),
} as unknown as RoutingTraceService;
routingFormTraceService = new RoutingFormTraceService(mockTraceService);
});
describe("routeMatched", () => {
it("should add a route_matched step with correct data", () => {
routingFormTraceService.routeMatched({
routeId: "route-123",
routeName: "Sales Route",
});
expect(mockTraceService.addStep).toHaveBeenCalledWith({
domain: ROUTING_TRACE_DOMAINS.ROUTING_FORM,
step: ROUTING_FORM_STEPS.ROUTE_MATCHED,
data: {
routeId: "route-123",
routeName: "Sales Route",
},
});
});
});
describe("fallbackRouteUsed", () => {
it("should add a fallback_route_used step with correct data", () => {
routingFormTraceService.fallbackRouteUsed({
routeId: "fallback-route",
routeName: "Default Route",
});
expect(mockTraceService.addStep).toHaveBeenCalledWith({
domain: ROUTING_TRACE_DOMAINS.ROUTING_FORM,
step: ROUTING_FORM_STEPS.FALLBACK_ROUTE_USED,
data: {
routeId: "fallback-route",
routeName: "Default Route",
},
});
});
});
describe("attributeLogicEvaluated", () => {
it("should add an attribute-logic-evaluated step with routing details", () => {
routingFormTraceService.attributeLogicEvaluated({
routeName: "Enterprise Route",
routeIsFallback: false,
checkedFallback: true,
attributeRoutingDetails: [
{ attributeName: "Company Size", attributeValue: "Enterprise" },
{ attributeName: "Region", attributeValue: "APAC" },
],
});
expect(mockTraceService.addStep).toHaveBeenCalledWith({
domain: ROUTING_TRACE_DOMAINS.ROUTING_FORM,
step: ROUTING_FORM_STEPS.ATTRIBUTE_LOGIC_EVALUATED,
data: {
routeName: "Enterprise Route",
routeIsFallback: false,
checkedFallback: true,
attributeRoutingDetails: [
{ attributeName: "Company Size", attributeValue: "Enterprise" },
{ attributeName: "Region", attributeValue: "APAC" },
],
},
});
});
it("should add an attribute-logic-evaluated step with minimal data", () => {
routingFormTraceService.attributeLogicEvaluated({});
expect(mockTraceService.addStep).toHaveBeenCalledWith({
domain: ROUTING_TRACE_DOMAINS.ROUTING_FORM,
step: ROUTING_FORM_STEPS.ATTRIBUTE_LOGIC_EVALUATED,
data: {},
});
});
});
describe("attributeFallbackUsed", () => {
it("should add an attribute_fallback_used step with route name", () => {
routingFormTraceService.attributeFallbackUsed({
routeName: "Fallback Route",
});
expect(mockTraceService.addStep).toHaveBeenCalledWith({
domain: ROUTING_TRACE_DOMAINS.ROUTING_FORM,
step: ROUTING_FORM_STEPS.ATTRIBUTE_FALLBACK_USED,
data: {
routeName: "Fallback Route",
},
});
});
it("should add an attribute_fallback_used step with undefined route name", () => {
routingFormTraceService.attributeFallbackUsed({});
expect(mockTraceService.addStep).toHaveBeenCalledWith({
domain: ROUTING_TRACE_DOMAINS.ROUTING_FORM,
step: ROUTING_FORM_STEPS.ATTRIBUTE_FALLBACK_USED,
data: {},
});
});
});
describe("ROUTING_FORM_STEPS constants", () => {
it("should have correct step values", () => {
expect(ROUTING_FORM_STEPS.ROUTE_MATCHED).toBe("route_matched");
expect(ROUTING_FORM_STEPS.FALLBACK_ROUTE_USED).toBe("fallback_route_used");
expect(ROUTING_FORM_STEPS.ATTRIBUTE_LOGIC_EVALUATED).toBe("attribute-logic-evaluated");
expect(ROUTING_FORM_STEPS.ATTRIBUTE_FALLBACK_USED).toBe("attribute_fallback_used");
});
});
});
@@ -0,0 +1,50 @@
import type { RoutingTraceService } from "../services/RoutingTraceService";
import { ROUTING_TRACE_DOMAINS } from "../services/RoutingTraceService";
export const ROUTING_FORM_STEPS = {
ROUTE_MATCHED: "route_matched",
FALLBACK_ROUTE_USED: "fallback_route_used",
ATTRIBUTE_LOGIC_EVALUATED: "attribute-logic-evaluated",
ATTRIBUTE_FALLBACK_USED: "attribute_fallback_used",
} as const;
export class RoutingFormTraceService {
constructor(private readonly traceService: RoutingTraceService) {}
routeMatched(data: { routeId: string; routeName: string }): void {
this.traceService.addStep({
domain: ROUTING_TRACE_DOMAINS.ROUTING_FORM,
step: ROUTING_FORM_STEPS.ROUTE_MATCHED,
data,
});
}
fallbackRouteUsed(data: { routeId: string; routeName: string }): void {
this.traceService.addStep({
domain: ROUTING_TRACE_DOMAINS.ROUTING_FORM,
step: ROUTING_FORM_STEPS.FALLBACK_ROUTE_USED,
data,
});
}
attributeLogicEvaluated(data: {
routeName?: string;
routeIsFallback?: boolean;
checkedFallback?: boolean;
attributeRoutingDetails?: Array<{ attributeName: string; attributeValue: string }>;
}): void {
this.traceService.addStep({
domain: ROUTING_TRACE_DOMAINS.ROUTING_FORM,
step: ROUTING_FORM_STEPS.ATTRIBUTE_LOGIC_EVALUATED,
data,
});
}
attributeFallbackUsed(data: { routeName?: string }): void {
this.traceService.addStep({
domain: ROUTING_TRACE_DOMAINS.ROUTING_FORM,
step: ROUTING_FORM_STEPS.ATTRIBUTE_FALLBACK_USED,
data,
});
}
}