All files / lib/analytics queries.ts

100% Statements 28/28
100% Branches 22/22
100% Functions 8/8
100% Lines 27/27

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226                                  45x   45x                 45x 1x 1x         44x             44x                                                         9x     9x                                 9x 9x             9x   9x 7x     7x                                                                 7x                   2x     2x     2x         9x                               21x                   21x                       10x   10x                             10x 10x   10x                
/**
 * Analytics Engine Query Functions
 * Functions for querying Cloudflare Analytics Engine via SQL API
 */
 
import type { AnalyticsQueryResult, ProStats, ProjectStats } from "./types";
import { getDateRangeUTC } from "./date-helpers";
 
/**
 * Execute a SQL query against Analytics Engine
 * Uses the Cloudflare Analytics Engine SQL API
 */
export async function executeAnalyticsQuery(
	accountId: string,
	apiToken: string,
	query: string,
): Promise<AnalyticsQueryResult> {
	const endpoint = `https://api.cloudflare.com/client/v4/accounts/${accountId}/analytics_engine/sql`;
 
	const response = await fetch(endpoint, {
		method: "POST",
		headers: {
			Authorization: `Bearer ${apiToken}`,
			"Content-Type": "text/plain",
		},
		body: query,
	});
 
	if (!response.ok) {
		const errorText = await response.text();
		throw new Error(
			`Analytics Engine query failed: ${response.status} - ${errorText}`,
		);
	}
 
	const result = (await response.json()) as AnalyticsQueryResult;
	/* v8 ignore start -- defensive guard: API always returns data array */
	if (!Array.isArray(result.data)) {
		console.warn("[Analytics] Unexpected response shape, returning empty");
		return { data: [], meta: [], rows: 0 };
	}
	/* v8 ignore stop */
	return result;
}
 
/**
 * Query Analytics Engine for pro stats
 *
 * Blob mapping (from track.ts):
 * - blob1: event_type (page_view, click)
 * - blob2: entity_type (pro, project, image)
 * - blob3: entity_id
 * - blob4: pro_id / pro_id (for rollups)
 * - blob5: blog_id (for blog events)
 * - blob6: action (whatsapp_form_submit, whatsapp_redirect, callback_form_submit, contact_cta_click)
 * - blob7: session_id
 * - blob8: referrer_source
 * - blob9: referrer_medium
 * - blob10: device_type
 * - blob11: browser
 * - blob12: os
 * - blob13: country
 * - blob14: city
 * - blob15: page_url
 */
export async function queryProStats(
	accountId: string,
	apiToken: string,
	dataset: string,
	date: string,
): Promise<ProStats[]> {
	const [startTime, endTime] = getDateRangeUTC(date);
 
	// Query for pro aggregate stats
	const statsQuery = `
		SELECT
			blob4 as pro_id,
			countIf(blob1 = 'page_view' AND blob2 = 'pro') as page_views,
			countIf(blob1 = 'click' AND blob2 = 'project') as project_clicks,
			countIf(blob1 = 'click' AND blob2 = 'image') as image_clicks,
		countIf(blob1 = 'click' AND blob6 IN ('whatsapp_form_submit', 'whatsapp_redirect')) as whatsapp_clicks,
			countIf(blob1 = 'click' AND blob6 = 'callback_form_submit') as call_clicks,
			countIf(blob1 = 'click' AND blob6 = 'contact_cta_click') as inquiry_clicks,
			COUNT(DISTINCT blob7) as unique_sessions
		FROM "${dataset}"
		WHERE timestamp >= toDateTime('${startTime}')
			AND timestamp < toDateTime('${endTime}')
			AND blob4 != 'unknown'
		GROUP BY blob4
	`;
 
	console.log(`[Analytics Sync] Querying pro stats for ${date}`);
	const statsResult = await executeAnalyticsQuery(
		accountId,
		apiToken,
		statsQuery,
	);
 
	// For each pro, get breakdowns
	const proStats: ProStats[] = [];
 
	for (const row of statsResult.data) {
		const proId = row.pro_id as string;
 
		// Query breakdowns in parallel
		const [sourcesResult, devicesResult, citiesResult] = await Promise.all([
			queryProBreakdown(
				accountId,
				apiToken,
				dataset,
				proId,
				startTime,
				endTime,
				"blob8",
				"source",
			),
			queryProBreakdown(
				accountId,
				apiToken,
				dataset,
				proId,
				startTime,
				endTime,
				"blob10",
				"device",
			),
			queryProBreakdown(
				accountId,
				apiToken,
				dataset,
				proId,
				startTime,
				endTime,
				"blob14",
				"city",
			),
		]);
 
		proStats.push({
			pro_id: proId,
			page_views: Number(row.page_views) || 0,
			project_clicks: Number(row.project_clicks) || 0,
			image_clicks: Number(row.image_clicks) || 0,
			whatsapp_clicks: Number(row.whatsapp_clicks) || 0,
			call_clicks: Number(row.call_clicks) || 0,
			inquiry_clicks: Number(row.inquiry_clicks) || 0,
			unique_sessions: Number(row.unique_sessions) || 0,
			sources: Object.fromEntries(
				sourcesResult.data.map((r) => [r.value as string, Number(r.count)]),
			),
			devices: Object.fromEntries(
				devicesResult.data.map((r) => [r.value as string, Number(r.count)]),
			),
			cities: Object.fromEntries(
				citiesResult.data.map((r) => [r.value as string, Number(r.count)]),
			),
		});
	}
 
	return proStats;
}
 
/**
 * Query breakdown stats for a specific pro
 */
async function queryProBreakdown(
	accountId: string,
	apiToken: string,
	dataset: string,
	proId: string,
	startTime: string,
	endTime: string,
	blobField: string,
	_alias: string,
): Promise<AnalyticsQueryResult> {
	const query = `
		SELECT ${blobField} as value, count() as count
		FROM "${dataset}"
		WHERE timestamp >= toDateTime('${startTime}')
			AND timestamp < toDateTime('${endTime}')
			AND blob4 = '${proId}'
		GROUP BY ${blobField}
		ORDER BY count DESC
		LIMIT 10
	`;
	return executeAnalyticsQuery(accountId, apiToken, query);
}
 
/**
 * Query Analytics Engine for project stats
 */
export async function queryProjectStats(
	accountId: string,
	apiToken: string,
	dataset: string,
	date: string,
): Promise<ProjectStats[]> {
	const [startTime, endTime] = getDateRangeUTC(date);
 
	const query = `
		SELECT
			blob3 as project_id,
			blob4 as pro_id,
			countIf(blob1 = 'page_view') as page_views,
			countIf(blob1 = 'click' AND blob2 = 'image') as image_clicks,
		COUNT(DISTINCT blob7) as unique_sessions
		FROM "${dataset}"
		WHERE timestamp >= toDateTime('${startTime}')
			AND timestamp < toDateTime('${endTime}')
			AND blob2 IN ('project', 'image')
			AND blob3 != 'unknown'
		GROUP BY blob3, blob4
	`;
 
	console.log(`[Analytics Sync] Querying project stats for ${date}`);
	const result = await executeAnalyticsQuery(accountId, apiToken, query);
 
	return result.data.map((row) => ({
		project_id: row.project_id as string,
		pro_id: row.pro_id as string,
		page_views: Number(row.page_views) || 0,
		image_clicks: Number(row.image_clicks) || 0,
		unique_sessions: Number(row.unique_sessions) || 0,
	}));
}