Add audit analytics and campaign metrics
This commit is contained in:
195
lib/rybbit-analytics.ts
Normal file
195
lib/rybbit-analytics.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
export type RybbitEvent = {
|
||||
type?: string;
|
||||
timestamp?: string;
|
||||
pathname?: string;
|
||||
path?: string;
|
||||
url?: string;
|
||||
event_name?: string;
|
||||
name?: string;
|
||||
properties?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type AuditRybbitSummary = {
|
||||
opened: boolean;
|
||||
viewCount: number;
|
||||
lastView: string | null;
|
||||
ctaClicks: number;
|
||||
websiteLinkClicks: number;
|
||||
deviceTypes: string[];
|
||||
};
|
||||
|
||||
type FetchLike = (
|
||||
input: string | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Pick<Response, "ok" | "status" | "json" | "text">>;
|
||||
|
||||
export function buildRybbitEventsUrl(input: {
|
||||
apiUrl: string;
|
||||
siteId: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}) {
|
||||
const base = input.apiUrl.endsWith("/") ? input.apiUrl : `${input.apiUrl}/`;
|
||||
const url = new URL(`api/sites/${encodeURIComponent(input.siteId)}/events`, base);
|
||||
|
||||
if (input.startDate) {
|
||||
url.searchParams.set("start_date", input.startDate);
|
||||
}
|
||||
if (input.endDate) {
|
||||
url.searchParams.set("end_date", input.endDate);
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
function eventPath(event: RybbitEvent) {
|
||||
const propertyPath =
|
||||
typeof event.properties?.pathname === "string"
|
||||
? event.properties.pathname
|
||||
: typeof event.properties?.path === "string"
|
||||
? event.properties.path
|
||||
: undefined;
|
||||
|
||||
return event.pathname ?? event.path ?? propertyPath ?? event.url ?? "";
|
||||
}
|
||||
|
||||
function eventName(event: RybbitEvent) {
|
||||
const propertyName =
|
||||
typeof event.properties?.event_name === "string"
|
||||
? event.properties.event_name
|
||||
: typeof event.properties?.name === "string"
|
||||
? event.properties.name
|
||||
: undefined;
|
||||
|
||||
return event.event_name ?? event.name ?? propertyName ?? "";
|
||||
}
|
||||
|
||||
function eventDevice(event: RybbitEvent) {
|
||||
const value =
|
||||
event.properties?.deviceType ??
|
||||
event.properties?.device_type ??
|
||||
event.properties?.device;
|
||||
|
||||
return typeof value === "string" && value.trim().length > 0
|
||||
? value.trim()
|
||||
: null;
|
||||
}
|
||||
|
||||
function isAuditEvent(event: RybbitEvent, auditPath: string) {
|
||||
const path = eventPath(event);
|
||||
return path === auditPath || path.endsWith(auditPath);
|
||||
}
|
||||
|
||||
export function summarizeAuditRybbitEvents(
|
||||
events: RybbitEvent[],
|
||||
auditPath: string,
|
||||
): AuditRybbitSummary {
|
||||
const matchingEvents = events.filter((event) => isAuditEvent(event, auditPath));
|
||||
const pageviews = matchingEvents.filter((event) => event.type === "pageview");
|
||||
const ctaClicks = matchingEvents.filter((event) => {
|
||||
const name = eventName(event);
|
||||
return event.type === "custom_event" && name === "audit_cta_click";
|
||||
});
|
||||
const websiteLinkClicks = matchingEvents.filter((event) => {
|
||||
const name = eventName(event);
|
||||
return event.type === "outbound_link" || name === "audit_website_link_click";
|
||||
});
|
||||
const lastView = pageviews
|
||||
.map((event) => event.timestamp)
|
||||
.filter((timestamp): timestamp is string => Boolean(timestamp))
|
||||
.sort()
|
||||
.at(-1) ?? null;
|
||||
const deviceTypes = [
|
||||
...new Set(
|
||||
matchingEvents
|
||||
.map(eventDevice)
|
||||
.filter((device): device is string => device !== null),
|
||||
),
|
||||
].sort();
|
||||
|
||||
return {
|
||||
opened: pageviews.length > 0,
|
||||
viewCount: pageviews.length,
|
||||
lastView,
|
||||
ctaClicks: ctaClicks.length,
|
||||
websiteLinkClicks: websiteLinkClicks.length,
|
||||
deviceTypes,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEventsPayload(payload: unknown): RybbitEvent[] {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload.filter((event): event is RybbitEvent => typeof event === "object" && event !== null);
|
||||
}
|
||||
|
||||
if (
|
||||
typeof payload === "object" &&
|
||||
payload !== null &&
|
||||
"data" in payload &&
|
||||
Array.isArray((payload as { data?: unknown }).data)
|
||||
) {
|
||||
return (payload as { data: unknown[] }).data.filter(
|
||||
(event): event is RybbitEvent => typeof event === "object" && event !== null,
|
||||
);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function fetchRybbitAuditAnalytics(input: {
|
||||
apiUrl?: string;
|
||||
apiKey?: string;
|
||||
siteId?: string;
|
||||
auditPath: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
fetchImpl?: FetchLike;
|
||||
}) {
|
||||
if (!input.apiUrl || !input.apiKey || !input.siteId) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: "Rybbit ist nicht vollständig konfiguriert.",
|
||||
data: summarizeAuditRybbitEvents([], input.auditPath),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await (input.fetchImpl ?? fetch)(
|
||||
buildRybbitEventsUrl({
|
||||
apiUrl: input.apiUrl,
|
||||
siteId: input.siteId,
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${input.apiKey}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
return {
|
||||
ok: false as const,
|
||||
error: `Rybbit API Fehler ${response.status}: ${body.slice(0, 160)}`,
|
||||
data: summarizeAuditRybbitEvents([], input.auditPath),
|
||||
};
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
return {
|
||||
ok: true as const,
|
||||
data: summarizeAuditRybbitEvents(
|
||||
normalizeEventsPayload(payload),
|
||||
input.auditPath,
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
data: summarizeAuditRybbitEvents([], input.auditPath),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user