Skip to content

Commit 6b9946c

Browse files
committed
fix(analytics): correct identity funnels links and uptime
1 parent 1f70466 commit 6b9946c

38 files changed

Lines changed: 1619 additions & 474 deletions

apps/api/src/integration/profile-handlers.test.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -331,13 +331,69 @@ describe("getTraitDistribution", () => {
331331
});
332332

333333
const distribution = await getTraitDistribution(website.id);
334-
expect(distribution.identifiedProfiles).toBe(3);
334+
expect(distribution).toMatchObject({
335+
hasMoreKeys: false,
336+
hasMoreValues: false,
337+
identifiedProfiles: 3,
338+
returnedTraitKeys: 2,
339+
totalTraitKeys: 2,
340+
valuesPerKey: 20,
341+
});
335342
expect(distribution.traits).toEqual([
336343
{ key: "beta", value: "true", profiles: 1 },
337344
{ key: "plan", value: "pro", profiles: 2 },
338345
{ key: "plan", value: "free", profiles: 1 },
339346
]);
340347
});
348+
349+
iit("gives every returned key a value before adding more values per key", async () => {
350+
const org = await insertOrganization();
351+
const website = await insertWebsite({ organizationId: org.id });
352+
const keys = Array.from({ length: 11 }, (_, index) => `trait_${index}`);
353+
354+
await Promise.all(
355+
Array.from({ length: 20 }, (_, valueIndex) =>
356+
seedProfile(website.id, `user_${valueIndex}`, {
357+
traits: Object.fromEntries(
358+
keys.map((key) => [key, `value_${valueIndex}`])
359+
),
360+
})
361+
)
362+
);
363+
364+
const distribution = await getTraitDistribution(website.id);
365+
expect(distribution).toMatchObject({
366+
hasMoreKeys: false,
367+
hasMoreValues: true,
368+
returnedTraitKeys: 11,
369+
totalTraitKeys: 11,
370+
valuesPerKey: 18,
371+
});
372+
expect(distribution.traits).toHaveLength(198);
373+
expect(new Set(distribution.traits.map((trait) => trait.key))).toEqual(
374+
new Set(keys)
375+
);
376+
});
377+
378+
iit("reports when lower-coverage keys are omitted by the row bound", async () => {
379+
const org = await insertOrganization();
380+
const website = await insertWebsite({ organizationId: org.id });
381+
await seedProfile(website.id, "user_1", {
382+
traits: Object.fromEntries(
383+
Array.from({ length: 201 }, (_, index) => [`trait_${index}`, true])
384+
),
385+
});
386+
387+
const distribution = await getTraitDistribution(website.id);
388+
expect(distribution).toMatchObject({
389+
hasMoreKeys: true,
390+
hasMoreValues: false,
391+
returnedTraitKeys: 200,
392+
totalTraitKeys: 201,
393+
valuesPerKey: 1,
394+
});
395+
expect(distribution.traits).toHaveLength(200);
396+
});
341397
});
342398

343399
describe("resolveTraitSegment", () => {

apps/dashboard/app/(main)/websites/[id]/errors/_components/error-summary-stats.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ export const ErrorSummaryStats = ({
100100
/>
101101
<ErrorStatCard
102102
icon={TrendUpIcon}
103-
title="Sessions with errors"
103+
title="Session error rate"
104104
value={`${(errorSummary.errorRate || 0).toFixed(2)}%`}
105105
variant="warning"
106106
/>

apps/dashboard/app/(main)/websites/[id]/funnels/_components/funnel-analytics.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,10 @@ export function FunnelAnalytics({
110110
total_users_completed: selectedReferrerData.completed_users,
111111
overall_conversion_rate: selectedReferrerData.conversion_rate,
112112
avg_completion_time: 0,
113-
avg_completion_time_formatted: "0s",
113+
avg_completion_time_formatted: "",
114114
biggest_dropoff_step: 1,
115115
biggest_dropoff_rate: 100 - selectedReferrerData.conversion_rate,
116+
duration_available: false,
116117
steps_analytics: [],
117118
}
118119
: data;
@@ -127,6 +128,7 @@ export function FunnelAnalytics({
127128
const errorInsights = data?.error_insights;
128129
const hasErrorCorrelation =
129130
errorInsights &&
131+
errorInsights.available &&
130132
errorInsights.dropoffs_with_errors > 0 &&
131133
errorInsights.error_correlation_rate > 0;
132134

@@ -216,7 +218,7 @@ export function FunnelAnalytics({
216218
v < 60 ? `${Math.round(v)}s` : `${Math.round(v / 60)}m`
217219
}
218220
icon={ClockIcon}
219-
showChart={hasChartData}
221+
showChart={hasChartData && displayData.duration_available}
220222
title="Avg Time"
221223
value={displayData.avg_completion_time_formatted || "—"}
222224
/>

apps/dashboard/app/(main)/websites/[id]/funnels/_components/funnel-flow.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ export function FunnelFlow({ steps }: FunnelFlowProps) {
157157
<span className="truncate font-medium text-foreground">
158158
{step.step_name}
159159
</span>
160-
{step.error_count > 0 && (
160+
{step.error_context_available && step.error_count > 0 && (
161161
<Tooltip
162162
content={
163163
<div className="max-w-xs space-y-1.5">

apps/dashboard/hooks/use-goals.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ export interface GoalAnalyticsData {
1717
avg_completion_time_formatted: string;
1818
biggest_dropoff_rate: number;
1919
biggest_dropoff_step: number;
20+
duration_available: boolean;
2021
error_insights: {
22+
available: boolean;
2123
total_errors: number;
2224
sessions_with_errors: number;
2325
dropoffs_with_errors: number;
@@ -29,6 +31,7 @@ export interface GoalAnalyticsData {
2931
conversion_rate: number;
3032
dropoff_rate: number;
3133
dropoffs: number;
34+
error_context_available: boolean;
3235
error_count: number;
3336
error_rate: number;
3437
step_name: string;

apps/dashboard/types/funnels.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export interface FunnelStepAnalytics {
4343
conversion_rate: number;
4444
dropoff_rate: number;
4545
dropoffs: number;
46+
error_context_available: boolean;
4647
error_count: number;
4748
error_rate: number;
4849
step_name: string;
@@ -53,6 +54,7 @@ export interface FunnelStepAnalytics {
5354
}
5455

5556
export interface FunnelErrorInsights {
57+
available: boolean;
5658
dropoffs_with_errors: number;
5759
error_correlation_rate: number;
5860
sessions_with_errors: number;
@@ -74,6 +76,7 @@ export interface FunnelAnalyticsData {
7476
avg_completion_time_formatted: string;
7577
biggest_dropoff_rate: number;
7678
biggest_dropoff_step: number;
79+
duration_available: boolean;
7780
error_insights?: FunnelErrorInsights;
7881
overall_conversion_rate: number;
7982
steps_analytics: FunnelStepAnalytics[];

packages/ai/src/ai/mcp/tools.ts

Lines changed: 31 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,12 @@ import {
1717
LinkFolderSelectorSchema,
1818
LinkFolderWithUsageSchema,
1919
LinkRowOutputSchema,
20+
getLinkSummary,
2021
listLinkFolders,
2122
listLinks,
2223
parseLinkRow,
2324
resolveLinkFolder,
25+
searchLinks,
2426
summarizeLink,
2527
summarizeLinkFolder,
2628
summarizeLinkFoldersWithUsage,
@@ -943,15 +945,15 @@ const listLinkFoldersTool = defineMcpTool(
943945
}
944946

945947
const rpcContext = buildRpcContext(ctx);
946-
const [folders, links] = await Promise.all([
948+
const [folders, summary] = await Promise.all([
947949
listLinkFolders(rpcContext, orgId),
948-
listLinks(rpcContext, orgId),
950+
getLinkSummary(rpcContext, orgId),
949951
]);
950952

951953
return {
952-
folders: summarizeLinkFoldersWithUsage(folders, links),
954+
folders: summarizeLinkFoldersWithUsage(folders),
953955
count: folders.length,
954-
unfiledCount: links.filter((link) => !link.folderId).length,
956+
unfiledCount: summary.unfiledTotal,
955957
hint:
956958
folders.length === 0
957959
? "No link folders exist yet. Leave links unfiled unless the user creates a folder in Databuddy."
@@ -964,7 +966,7 @@ const listLinksTool = defineMcpTool(
964966
{
965967
name: "list_links",
966968
description:
967-
"List short links and existing folders for the website's organization. Use to enumerate all links before referencing one or choosing where a new link should go.",
969+
"List the newest short links and existing folders for the website's organization. The count covers the full catalog; use search_links to find a specific older link.",
968970
inputSchema: z.object({
969971
...WebsiteSelectorSchema,
970972
}),
@@ -984,24 +986,29 @@ const listLinksTool = defineMcpTool(
984986
throw new McpToolError("not_found", orgId.message);
985987
}
986988
const rpcContext = buildRpcContext(ctx);
987-
const [links, folders] = await Promise.all([
989+
const [page, folders, summary] = await Promise.all([
988990
listLinks(rpcContext, orgId),
989991
listLinkFolders(rpcContext, orgId),
992+
getLinkSummary(rpcContext, orgId),
990993
]);
991-
if (links.length === 0) {
994+
if (page.items.length === 0) {
992995
return {
993-
links,
996+
links: page.items,
994997
count: 0,
995-
folders: summarizeLinkFoldersWithUsage(folders, links),
996-
unfiledCount: 0,
998+
folders: summarizeLinkFoldersWithUsage(folders),
999+
unfiledCount: summary.unfiledTotal,
9971000
hint: "No links yet for this organization.",
9981001
};
9991002
}
10001003
return {
1001-
links: links.map((link) => summarizeLink(link, folders)),
1002-
count: links.length,
1003-
folders: summarizeLinkFoldersWithUsage(folders, links),
1004-
unfiledCount: links.filter((link) => !link.folderId).length,
1004+
links: page.items.map((link) => summarizeLink(link, folders)),
1005+
count: summary.total,
1006+
folders: summarizeLinkFoldersWithUsage(folders, page.items),
1007+
unfiledCount: summary.unfiledTotal,
1008+
hint:
1009+
page.hasMore || summary.total > page.items.length
1010+
? `Showing the ${page.items.length} newest of ${summary.total} links. Use search_links for older links.`
1011+
: undefined,
10051012
};
10061013
}
10071014
);
@@ -1010,12 +1017,14 @@ const searchLinksTool = defineMcpTool(
10101017
{
10111018
name: "search_links",
10121019
description:
1013-
"Find short links matching a substring on name, slug, target URL, or external ID. Use when you know part of a link identifier.",
1020+
"Find short links across the full catalog matching a substring on name, slug, target URL, or external ID. Returns at most the newest 50 matches.",
10141021
inputSchema: z.object({
10151022
...WebsiteSelectorSchema,
10161023
query: z
10171024
.string()
1025+
.trim()
10181026
.min(1)
1027+
.max(255)
10191028
.describe("Search query (matches name, slug, URL, or external ID)"),
10201029
}),
10211030
outputSchema: z.object({
@@ -1031,30 +1040,23 @@ const searchLinksTool = defineMcpTool(
10311040
})
10321041
),
10331042
count: z.number(),
1043+
hasMore: z.boolean(),
10341044
}),
10351045
resolveWebsite: true,
1036-
ratelimit: { limit: 60, windowSec: 60 },
1046+
ratelimit: { limit: 20, windowSec: 60 },
10371047
},
10381048
async (input, ctx) => {
10391049
const orgId = await getOrganizationId(getResolvedWebsiteId(ctx));
10401050
if (orgId instanceof Error) {
10411051
throw new McpToolError("not_found", orgId.message);
10421052
}
10431053
const rpcContext = buildRpcContext(ctx);
1044-
const [allLinks, folders] = await Promise.all([
1045-
listLinks(rpcContext, orgId),
1054+
const [page, folders] = await Promise.all([
1055+
searchLinks(rpcContext, orgId, input.query),
10461056
listLinkFolders(rpcContext, orgId),
10471057
]);
1048-
const queryLower = input.query.toLowerCase();
1049-
const matches = allLinks.filter(
1050-
(link) =>
1051-
link.name.toLowerCase().includes(queryLower) ||
1052-
link.slug.toLowerCase().includes(queryLower) ||
1053-
link.targetUrl.toLowerCase().includes(queryLower) ||
1054-
link.externalId?.toLowerCase().includes(queryLower)
1055-
);
10561058
return {
1057-
links: matches.map((link) => ({
1059+
links: page.items.map((link) => ({
10581060
id: link.id,
10591061
name: link.name,
10601062
slug: link.slug,
@@ -1063,7 +1065,8 @@ const searchLinksTool = defineMcpTool(
10631065
folder: summarizeLink(link, folders).folder,
10641066
externalId: link.externalId,
10651067
})),
1066-
count: matches.length,
1068+
count: page.items.length,
1069+
hasMore: page.hasMore,
10671070
};
10681071
}
10691072
);

packages/ai/src/ai/prompts/clickhouse-schema.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
AGENT_TABLE_COLUMNS,
33
AGENT_TENANT_COLUMN_BY_TABLE,
4+
CUSTOM_EVENTS_VISITOR_KEY,
45
EVENTS_VISITOR_KEY,
56
validateAgentSQL,
67
} from "@databuddy/db/clickhouse";
@@ -191,7 +192,7 @@ const GUIDELINES = `## Query Guidelines
191192
- Geographic data (country, region, city) exists only on analytics.events, NOT on web_vitals_spans or error_spans. Join via session_id if needed.
192193
- All timestamps are in UTC.
193194
- analytics.custom_events.properties contains JSON strings — use JSONExtractString(properties, 'key') to parse.
194-
- Identity: \`anonymous_id\` is a per-device id; \`profile_id\` is the customer-assigned user id ('' when anonymous, on analytics.events, analytics.custom_events, and analytics.revenue). To count people, dedupe identified users across devices with \`uniq(${EVENTS_VISITOR_KEY})\` (on custom_events/revenue wrap the fallback: \`if(profile_id != '', profile_id, ifNull(anonymous_id, ''))\` since anonymous_id is Nullable there). To count only identified users: \`uniqIf(profile_id, profile_id != '')\`. error_spans/web_vitals_spans/outgoing_links have no profile_id — resolve an identified user's rows there via their anonymous_ids from analytics.events.
195+
- Identity: \`anonymous_id\` is a per-device id; \`profile_id\` is the customer-assigned user id ('' when anonymous, on analytics.events, analytics.custom_events, and analytics.revenue). To count people, dedupe identified users across devices with \`uniq(${EVENTS_VISITOR_KEY})\`; on custom_events/revenue use \`uniq(${CUSTOM_EVENTS_VISITOR_KEY})\` because anonymous_id is Nullable and rows missing both identifiers must not count as a person. To count only identified users: \`uniqIf(profile_id, profile_id != '')\`. error_spans/web_vitals_spans/outgoing_links have no profile_id — resolve an identified user's rows there via their anonymous_ids from analytics.events.
195196
196197
## Aggregate function preferences
197198
- Percentiles: use \`quantileTDigest(p)(col)\` for p50/p75/p95/p99. Plain \`quantile(p)\` uses reservoir sampling and is noisy at the tails (~10% error at p99). \`quantileTDigest\` is within 0.1% of exact at the same memory cost.

packages/ai/src/ai/tools/link-catalog.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { describe, expect, it } from "bun:test";
22
import {
33
type LinkFolder,
4+
fetchLinkCatalogPage,
5+
fetchLinkSummary,
46
resolveLinkFolderFromList,
57
} from "./link-catalog";
68

@@ -80,3 +82,45 @@ describe("resolveLinkFolderFromList", () => {
8082
}
8183
});
8284
});
85+
86+
describe("paginated link catalog", () => {
87+
it("uses one bounded server-side search beyond the legacy 1,000-row cap", async () => {
88+
const source = Array.from({ length: 1001 }, (_, index) => ({
89+
id: `link-${index}`,
90+
name: `Example ${index}`,
91+
slug: `example-${index}`,
92+
targetUrl: `https://example.com/${index}`,
93+
}));
94+
const calls: Array<{ limit: number; offset: number; search?: string }> = [];
95+
96+
const page = await fetchLinkCatalogPage(async (input) => {
97+
calls.push(input);
98+
const matches = source.filter((link) =>
99+
link.name.toLowerCase().includes(input.search?.toLowerCase() ?? "")
100+
);
101+
const items = matches.slice(input.offset, input.offset + input.limit);
102+
return {
103+
hasMore: input.offset + items.length < matches.length,
104+
items,
105+
};
106+
}, { search: "Example 1000" });
107+
108+
expect(page.items).toHaveLength(1);
109+
expect(page.items[0]?.id).toBe("link-1000");
110+
expect(page.total).toBeUndefined();
111+
expect(calls).toEqual([
112+
{ limit: 50, offset: 0, search: "Example 1000" },
113+
]);
114+
});
115+
116+
it("loads exact catalog and unfiled totals with one summary call", async () => {
117+
const calls: Array<{ search?: string }> = [];
118+
const summary = await fetchLinkSummary(async (input) => {
119+
calls.push(input);
120+
return { total: 7, unfiledTotal: 3 };
121+
}, "campaign");
122+
123+
expect(summary).toEqual({ total: 7, unfiledTotal: 3 });
124+
expect(calls).toEqual([{ search: "campaign" }]);
125+
});
126+
});

0 commit comments

Comments
 (0)