rybbit/server/src/tracker/pageviewQueue.ts
Bill Yang ca0faeb484
Add goal management functionality in analytics (#100)
* Add goal management functionality in analytics

- Introduced goal creation, deletion, and updating capabilities in the analytics API, allowing users to define conversion goals based on path or event types.
- Implemented corresponding React hooks for managing goals, including fetching, creating, updating, and deleting goals.
- Enhanced the UI with a dedicated Goals page and components for listing and managing goals, improving user experience in tracking conversions.
- Updated package dependencies to include necessary libraries for form handling and validation.

* Enhance goals management with pagination and sorting

- Added pagination and sorting capabilities to the goals fetching logic in the analytics API, allowing users to navigate through goals more efficiently.
- Updated the GoalsPage component to manage current page state and handle page changes, improving user experience.
- Modified the GoalsList component to display pagination metadata and navigation controls, facilitating better goal management.
- Adjusted the server-side getGoals function to support pagination and sorting parameters, ensuring accurate data retrieval.

* Refactor GoalsPage and GoalCard components for improved UI and functionality

- Updated GoalsPage to include a SubHeader component and adjusted layout for better responsiveness.
- Enhanced loading and empty state handling in GoalsPage for a smoother user experience.
- Modified GoalCard to use icons for goal types, improving visual clarity and consistency in the UI.

* Refactor CreateGoalButton and GoalCard components for improved modal handling

- Updated CreateGoalButton to utilize a trigger prop for the GoalFormModal, simplifying modal state management.
- Refactored GoalCard to integrate GoalFormModal for editing goals, enhancing user interaction and reducing state complexity.
- Removed unnecessary state management and modal handling from both components, streamlining the codebase.

* Refactor GoalCard and Clickhouse initialization for improved code clarity

- Removed unnecessary imports in GoalCard component, streamlining the code.
- Updated Clickhouse initialization to include a new 'props' JSON field alongside 'properties', enhancing data structure for analytics.
- Added a utility function in PageviewQueue to parse properties, improving error handling and data integrity.

* enable clickhouse

* fix ch build

* fix ch build

* fix ch build

* wip

* wip

* wip

* Enable json

* add network

* add network

* Refactor Clickhouse configuration and remove unused properties from data models

* Refactor property value handling in analytics queries to utilize native JSON types in ClickHouse, improving type safety and performance.
2025-04-28 20:58:43 -07:00

141 lines
4.5 KiB
TypeScript

import { DateTime } from "luxon";
import clickhouse from "../db/clickhouse/clickhouse.js";
import { TrackingPayload } from "../types.js";
import { getDeviceType } from "../utils.js";
import { getChannel } from "./getChannel.js";
import { clearSelfReferrer, getUTMParams } from "./trackingUtils.js";
import { getLocation } from "../db/geolocation/geolocation.js";
type TotalPayload = TrackingPayload & {
userId: string;
timestamp: string;
sessionId: string;
ua: UAParser.IResult;
referrer: string;
ipAddress: string;
type?: string;
event_name?: string;
properties?: string;
};
const getParsedProperties = (properties: string | undefined) => {
try {
return properties ? JSON.parse(properties) : undefined;
} catch (error) {
return undefined;
}
};
class PageviewQueue {
private queue: TotalPayload[] = [];
private batchSize = 5000;
private interval = 10000; // 10 seconds
private processing = false;
constructor() {
// Start processing interval
setInterval(() => this.processQueue(), this.interval);
}
async add(pageview: TotalPayload) {
this.queue.push(pageview);
}
private async processQueue() {
if (this.processing || this.queue.length === 0) return;
this.processing = true;
// Get batch of pageviews
const batch = this.queue.splice(0, this.batchSize);
const ips = [...new Set(batch.map((pv) => pv.ipAddress))];
let geoData: Record<string, { data: any }> = {};
try {
// Process each IP to get geo data using local implementation
const geoPromises = ips.map(async (ip) => {
const locationData = await getLocation(ip);
return { ip, locationData };
});
const results = await Promise.all(geoPromises);
// Format results to match expected structure
results.forEach(({ ip, locationData }) => {
geoData[ip] = { data: locationData };
});
} catch (error) {
console.error("Error getting geo data:", error);
}
// Process each pageview with its geo data
const processedPageviews = batch.map((pv) => {
const dataForIp = geoData?.[pv.ipAddress];
const countryCode = dataForIp?.data?.countryIso || "";
const regionCode = dataForIp?.data?.subdivisions?.[0]?.isoCode || "";
const latitude = dataForIp?.data?.latitude || 0;
const longitude = dataForIp?.data?.longitude || 0;
const city = dataForIp?.data?.city || "";
// Check if referrer is from the same domain and clear it if so
let referrer = clearSelfReferrer(pv.referrer || "", pv.hostname || "");
// Get UTM parameters
const utmParams = getUTMParams(pv.querystring || "");
return {
site_id: pv.site_id,
timestamp: DateTime.fromISO(pv.timestamp).toFormat(
"yyyy-MM-dd HH:mm:ss"
),
session_id: pv.sessionId,
user_id: pv.userId,
hostname: pv.hostname || "",
pathname: pv.pathname || "",
querystring: pv.querystring || "",
page_title: pv.page_title || "",
referrer: referrer,
channel: getChannel(referrer, pv.querystring, pv.hostname),
browser: pv.ua.browser.name || "",
browser_version: pv.ua.browser.major || "",
operating_system: pv.ua.os.name || "",
operating_system_version: pv.ua.os.version || "",
language: pv.language || "",
screen_width: pv.screenWidth || 0,
screen_height: pv.screenHeight || 0,
device_type: getDeviceType(pv.screenWidth, pv.screenHeight, pv.ua),
country: countryCode,
region: countryCode && regionCode ? countryCode + "-" + regionCode : "",
city: city || "",
lat: latitude || 0,
lon: longitude || 0,
type: pv.type || "pageview",
event_name: pv.event_name || "",
props: getParsedProperties(pv.properties),
utm_source: utmParams["utm_source"] || "",
utm_medium: utmParams["utm_medium"] || "",
utm_campaign: utmParams["utm_campaign"] || "",
utm_term: utmParams["utm_term"] || "",
utm_content: utmParams["utm_content"] || "",
};
});
console.info("bulk insert: ", processedPageviews.length);
// Bulk insert into database
try {
await clickhouse.insert({
table: "events",
values: processedPageviews,
format: "JSONEachRow",
});
} catch (error) {
console.error("Error processing pageview queue:", error);
} finally {
this.processing = false;
}
}
}
// Create singleton instance
export const pageviewQueue = new PageviewQueue();