Skip to content

Show events metadata on customers page #2308

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Apr 18, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion apps/web/app/api/customers/[id]/activity/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { prisma } from "@dub/prisma";
import { NextResponse } from "next/server";

// GET /api/customers/[id]/activity - get a customer's activity
export const GET = withWorkspace(async ({ workspace, params, session }) => {
export const GET = withWorkspace(async ({ workspace, params }) => {
const { id: customerId } = params;

const customer = await getCustomerOrThrow({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const GET = withPartnerProfile(async ({ partner, params }) => {
const events = await getCustomerEvents({
customerId: customer.id,
linkIds: links.map((link) => link.id),
hideMetadata: true, // don't expose metadata to partners
});

if (events.length === 0) {
Expand Down
3 changes: 3 additions & 0 deletions apps/web/lib/analytics/get-customer-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ import { saleEventResponseSchema } from "../zod/schemas/sales";
export const getCustomerEvents = async ({
customerId,
linkIds,
hideMetadata = false,
}: {
customerId: string;
linkIds?: string[];
hideMetadata?: boolean;
}) => {
const pipe = tb.buildPipe({
pipe: "v2_customer_events",
Expand Down Expand Up @@ -60,6 +62,7 @@ export const getCustomerEvents = async ({
? {
eventId: evt.event_id,
eventName: evt.event_name,
metadata: hideMetadata ? null : evt.metadata,
...(evt.event === "sale"
? {
sale: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export const verifyAnalyticsAllowedHostnames = ({
allowedHostnames: string[];
req: Request;
}) => {
if (allowedHostnames.length > 0) {
if (allowedHostnames && allowedHostnames.length > 0) {
const source = req.headers.get("referer") || req.headers.get("origin");
const sourceUrl = source ? new URL(source) : null;
const hostname = sourceUrl?.hostname.replace(/^www\./, "");
Expand Down
6 changes: 6 additions & 0 deletions apps/web/lib/zod/schemas/leads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,12 @@ export const leadEventResponseSchema = z
timestamp: z.coerce.string(),
eventId: z.string(),
eventName: z.string(),
metadata: z
.string()
.nullish()
.transform((val) => (val === "" ? null : val))
.default(null)
.openapi({ type: "string" }),
// nested objects
click: clickEventSchema,
link: linkEventSchema,
Expand Down
6 changes: 6 additions & 0 deletions apps/web/lib/zod/schemas/sales.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,12 @@ export const saleEventResponseSchema = z
payment_processor: z
.string()
.describe("Deprecated. Use `sale.paymentProcessor` instead."),
metadata: z
.string()
.nullish()
.transform((val) => (val === "" ? null : val))
.default(null)
.openapi({ type: "string" }),
})
.merge(commonDeprecatedEventFields)
.openapi({ ref: "SaleEvent" });
102 changes: 102 additions & 0 deletions apps/web/ui/analytics/events/metadata-viewer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { Button, Tooltip, useCopyToClipboard } from "@dub/ui";
import { cn, truncate } from "@dub/utils";
import { Check, Copy } from "lucide-react";
import { Fragment } from "react";

// Display the event metadata
export function MetadataViewer({
metadata,
}: {
metadata: Record<string, any>;
}) {
const [copied, copyToClipboard] = useCopyToClipboard();

const displayEntries = Object.entries(metadata)
.map(([key, value]) => {
if (typeof value === "object" && value !== null) {
// Only show nested properties if the parent object has exactly one property
if (Object.keys(metadata).length === 1) {
const nestedEntries = Object.entries(value).map(
([nestedKey, nestedValue]) => {
const displayValue =
typeof nestedValue === "object" && nestedValue !== null
? truncate(JSON.stringify(nestedValue), 20)
: truncate(String(nestedValue), 20);
return `${key}.${nestedKey}: ${displayValue}`;
},
);
// else show the parent object properties
return nestedEntries;
}
return [`${key}: ${truncate(JSON.stringify(value), 20)}`];
}
return [`${key}: ${truncate(String(value), 20)}`];
})
.flat();

const hasMoreItems = displayEntries.length > 3;
const visibleEntries = hasMoreItems
? displayEntries.slice(0, 3)
: displayEntries;

return (
<div className="flex items-center gap-2 text-xs text-neutral-600">
{visibleEntries.map((entry, i) => (
<Fragment key={i}>
<span className="rounded-md border border-neutral-200 bg-neutral-100 px-1.5 py-0.5">
{entry}
</span>
</Fragment>
))}

<Tooltip
content={
<div className="flex flex-col gap-4 overflow-hidden rounded-md border border-neutral-200 bg-white p-4">
<div className="flex h-[200px] w-[280px] overflow-hidden rounded-md border border-neutral-200 bg-white sm:h-[300px] sm:w-[350px]">
<div className="w-full overflow-auto">
<pre className="p-2 text-xs text-neutral-600">
{JSON.stringify(metadata, null, 2)}
</pre>
</div>
</div>
<Button
icon={
<div className="relative size-4">
<div
className={cn(
"absolute inset-0 transition-[transform,opacity]",
copied && "translate-y-1 opacity-0",
)}
>
<Copy className="size-4" />
</div>
<div
className={cn(
"absolute inset-0 transition-[transform,opacity]",
!copied && "translate-y-1 opacity-0",
)}
>
<Check className="size-4" />
</div>
</div>
}
className="h-9"
text={copied ? "Copied metadata" : "Copy metadata"}
onClick={() => copyToClipboard(JSON.stringify(metadata, null, 2))}
/>
</div>
}
align="start"
>
<button
type="button"
className="rounded-md border border-neutral-200 bg-white px-1.5 py-0.5 hover:bg-neutral-50"
>
{hasMoreItems
? `+${displayEntries.length - 3} more`
: "View metadata"}
</button>
</Tooltip>
</div>
);
}
43 changes: 41 additions & 2 deletions apps/web/ui/customers/customer-activity-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { CursorRays, MoneyBill2, UserCheck } from "@dub/ui/icons";
import { formatDateTimeSmart, getApexDomain, getPrettyUrl } from "@dub/utils";
import Link from "next/link";
import { useParams } from "next/navigation";
import { MetadataViewer } from "../analytics/events/metadata-viewer";

const activityData = {
click: {
Expand Down Expand Up @@ -80,8 +81,46 @@ const activityData = {
);
},
},
lead: { icon: UserCheck, content: (event) => event.eventName || "New lead" },
sale: { icon: MoneyBill2, content: (event) => event.eventName || "New sale" },

lead: {
icon: UserCheck,
content: (event) => {
let metadata = null;

try {
metadata = event.metadata ? JSON.parse(event.metadata) : null;
} catch (e) {
//
}

return (
<div className="flex flex-col gap-1">
<span>{event.eventName || "New lead"}</span>
{metadata && <MetadataViewer metadata={metadata} />}
</div>
);
},
},

sale: {
icon: MoneyBill2,
content: (event) => {
let metadata = null;

try {
metadata = event.metadata ? JSON.parse(event.metadata) : null;
} catch (e) {
//
}

return (
<div className="flex flex-col gap-1">
<span>{event.eventName || "New sale"}</span>
{metadata && <MetadataViewer metadata={metadata} />}
</div>
);
},
},
};

export function CustomerActivityList({
Expand Down
74 changes: 40 additions & 34 deletions packages/tinybird/pipes/v2_events.pipe
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,30 @@ DESCRIPTION >
Top countries


TOKEN "v2_events_endpoint_read_6623" READ

TAGS "Dub Endpoints"

NODE workspace_links
SQL >

%
SELECT link_id, domain, key
from dub_links_metadata_latest FINAL
FROM
{% if defined(isMegaFolder) and Boolean(isMegaFolder) == 1 %} dub_links_metadata_latest
{% else %} dub_regular_links_metadata_latest
{% end %} FINAL
WHERE
deleted == 0
{% if defined(workspaceId) %} AND workspace_id = {{ workspaceId }} {% end %}
{% if defined(programId) %} AND program_id = {{ programId }} {% end %}
{% if defined(partnerId) %} AND partner_id = {{ partnerId }} {% end %}
{% if defined(tenantId) %} AND tenant_id = {{ tenantId }} {% end %}
{% if defined(folderId) %} AND folder_id = {{ folderId }} {% end %}
{% if defined(folderIds) %} AND folder_id IN {{ Array(folderIds, 'String') }}
{% elif defined(folderId) %} AND folder_id = {{ folderId }}
{% end %}
{% if defined(domain) %} AND domain IN {{ Array(domain, 'String') }} {% end %}
{% if defined(tagIds) %} AND arrayIntersect(tag_ids, {{ Array(tagIds, 'String') }}) != [] {% end %}
{% if defined(tagIds) %}
AND arrayIntersect(tag_ids, {{ Array(tagIds, 'String') }}) != []
{% end %}
{% if defined(root) %}
{% if Boolean(root) == 1 %} AND key = '_root' {% else %} AND key != '_root' {% end %}
{% end %}
Expand Down Expand Up @@ -54,21 +59,11 @@ SQL >
{% if defined(os) %} AND os = {{ os }} {% end %}
{% if defined(referer) %} AND referer = {{ referer }} {% end %}
{% if defined(refererUrl) %} AND splitByString('?', referer_url)[1] = {{ refererUrl }} {% end %}
{% if defined(utm_source) %}
AND url LIKE concat('%utm_source=', {{ String(utm_source) }}, '%')
{% end %}
{% if defined(utm_medium) %}
AND url LIKE concat('%utm_medium=', {{ String(utm_medium) }}, '%')
{% end %}
{% if defined(utm_campaign) %}
AND url LIKE concat('%utm_campaign=', {{ String(utm_campaign) }}, '%')
{% end %}
{% if defined(utm_term) %}
AND url LIKE concat('%utm_term=', {{ String(utm_term) }}, '%')
{% end %}
{% if defined(utm_content) %}
AND url LIKE concat('%utm_content=', {{ String(utm_content) }}, '%')
{% end %}
{% if defined(utm_source) %} AND url LIKE concat('%utm_source=', encodeURLFormComponent({{ String(utm_source) }}), '%') {% end %}
{% if defined(utm_medium) %} AND url LIKE concat('%utm_medium=', encodeURLFormComponent({{ String(utm_medium) }}), '%') {% end %}
{% if defined(utm_campaign) %} AND url LIKE concat('%utm_campaign=', encodeURLFormComponent({{ String(utm_campaign) }}), '%') {% end %}
{% if defined(utm_term) %} AND url LIKE concat('%utm_term=', encodeURLFormComponent({{ String(utm_term) }}), '%') {% end %}
{% if defined(utm_content) %} AND url LIKE concat('%utm_content=', encodeURLFormComponent({{ String(utm_content) }}), '%') {% end %}
{% if defined(url) %} AND url = {{ url }} {% end %}
ORDER BY timestamp {% if order == 'asc' %} ASC {% else %} DESC {% end %}
LIMIT {{ Int32(limit, 100) }}
Expand All @@ -85,10 +80,10 @@ SQL >
splitByString('?', referer_url)[1] as referer_url_processed,
CONCAT(country, '-', region) as region_processed,
'lead' as event
FROM dub_lead_events_mv
FROM dub_lead_events_mv_new
WHERE
timestamp >= {{ DateTime(start, '2024-06-01 00:00:00') }}
AND timestamp < {{ DateTime(end, '2024-06-07 00:00:00') }}
timestamp >= {{ DateTime(start, '2024-01-01 00:00:00') }}
AND timestamp < {{ DateTime(end, '2025-12-31 00:00:00') }}
{% if defined(linkId) %} AND link_id = {{ String(linkId) }}
{% elif defined(workspaceId) or defined(partnerId) or defined(programId) %}
AND link_id IN (SELECT link_id FROM workspace_links)
Expand All @@ -104,11 +99,22 @@ SQL >
{% if defined(os) %} AND os = {{ os }} {% end %}
{% if defined(referer) %} AND referer = {{ referer }} {% end %}
{% if defined(refererUrl) %} AND splitByString('?', referer_url)[1] = {{ refererUrl }} {% end %}
{% if defined(utm_source) %} AND url LIKE concat('%utm_source=', {{ String(utm_source) }}, '%') {% end %}
{% if defined(utm_medium) %} AND url LIKE concat('%utm_medium=', {{ String(utm_medium) }}, '%') {% end %}
{% if defined(utm_campaign) %} AND url LIKE concat('%utm_campaign=', {{ String(utm_campaign) }}, '%') {% end %}
{% if defined(utm_term) %} AND url LIKE concat('%utm_term=', {{ String(utm_term) }}, '%') {% end %}
{% if defined(utm_content) %} AND url LIKE concat('%utm_content=', {{ String(utm_content) }}, '%') {% end %}
{% if defined(utm_source) %}
AND url LIKE concat('%utm_source=', encodeURLFormComponent({{ String(utm_source) }}), '%')
{% end %}
{% if defined(utm_medium) %}
AND url LIKE concat('%utm_medium=', encodeURLFormComponent({{ String(utm_medium) }}), '%')
{% end %}
{% if defined(utm_campaign) %}
AND url
LIKE concat('%utm_campaign=', encodeURLFormComponent({{ String(utm_campaign) }}), '%')
{% end %}
{% if defined(utm_term) %}
AND url LIKE concat('%utm_term=', encodeURLFormComponent({{ String(utm_term) }}), '%')
{% end %}
{% if defined(utm_content) %}
AND url LIKE concat('%utm_content=', encodeURLFormComponent({{ String(utm_content) }}), '%')
{% end %}
{% if defined(url) %} AND url = {{ url }} {% end %}
ORDER BY timestamp {% if order == 'asc' %} ASC {% else %} DESC {% end %}
LIMIT {{ Int32(limit, 100) }}
Expand All @@ -126,7 +132,7 @@ SQL >
CONCAT(country, '-', region) as region_processed,
splitByString('?', referer_url)[1] as referer_url_processed,
'sale' as event
FROM dub_sale_events_mv
FROM dub_sale_events_mv_new
WHERE
timestamp >= {{ DateTime(start, '2024-06-01 00:00:00') }}
AND timestamp < {{ DateTime(end, '2024-06-07 00:00:00') }}
Expand All @@ -145,11 +151,11 @@ SQL >
{% if defined(os) %} AND os = {{ os }} {% end %}
{% if defined(referer) %} AND referer = {{ referer }} {% end %}
{% if defined(refererUrl) %} AND splitByString('?', referer_url)[1] = {{ refererUrl }} {% end %}
{% if defined(utm_source) %} AND url LIKE concat('%utm_source=', {{ String(utm_source) }}, '%') {% end %}
{% if defined(utm_medium) %} AND url LIKE concat('%utm_medium=', {{ String(utm_medium) }}, '%') {% end %}
{% if defined(utm_campaign) %} AND url LIKE concat('%utm_campaign=', {{ String(utm_campaign) }}, '%') {% end %}
{% if defined(utm_term) %} AND url LIKE concat('%utm_term=', {{ String(utm_term) }}, '%') {% end %}
{% if defined(utm_content) %} AND url LIKE concat('%utm_content=', {{ String(utm_content) }}, '%') {% end %}
{% if defined(utm_source) %} AND url LIKE concat('%utm_source=', encodeURLFormComponent({{ String(utm_source) }}), '%') {% end %}
{% if defined(utm_medium) %} AND url LIKE concat('%utm_medium=', encodeURLFormComponent({{ String(utm_medium) }}), '%') {% end %}
{% if defined(utm_campaign) %} AND url LIKE concat('%utm_campaign=', encodeURLFormComponent({{ String(utm_campaign) }}), '%') {% end %}
{% if defined(utm_term) %} AND url LIKE concat('%utm_term=', encodeURLFormComponent({{ String(utm_term) }}), '%') {% end %}
{% if defined(utm_content) %} AND url LIKE concat('%utm_content=', encodeURLFormComponent({{ String(utm_content) }}), '%') {% end %}
{% if defined(url) %} AND url = {{ url }} {% end %}
ORDER BY timestamp {% if order == 'asc' %} ASC {% else %} DESC {% end %}
LIMIT {{ Int32(limit, 100) }}
Expand Down