← Public packages
@kody/fathom-analytics
Read Fathom Analytics site stats, aggregation reports, and current visitors.
src/index.ts
409 lines · 13.5 KB · TypeScriptconst 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)
}
}