Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
e8996e9
Add metadata filtering to analytics & events wip
devkiran Jul 16, 2025
fea11d2
Update analytics.ts
devkiran Jul 16, 2025
fd9f27c
Merge branch 'main' into filter-events-by-metadata
devkiran Jul 17, 2025
2d5934a
Add filter extraction and processing for event metadata in analytics
devkiran Jul 17, 2025
9aefa03
Implement query filter parsing for analytics and events; refactor fil…
devkiran Jul 17, 2025
5a36767
Update analytics-query-parser.ts
devkiran Jul 17, 2025
127ca9c
Update analytics.ts
devkiran Jul 17, 2025
1c26a43
Merge branch 'main' into filter-events-by-metadata
devkiran Jul 17, 2025
a2287c2
Merge branch 'main' into filter-events-by-metadata
devkiran Jul 18, 2025
8ce567b
support Stripe-like query filter
devkiran Jul 18, 2025
8c69b57
Update analytics-query-parser.ts
devkiran Jul 18, 2025
d6c229b
Update analytics-query-parser.test.ts
devkiran Jul 18, 2025
9e7bd00
Refactor analytics query parser to use descriptive operator names and…
devkiran Jul 18, 2025
76d5670
Update analytics-query-parser.ts
devkiran Jul 18, 2025
01dab20
fix tests
devkiran Jul 18, 2025
a244e8a
Merge branch 'main' into filter-events-by-metadata
devkiran Jul 19, 2025
284925f
Merge branch 'main' into filter-events-by-metadata
steven-tey Jul 19, 2025
56de4b2
Merge branch 'main' into filter-events-by-metadata
steven-tey Jul 20, 2025
b99c72d
Merge branch 'main' into filter-events-by-metadata
steven-tey Jul 21, 2025
200e1c3
Merge branch 'main' into filter-events-by-metadata
steven-tey Jul 22, 2025
ad5aede
Merge branch 'main' into filter-events-by-metadata
devkiran Jul 25, 2025
4d76c88
Merge branch 'main' into filter-events-by-metadata
steven-tey Jul 28, 2025
090552a
Merge branch 'main' into filter-events-by-metadata
devkiran Aug 4, 2025
ccaa34f
Merge branch 'main' into filter-events-by-metadata
steven-tey Aug 4, 2025
cfed824
Merge branch 'main' into filter-events-by-metadata
steven-tey Aug 4, 2025
4dd148f
rearrange parseFiltersFromQuery
steven-tey Aug 4, 2025
cfdc5cf
finalize schemas
steven-tey Aug 4, 2025
a240dc5
add metadata column
steven-tey Aug 4, 2025
1bb0850
Merge branch 'main' into filter-events-by-metadata
steven-tey Aug 4, 2025
c9ee2b9
add metadata.productId filter test
steven-tey Aug 4, 2025
d6ece15
parseFiltersFromQuery → queryParser
steven-tey Aug 4, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion apps/web/app/(ee)/api/customers/[id]/activity/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ export const GET = withWorkspace(async ({ workspace, params }) => {

const events = await getCustomerEvents({
customerId: customer.id,
includeMetadata: true,
});

// get the first partner link that this customer interacted with
Expand Down
1 change: 1 addition & 0 deletions apps/web/app/(ee)/api/events/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { eventsQuerySchema } from "@/lib/zod/schemas/analytics";
import { Folder, Link } from "@dub/prisma/client";
import { NextResponse } from "next/server";

// GET /api/events
export const GET = withWorkspace(
async ({ searchParams, workspace, session }) => {
throwIfClicksUsageExceeded(workspace);
Expand Down
22 changes: 15 additions & 7 deletions apps/web/lib/analytics/get-analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
DIMENSIONAL_ANALYTICS_FILTERS,
SINGULAR_ANALYTICS_ENDPOINTS,
} from "./constants";
import { queryParser } from "./query-parser";
import { AnalyticsFilters } from "./types";
import { getStartEndDates } from "./utils/get-start-end-dates";

Expand All @@ -32,6 +33,7 @@ export const getAnalytics = async (params: AnalyticsFilters) => {
timezone = "UTC",
isDeprecatedClicksEndpoint = false,
dataAvailableFrom,
query,
} = params;

const tagIds = combineTagIds(params);
Expand Down Expand Up @@ -100,6 +102,8 @@ export const getAnalytics = async (params: AnalyticsFilters) => {
: analyticsResponse[groupBy],
});

const filters = queryParser(query);

const response = await pipe({
...params,
...(UTM_TAGS_PLURAL_LIST.includes(groupBy)
Expand All @@ -115,6 +119,7 @@ export const getAnalytics = async (params: AnalyticsFilters) => {
timezone,
country,
region,
filters: filters ? JSON.stringify(filters) : undefined,
});

if (groupBy === "count") {
Expand Down Expand Up @@ -207,13 +212,16 @@ export const getAnalytics = async (params: AnalyticsFilters) => {
},
});

return topPartnersData.map((item) => {
const partner = partners.find((p) => p.id === item.partnerId);
return {
...item,
partner,
};
});
return topPartnersData
.map((item) => {
const partner = partners.find((p) => p.id === item.partnerId);
if (!partner) return null;
return {
...item,
partner,
};
})
.filter((d) => d !== null);
}

// Return array for other endpoints
Expand Down
23 changes: 5 additions & 18 deletions apps/web/lib/analytics/get-customer-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,15 @@ import {
clickEventResponseSchema,
clickEventSchema,
} from "../zod/schemas/clicks";
import {
leadEventResponseSchema,
leadEventResponseSchemaExtended,
} from "../zod/schemas/leads";
import {
saleEventResponseSchema,
saleEventResponseSchemaExtended,
} from "../zod/schemas/sales";
import { leadEventResponseSchema } from "../zod/schemas/leads";
import { saleEventResponseSchema } from "../zod/schemas/sales";

export const getCustomerEvents = async ({
customerId,
linkIds,
includeMetadata,
}: {
customerId: string;
linkIds?: string[];
includeMetadata?: boolean;
}) => {
const pipe = tb.buildPipe({
pipe: "v2_customer_events",
Expand Down Expand Up @@ -68,6 +60,7 @@ export const getCustomerEvents = async ({
? {
eventId: evt.event_id,
eventName: evt.event_name,
metadata: evt.metadata ? JSON.parse(evt.metadata) : undefined,
...(evt.event === "sale"
? {
sale: {
Expand All @@ -83,14 +76,8 @@ export const getCustomerEvents = async ({

return {
click: clickEventResponseSchema,
lead: (includeMetadata
? leadEventResponseSchemaExtended
: leadEventResponseSchema
).omit({ customer: true }),
sale: (includeMetadata
? saleEventResponseSchemaExtended
: saleEventResponseSchema
).omit({ customer: true }),
lead: leadEventResponseSchema.omit({ customer: true }),
sale: saleEventResponseSchema.omit({ customer: true }),
}[evt.event].parse(eventData);
})
.filter((d) => d !== null);
Expand Down
6 changes: 6 additions & 0 deletions apps/web/lib/analytics/get-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
saleEventResponseSchema,
saleEventSchemaTBEndpoint,
} from "../zod/schemas/sales";
import { queryParser } from "./query-parser";
import { EventsFilters } from "./types";
import { getStartEndDates } from "./utils/get-start-end-dates";

Expand All @@ -39,6 +40,7 @@ export const getEvents = async (params: EventsFilters) => {
order,
sortOrder,
dataAvailableFrom,
query,
} = params;

const { startDate, endDate } = getStartEndDates({
Expand Down Expand Up @@ -74,6 +76,8 @@ export const getEvents = async (params: EventsFilters) => {
}[eventType] ?? clickEventSchemaTBEndpoint,
});

const filters = queryParser(query);

const response = await pipe({
...params,
eventType,
Expand All @@ -85,6 +89,7 @@ export const getEvents = async (params: EventsFilters) => {
offset: (params.page - 1) * params.limit,
start: startDate.toISOString().replace("T", " ").replace("Z", ""),
end: endDate.toISOString().replace("T", " ").replace("Z", ""),
filters: filters ? JSON.stringify(filters) : undefined,
});

const [linksMap, customersMap] = await Promise.all([
Expand Down Expand Up @@ -130,6 +135,7 @@ export const getEvents = async (params: EventsFilters) => {
? {
eventId: evt.event_id,
eventName: evt.event_name,
metadata: evt.metadata ? JSON.parse(evt.metadata) : undefined,
customer: customersMap[evt.customer_id] ?? {
id: evt.customer_id,
name: "Deleted Customer",
Expand Down
126 changes: 126 additions & 0 deletions apps/web/lib/analytics/query-parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { EventsFilters } from "./types";

interface InternalFilter {
operand: string;
operator:
| "equals"
| "notEquals"
| "greaterThan"
| "lessThan"
| "greaterThanOrEqual"
| "lessThanOrEqual";
value: string;
}

// Query parser that can parse the query string into a list of filters
export const queryParser = (
query: EventsFilters["query"],
allowedOperands = ["metadata"],
) => {
if (!query) {
return undefined;
}

const filters: InternalFilter[] = [];

// Split the query by logical operators (AND/OR) to handle multiple conditions
// For now, we'll focus on single conditions, but this structure allows for future expansion
const conditions = query.split(/\s+(?:AND|and|OR|or)\s+/);

for (const condition of conditions) {
const trimmedCondition = condition.trim();

if (!trimmedCondition) {
continue;
}

const filter = parseCondition(trimmedCondition);

if (!filter) {
continue;
}

const isAllowed = allowedOperands.some((allowed) => {
if (filter.operand === allowed) {
return true;
}

if (filter.operand.startsWith(`${allowed}.`)) {
return true;
}

return false;
});

if (!isAllowed) {
continue;
}

filters.push(filter);
}

return filters.length > 0 ? filters : undefined;
};

// Parses a single condition in the format: field:value, field>value, or metadata['key']:value
function parseCondition(condition: string): InternalFilter | null {
// This regex captures:
// 1. field - either a regular field name OR metadata with bracket notation (supports both single and double quotes)
// 2. operator - :, >, <, >=, <=, !=
// 3. value - the value after the operator (supports quoted and unquoted values)
const unifiedPattern =
/^([a-zA-Z_][a-zA-Z0-9_]*|metadata\[['"][^'"]*['"]\](?:\[['"][^'"]*['"]\])*)\s*([:><=!]+)\s*(.+)$/;

const match = condition.match(unifiedPattern);

if (!match) {
return null;
}

// Extract the matched groups
const [, fieldOrMetadata, operator, value] = match;

let operand: string;

// Determine the operand based on whether it's metadata or a regular field
if (fieldOrMetadata.startsWith("metadata")) {
const keyPath = fieldOrMetadata.replace(/^metadata/, "");

const extractedKey = keyPath
.replace(/^\[['"]|['"]\]$/g, "") // Remove leading [' or [" and trailing '] or "]
.replace(/\[['"]/g, ".") // Replace [' or [" with .
.replace(/['"]\]/g, ""); // Remove trailing '] or "]

operand = `metadata.${extractedKey}`;
Comment on lines +89 to +94
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Potential security risk with bracket notation parsing.

The bracket notation parsing logic uses multiple regex replacements that could be vulnerable to malformed input. Consider using a more robust parsing approach or adding input validation to prevent potential issues.

Apply this diff to add input validation:

  if (fieldOrMetadata.startsWith("metadata")) {
+   // Validate bracket notation format
+   if (!/^metadata(\[['"][^'"]*['"]\])+$/.test(fieldOrMetadata)) {
+     return null;
+   }
    const keyPath = fieldOrMetadata.replace(/^metadata/, "");

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In apps/web/lib/analytics/query-parser.ts around lines 89 to 94, the current
bracket notation parsing uses regex replacements that may be vulnerable to
malformed input. To fix this, add input validation before performing the
replacements to ensure the keyPath matches expected patterns or sanitize it
properly. Consider implementing a stricter parser or validation function that
checks for allowed characters and structure in keyPath to prevent injection or
parsing errors.

} else {
operand = fieldOrMetadata;
}

return {
operand,
operator: mapOperator(operator),
value: value.trim().replace(/^['"`]|['"`]$/g, ""),
};
}

// Maps operator strings to our internal operator types
function mapOperator(operator: string): InternalFilter["operator"] {
switch (operator) {
case ":":
case "=":
return "equals";
case ">":
return "greaterThan";
case "<":
return "lessThan";
case ">=":
return "greaterThanOrEqual";
case "<=":
return "lessThanOrEqual";
case "!=":
return "notEquals";
default:
// For unsupported operators, default to equals
return "equals";
}
}
14 changes: 14 additions & 0 deletions apps/web/lib/zod/schemas/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,16 @@ export const analyticsQuerySchema = z
.describe(
"Filter sales by type: 'new' for first-time purchases, 'recurring' for repeat purchases. If undefined, returns both.",
),
query: z
.string()
.max(10000)
.optional()
.describe(
"Search the events by a custom metadata value. Only available for lead and sale events.",
)
.openapi({
example: "metadata['key']:'value'",
}),
})
.merge(utmTagsSchema);

Expand Down Expand Up @@ -261,6 +271,10 @@ export const analyticsFilterTB = z
.optional()
.describe("The folder IDs to retrieve analytics for."),
isMegaFolder: z.boolean().optional(),
filters: z
.string()
.optional()
.describe("The filters to apply to the analytics."),
})
.merge(
analyticsQuerySchema.pick({
Expand Down
14 changes: 3 additions & 11 deletions apps/web/lib/zod/schemas/leads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,30 +119,22 @@ export const leadEventSchemaTBEndpoint = z.object({
referer_url_processed: z.string().nullable(),
qr: z.number().nullable(),
ip: z.string().nullable(),
metadata: z.string().nullish(),
});

// response from dub api
export const leadEventResponseSchema = z
.object({
event: z.literal("lead"),
timestamp: z.coerce.string(),
// core event fields
eventId: z.string(),
eventName: z.string(),
metadata: z.any().nullish(),
// nested objects
click: clickEventSchema,
link: linkEventSchema,
customer: CustomerSchema,
})
.merge(commonDeprecatedEventFields)
.openapi({ ref: "LeadEvent", title: "LeadEvent" });

export const leadEventResponseSchemaExtended = leadEventResponseSchema.merge(
z.object({
metadata: z
.string()
.nullish()
.transform((val) => (val === "" ? null : val))
.default(null)
.openapi({ type: "string" }),
}),
);
Loading