Skip to content
← Public packages

@kody/fathom-analytics

Read Fathom Analytics site stats, aggregation reports, and current visitors.

src/index.ts

409 lines · 13.5 KB · TypeScript
const API_BASE = 'https://api.usefathom.com/v1'
const SECRET_API_TOKEN = '{{secret:FATHOM_ANALYTICS_API_TOKEN|scope=user}}'

export type FathomRequestOptions = {
	method?: string
	query?: Record<string, unknown>
	body?: Record<string, unknown> | URLSearchParams | string
	headers?: Record<string, string>
	maxAttempts?: number
}

export type FathomListInput = {
	limit?: number
	startingAfter?: string
	endingBefore?: string
}

export type FathomAggregateFilter = {
	property: string
	operator: 'is' | 'is not' | 'is like' | 'is not like' | 'matching' | 'not matching'
	value: string
}

export type FathomAggregateInput = {
	/** `pageview` (default) or `event`. */
	entity?: 'pageview' | 'event'
	/** Site id for pageview entities, or an event tracking code for event entities. */
	entityId?: string
	/** Site id shorthand; used as `entity_id` for pageviews or `site_id` for events. */
	siteId?: string
	/** Event name (with `siteId`) for event entities, instead of an event tracking code. */
	entityName?: string
	/**
	 * Comma-separated string or array. Pageviews: visits, uniques, pageviews,
	 * avg_duration, bounce_rate. Events: conversions, unique_conversions, value (cents).
	 */
	aggregates?: string | Array<string>
	/** hour (ranges of up to 7 days), day, month, or year. Omit for totals. */
	dateGrouping?: 'hour' | 'day' | 'month' | 'year'
	/** Comma-separated string or array, e.g. `hostname,pathname`. */
	fieldGrouping?: string | Array<string>
	/** `field:asc|desc`, e.g. `pageviews:desc` (or `timestamp:asc` with dateGrouping). */
	sortBy?: string
	/** Timestamp like `2022-04-01 15:31:00` in the site's reporting timezone. */
	dateFrom?: string
	/** Timestamp like `2022-04-01 15:31:00` in the site's reporting timezone. Default: now. */
	dateTo?: string
	/** Row limit; set one when grouping by high-cardinality fields like pathname. */
	limit?: number
	filters?: Array<FathomAggregateFilter>
}

export type FathomInput = {
	action?: string
	siteId?: string
	eventId?: string
	detailed?: boolean
	days?: number
	topLimit?: number
	path?: string
	options?: FathomRequestOptions
	[key: string]: unknown
}

function requireString(value: unknown, name: string): string {
	if (typeof value !== 'string' || value.trim() === '') {
		throw new Error(name + ' is required.')
	}
	return value.trim()
}

function toNumber(value: unknown): number {
	const numeric = Number(value)
	return Number.isFinite(numeric) ? numeric : 0
}

function sleep(ms: number): Promise<void> {
	return new Promise((resolve) => setTimeout(resolve, ms))
}

function parseJsonIfPossible(text: string): unknown {
	const trimmed = text.trim()
	if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return text
	try {
		return JSON.parse(trimmed)
	} catch {
		return text
	}
}

function csv(value: string | Array<string> | undefined): string | undefined {
	if (value === undefined) return undefined
	return Array.isArray(value) ? value.join(',') : value
}

/**
 * Compact authenticated Fathom Analytics request: query serialization, bearer
 * secret header, JSON parsing, and retries on 429/5xx honoring Retry-After.
 * Every request counts as one pageview against the Fathom plan.
 */
export async function request(
	path: string,
	{ method = 'GET', query, body, headers, maxAttempts = 3 }: FathomRequestOptions = {},
): Promise<unknown> {
	const url = new URL(
		path
			? path.startsWith('http')
				? path
				: API_BASE + (path.startsWith('/') ? path : '/' + path)
			: API_BASE + '/account',
	)
	if (url.origin !== 'https://api.usefathom.com' || !url.pathname.startsWith('/v1')) {
		throw new Error('Fathom requests must stay on https://api.usefathom.com/v1.')
	}
	for (const [key, value] of Object.entries(query ?? {})) {
		if (value === undefined || value === null || value === '' || value === false) continue
		url.searchParams.append(key, String(value))
	}
	const initBody =
		body && !(body instanceof URLSearchParams) && typeof body === 'object'
			? new URLSearchParams(
					Object.entries(body).flatMap(([key, value]) =>
						value === undefined || value === null ? [] : [[key, String(value)] as [string, string]],
					),
				)
			: body
	let lastError: Error | undefined
	for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
		const response = await fetch(url, {
			method,
			headers: {
				authorization: 'Bearer ' + SECRET_API_TOKEN,
				accept: 'application/json',
				...(initBody instanceof URLSearchParams
					? { 'content-type': 'application/x-www-form-urlencoded' }
					: {}),
				...headers,
			},
			body: initBody as BodyInit | undefined,
		})
		const text = await response.text()
		const contentType = response.headers.get('content-type') || ''
		// Some endpoints (e.g. /token) return JSON without a JSON content-type.
		const parsed: unknown = contentType.includes('application/json')
			? text
				? JSON.parse(text)
				: null
			: parseJsonIfPossible(text)
		if (response.ok) return parsed
		lastError = new Error(
			'Fathom ' +
				response.status +
				' for ' +
				url.pathname +
				': ' +
				(typeof parsed === 'string' ? parsed : JSON.stringify(parsed ?? '')).slice(0, 500),
		)
		if (!(response.status === 429 || response.status >= 500) || attempt >= maxAttempts) {
			throw lastError
		}
		const retryAfter = response.headers.get('retry-after')
		const retryMs = retryAfter
			? Number.isFinite(Number(retryAfter))
				? Number(retryAfter) * 1000
				: Math.max(0, Date.parse(retryAfter) - Date.now())
			: Math.min(5000, 500 * 2 ** (attempt - 1))
		await sleep(retryMs)
	}
	throw lastError
}

/** Account that owns the API token. Requires full account access (`*`). */
export async function getAccount(): Promise<unknown> {
	return await request('/account')
}

/** Metadata about the API token itself (name, abilities, timestamps). */
export async function getToken(): Promise<unknown> {
	return await request('/token')
}

function listQuery(input: FathomListInput = {}) {
	return {
		limit: input.limit ?? 100,
		starting_after: input.startingAfter,
		ending_before: input.endingBefore,
	}
}

/** List sites the token can read. Returns `{ sites, hasMore, raw }`. */
export async function listSites(input: FathomListInput = {}): Promise<unknown> {
	const response = (await request('/sites', { query: listQuery(input) })) as {
		data?: Array<Record<string, unknown>>
		has_more?: boolean
	}
	return {
		sites: (response.data ?? []).map((site) => ({
			id: site.id,
			name: site.name,
			sharing: site.sharing,
			timezone: site.timezone,
			createdAt: site.created_at,
		})),
		hasMore: response.has_more ?? false,
		raw: response,
	}
}

export async function getSite(input: FathomInput = {}): Promise<unknown> {
	const siteId = requireString(input.siteId ?? input.id, 'siteId')
	return await request('/sites/' + encodeURIComponent(siteId))
}

/** List custom events for a site. Returns `{ events, hasMore, raw }`. */
export async function listEvents(input: FathomInput & FathomListInput = {}): Promise<unknown> {
	const siteId = requireString(input.siteId ?? input.id, 'siteId')
	const response = (await request('/sites/' + encodeURIComponent(siteId) + '/events', {
		query: listQuery(input),
	})) as { data?: Array<Record<string, unknown>>; has_more?: boolean }
	return {
		events: (response.data ?? []).map((event) => ({
			id: event.id,
			name: event.name,
			currency: event.currency,
			createdAt: event.created_at,
		})),
		hasMore: response.has_more ?? false,
		raw: response,
	}
}

export async function getEvent(input: FathomInput = {}): Promise<unknown> {
	const siteId = requireString(input.siteId, 'siteId')
	const eventId = requireString(input.eventId ?? input.id, 'eventId')
	return await request(
		'/sites/' + encodeURIComponent(siteId) + '/events/' + encodeURIComponent(eventId),
	)
}

/**
 * Flexible aggregation report over pageviews or events. Numeric values in the
 * response are strings (Fathom API behavior). Only accurate for data from
 * March 2021 onwards.
 */
export async function aggregate(input: FathomAggregateInput = {}): Promise<unknown> {
	const entity = input.entity ?? 'pageview'
	const entityId = input.entityId ?? (entity === 'pageview' ? input.siteId : undefined)
	if (entity === 'pageview') requireString(entityId, 'entityId (site id)')
	return await request('/aggregations', {
		query: {
			entity,
			entity_id: entityId,
			site_id: entity === 'event' && !input.entityId ? input.siteId : undefined,
			entity_name: entity === 'event' && !input.entityId ? input.entityName : undefined,
			aggregates: csv(input.aggregates) ?? (entity === 'pageview' ? 'pageviews' : 'conversions'),
			date_grouping: input.dateGrouping,
			field_grouping: csv(input.fieldGrouping),
			sort_by: input.sortBy,
			date_from: input.dateFrom,
			date_to: input.dateTo,
			limit: input.limit,
			filters: input.filters?.length ? JSON.stringify(input.filters) : undefined,
		},
	})
}

/** Current visitor count for a site; `detailed: true` adds top pages and referrers. */
export async function getCurrentVisitors(input: FathomInput = {}): Promise<unknown> {
	const siteId = requireString(input.siteId ?? input.id, 'siteId')
	return await request('/current_visitors', {
		query: { site_id: siteId, detailed: input.detailed === true },
	})
}

function dateKeyDaysAgo(days: number): string {
	const date = new Date(Date.now() - days * 86_400_000)
	return date.toISOString().slice(0, 10)
}

/**
 * Briefing-ready traffic summary for one site over the trailing N days
 * (default 7): totals, top pages, and top referrers. Costs 3 API requests.
 * The date window uses calendar days (UTC) against the site's reporting timezone.
 */
export async function getSiteTrafficSummary(input: FathomInput = {}): Promise<unknown> {
	const siteId = requireString(input.siteId ?? input.id, 'siteId')
	const days = Math.max(1, Math.floor(toNumber(input.days) || 7))
	const topLimit = Math.max(1, Math.floor(toNumber(input.topLimit) || 10))
	const dateFrom = dateKeyDaysAgo(days - 1) + ' 00:00:00'
	const [totalsRows, topPages, topReferrers] = await Promise.all([
		aggregate({
			siteId,
			aggregates: 'visits,uniques,pageviews,avg_duration,bounce_rate',
			dateFrom,
		}) as Promise<Array<Record<string, unknown>>>,
		aggregate({
			siteId,
			aggregates: 'pageviews,uniques',
			fieldGrouping: 'pathname',
			sortBy: 'pageviews:desc',
			limit: topLimit,
			dateFrom,
		}) as Promise<Array<Record<string, unknown>>>,
		aggregate({
			siteId,
			aggregates: 'pageviews,uniques',
			fieldGrouping: 'referrer_hostname',
			sortBy: 'pageviews:desc',
			limit: topLimit,
			dateFrom,
		}) as Promise<Array<Record<string, unknown>>>,
	])
	const totals = totalsRows[0] ?? {}
	return {
		siteId,
		window: { days, dateFrom },
		totals: {
			visits: toNumber(totals.visits),
			uniques: toNumber(totals.uniques),
			pageviews: toNumber(totals.pageviews),
			avgDurationSeconds: toNumber(totals.avg_duration),
			bounceRate: toNumber(totals.bounce_rate),
		},
		topPages: topPages.map((row) => ({
			pathname: row.pathname,
			pageviews: toNumber(row.pageviews),
			uniques: toNumber(row.uniques),
		})),
		topReferrers: topReferrers.map((row) => ({
			referrerHostname: row.referrer_hostname,
			pageviews: toNumber(row.pageviews),
			uniques: toNumber(row.uniques),
		})),
	}
}

/** Cheap end-to-end check: token metadata plus readable sites. Costs 2 API requests. */
export async function smokeTest(): Promise<unknown> {
	const [token, sites] = await Promise.all([
		getToken() as Promise<Record<string, unknown>>,
		listSites({ limit: 100 }) as Promise<{ sites: Array<Record<string, unknown>> }>,
	])
	return {
		token: { name: token.name, abilities: token.abilities, expiresAt: token.expires_at },
		siteCount: sites.sites.length,
		sites: sites.sites.map((site) => ({ id: site.id, name: site.name })),
	}
}

export function getOverview() {
	return {
		name: 'fathom-analytics',
		description:
			'Fathom Analytics helpers for account, sites, events, aggregation reports, current visitors, and traffic summaries.',
		commonActions: [
			'smokeTest',
			'listSites',
			'aggregate',
			'getCurrentVisitors',
			'getSiteTrafficSummary',
			'listEvents',
			'request',
		],
		notes: [
			'Every API request counts as one pageview against the Fathom plan; keep call counts low.',
			'Aggregation responses return numeric values as strings; getSiteTrafficSummary converts them.',
			'Mutations (create/update/delete sites, events, milestones) have no dedicated helpers; use the raw request helper only when the user explicitly asks.',
			'Rate limits are per account (free tier: 600 requests/hour, 5 concurrent); 429s are retried automatically honoring Retry-After.',
		],
	}
}

/**
 * Dispatch Fathom Analytics helpers by action name. Defaults to `overview`.
 * @example
 * import fathom from 'kody:@kody/fathom-analytics'
 * const summary = await fathom({ action: 'getSiteTrafficSummary', siteId: 'SITE_ID', days: 7 })
 */
export default async function fathomAnalytics(input: FathomInput = {}): Promise<unknown> {
	const action = input.action ?? 'overview'
	switch (action) {
		case 'overview':
			return getOverview()
		case 'smokeTest':
			return await smokeTest()
		case 'request':
			return await request(requireString(input.path, 'path'), input.options ?? {})
		case 'getAccount':
			return await getAccount()
		case 'getToken':
			return await getToken()
		case 'listSites':
			return await listSites(input as FathomListInput)
		case 'getSite':
			return await getSite(input)
		case 'listEvents':
			return await listEvents(input)
		case 'getEvent':
			return await getEvent(input)
		case 'aggregate':
			return await aggregate(input as FathomAggregateInput)
		case 'getCurrentVisitors':
			return await getCurrentVisitors(input)
		case 'getSiteTrafficSummary':
			return await getSiteTrafficSummary(input)
		default:
			throw new Error('Unknown fathom-analytics action: ' + action)
	}
}