@liiift-studio/sanity-visitor-insights 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +181 -0
- package/dist/index.d.mts +155 -0
- package/dist/index.d.ts +155 -0
- package/dist/index.js +652 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +609 -0
- package/dist/index.mjs.map +1 -0
- package/dist/ranges-D6AZwpmm.d.mts +282 -0
- package/dist/ranges-D6AZwpmm.d.ts +282 -0
- package/dist/server.d.mts +280 -0
- package/dist/server.d.ts +280 -0
- package/dist/server.js +937 -0
- package/dist/server.js.map +1 -0
- package/dist/server.mjs +882 -0
- package/dist/server.mjs.map +1 -0
- package/package.json +68 -0
- package/src/boundary.test.ts +93 -0
- package/src/core/core.test.ts +175 -0
- package/src/core/cutover.ts +109 -0
- package/src/core/ranges.ts +103 -0
- package/src/core/siteConfig.ts +150 -0
- package/src/index.ts +86 -0
- package/src/reportData.ts +120 -0
- package/src/server/auth.ts +133 -0
- package/src/server/cache.ts +75 -0
- package/src/server/createHandler.ts +199 -0
- package/src/server/ga4.ts +149 -0
- package/src/server/googleAuth.ts +139 -0
- package/src/server/orders.ts +127 -0
- package/src/server/reports/acquisition.ts +85 -0
- package/src/server/reports/journey.ts +112 -0
- package/src/server/reports/measurementHealth.ts +170 -0
- package/src/server/reports/typefaceInterest.ts +140 -0
- package/src/server/vercel.ts +68 -0
- package/src/server.ts +27 -0
- package/src/studio/Figure.tsx +175 -0
- package/src/studio/VisitorInsightsTool.tsx +247 -0
- package/src/studio/panels.tsx +233 -0
- package/src/studio/useReport.ts +99 -0
- package/src/types.ts +135 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request authentication and CORS for the report handler.
|
|
3
|
+
*
|
|
4
|
+
* The Studio forwards its own Sanity session token and this verifies it against Sanity. A shared
|
|
5
|
+
* secret was the obvious alternative and is the wrong one: the Studio bundle is served publicly, so
|
|
6
|
+
* any secret compiled into it is extractable, which moves the bar from "know the URL" to "open
|
|
7
|
+
* devtools". Verifying a session token instead proves the caller is a logged-in project user, and
|
|
8
|
+
* gives a real identity to log rather than an anonymous caller who read a constant.
|
|
9
|
+
*
|
|
10
|
+
* This mirrors the pattern already in production on Darden's order-action endpoints.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Minimal shape this module needs from a Next.js API request. */
|
|
14
|
+
export interface HandlerRequest {
|
|
15
|
+
method?: string
|
|
16
|
+
url?: string
|
|
17
|
+
headers: Record<string, string | string[] | undefined>
|
|
18
|
+
query?: Record<string, string | string[] | undefined>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Minimal shape this module needs from a Next.js API response. */
|
|
22
|
+
export interface HandlerResponse {
|
|
23
|
+
setHeader(name: string, value: string): void
|
|
24
|
+
status(code: number): HandlerResponse
|
|
25
|
+
json(body: unknown): void
|
|
26
|
+
end(): void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A verified Sanity Studio user. */
|
|
30
|
+
export interface StudioUser {
|
|
31
|
+
id: string
|
|
32
|
+
name?: string
|
|
33
|
+
email?: string
|
|
34
|
+
roles?: Array<{ name: string }>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Outcome of verifying a request's Studio token. */
|
|
38
|
+
export type VerifyResult =
|
|
39
|
+
| { ok: true; user: StudioUser }
|
|
40
|
+
| { ok: false; reason: string }
|
|
41
|
+
|
|
42
|
+
/** Read a header that may arrive as a string or an array. */
|
|
43
|
+
function header(req: HandlerRequest, name: string): string | undefined {
|
|
44
|
+
const raw = req.headers[name] ?? req.headers[name.toLowerCase()]
|
|
45
|
+
return Array.isArray(raw) ? raw[0] : raw
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Verify that a request carries a valid Sanity user token for this project.
|
|
50
|
+
*
|
|
51
|
+
* @param req - the incoming request
|
|
52
|
+
* @param sanityProjectId - project the token must belong to
|
|
53
|
+
* @returns ok with the resolved user, or a reason suitable for logging but never for the response
|
|
54
|
+
*/
|
|
55
|
+
export async function verifyStudioRequest(req: HandlerRequest, sanityProjectId: string): Promise<VerifyResult> {
|
|
56
|
+
const authorization = header(req, 'authorization') ?? ''
|
|
57
|
+
const token = authorization.startsWith('Bearer ') ? authorization.slice(7).trim() : null
|
|
58
|
+
|
|
59
|
+
if (!token) return { ok: false, reason: 'no-token' }
|
|
60
|
+
if (!sanityProjectId) {
|
|
61
|
+
// Fail closed rather than silently authorising everything if the env is misconfigured.
|
|
62
|
+
console.error('Visitor insights: Sanity project id not configured — cannot verify Studio requests')
|
|
63
|
+
return { ok: false, reason: 'not-configured' }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const response = await fetch(`https://${sanityProjectId}.api.sanity.io/v2021-06-07/users/me`, {
|
|
68
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
69
|
+
})
|
|
70
|
+
if (!response.ok) return { ok: false, reason: `sanity-${response.status}` }
|
|
71
|
+
|
|
72
|
+
const user = (await response.json()) as StudioUser | null
|
|
73
|
+
// Sanity answers 200 with a null id for an unauthenticated request rather than a 401.
|
|
74
|
+
if (!user || !user.id) return { ok: false, reason: 'no-user' }
|
|
75
|
+
|
|
76
|
+
return { ok: true, user }
|
|
77
|
+
} catch (e) {
|
|
78
|
+
console.error('Visitor insights: Studio token verification failed:', (e as Error).message)
|
|
79
|
+
return { ok: false, reason: 'verify-error' }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Apply CORS headers for a Studio-originated request and answer the preflight.
|
|
85
|
+
*
|
|
86
|
+
* Echoes one allow-listed origin rather than sending `*`. A wildcard is acceptable for public HTML
|
|
87
|
+
* but not for an endpoint that takes an Authorization header and returns business data.
|
|
88
|
+
*
|
|
89
|
+
* @param req - the incoming request
|
|
90
|
+
* @param res - the response to decorate
|
|
91
|
+
* @param allowedOrigins - exact origins permitted to call cross-origin
|
|
92
|
+
* @returns true when the request was a preflight and is now fully answered
|
|
93
|
+
*/
|
|
94
|
+
export function applyCors(req: HandlerRequest, res: HandlerResponse, allowedOrigins: readonly string[]): boolean {
|
|
95
|
+
const origin = header(req, 'origin')
|
|
96
|
+
|
|
97
|
+
if (origin && allowedOrigins.includes(origin)) {
|
|
98
|
+
res.setHeader('Access-Control-Allow-Origin', origin)
|
|
99
|
+
// The response varies by origin, so caches must not serve one origin's response to another.
|
|
100
|
+
res.setHeader('Vary', 'Origin')
|
|
101
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS')
|
|
102
|
+
res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type')
|
|
103
|
+
res.setHeader('Access-Control-Max-Age', '600')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (req.method === 'OPTIONS') {
|
|
107
|
+
// A disallowed origin gets 204 without allow headers; the browser rejects it on its own,
|
|
108
|
+
// so there is no need to reveal whether the origin is known.
|
|
109
|
+
res.status(204).end()
|
|
110
|
+
return true
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return false
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Guard a report endpoint. Responds 401 and returns null when the caller cannot be verified.
|
|
118
|
+
*
|
|
119
|
+
* @returns the verified user, or null when the request has already been answered with a 401
|
|
120
|
+
*/
|
|
121
|
+
export async function requireStudioUser(
|
|
122
|
+
req: HandlerRequest,
|
|
123
|
+
res: HandlerResponse,
|
|
124
|
+
sanityProjectId: string,
|
|
125
|
+
): Promise<StudioUser | null> {
|
|
126
|
+
const result = await verifyStudioRequest(req, sanityProjectId)
|
|
127
|
+
|
|
128
|
+
if (result.ok) return result.user
|
|
129
|
+
|
|
130
|
+
console.error(`Visitor insights: rejected unauthenticated request (${result.reason}) on ${req.url ?? 'unknown'}`)
|
|
131
|
+
res.status(401).json({ error: 'Not authorised. Sign in to the Studio and try again.' })
|
|
132
|
+
return null
|
|
133
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small in-memory TTL cache for report responses.
|
|
3
|
+
*
|
|
4
|
+
* Without this, every panel render and every range toggle is a live fan-out to GA4, Vercel and
|
|
5
|
+
* Sanity. GA4's standard quota is finite per property per day and shared with anything else
|
|
6
|
+
* querying it, so an editor idly switching ranges could exhaust it. Reports also change slowly —
|
|
7
|
+
* GA4 does not finalise the last two days at all — so serving a few minutes stale costs nothing.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately per-instance rather than a shared store: it is a quota guard and a latency
|
|
10
|
+
* smoother, not a source of truth, and a cold serverless instance simply repopulates it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** How long a cached report stays fresh. */
|
|
14
|
+
export const DEFAULT_TTL_MS = 5 * 60 * 1000
|
|
15
|
+
|
|
16
|
+
interface Entry<T> {
|
|
17
|
+
value: T
|
|
18
|
+
expiresAt: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Bound the cache so a long-lived instance cannot grow without limit. */
|
|
22
|
+
const MAX_ENTRIES = 200
|
|
23
|
+
|
|
24
|
+
const store = new Map<string, Entry<unknown>>()
|
|
25
|
+
|
|
26
|
+
/** Build a stable cache key from the parts that determine a response. */
|
|
27
|
+
export function cacheKey(parts: Array<string | number | undefined>): string {
|
|
28
|
+
return parts.map((p) => String(p ?? '')).join('|')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Read a fresh cached value, or undefined when absent or stale.
|
|
33
|
+
* Stale entries are evicted on read so expiry does not depend on a sweep.
|
|
34
|
+
*/
|
|
35
|
+
export function getCached<T>(key: string): T | undefined {
|
|
36
|
+
const entry = store.get(key)
|
|
37
|
+
if (!entry) return undefined
|
|
38
|
+
|
|
39
|
+
if (entry.expiresAt <= Date.now()) {
|
|
40
|
+
store.delete(key)
|
|
41
|
+
return undefined
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return entry.value as T
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Store a value with a TTL, evicting the oldest entry when full. */
|
|
48
|
+
export function setCached<T>(key: string, value: T, ttlMs: number = DEFAULT_TTL_MS): void {
|
|
49
|
+
if (store.size >= MAX_ENTRIES && !store.has(key)) {
|
|
50
|
+
// Map preserves insertion order, so the first key is the oldest.
|
|
51
|
+
const oldest = store.keys().next().value
|
|
52
|
+
if (oldest !== undefined) store.delete(oldest)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
store.set(key, { value, expiresAt: Date.now() + ttlMs })
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Return a cached value or compute, cache and return it.
|
|
60
|
+
* Concurrent callers may both compute on a cold key; that is acceptable here and avoids the
|
|
61
|
+
* complexity of an in-flight promise registry for a cache this small.
|
|
62
|
+
*/
|
|
63
|
+
export async function withCache<T>(key: string, ttlMs: number, compute: () => Promise<T>): Promise<T> {
|
|
64
|
+
const hit = getCached<T>(key)
|
|
65
|
+
if (hit !== undefined) return hit
|
|
66
|
+
|
|
67
|
+
const value = await compute()
|
|
68
|
+
setCached(key, value, ttlMs)
|
|
69
|
+
return value
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Empty the cache. Test seam, and useful after a config change. */
|
|
73
|
+
export function clearCache(): void {
|
|
74
|
+
store.clear()
|
|
75
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mountable Next.js API-route handler.
|
|
3
|
+
*
|
|
4
|
+
* Ships from this package rather than being copy-pasted into each site so the three foundries
|
|
5
|
+
* cannot drift apart: a fix applied here reaches all of them on a version bump, instead of being
|
|
6
|
+
* applied to one repo and forgotten in the other two. A consuming site's route is then:
|
|
7
|
+
*
|
|
8
|
+
* // pages/api/visitor-insights/[report].js
|
|
9
|
+
* import { createVisitorInsightsHandler } from '@liiift-studio/sanity-visitor-insights/server'
|
|
10
|
+
* export default createVisitorInsightsHandler({ config: mySiteConfig })
|
|
11
|
+
*
|
|
12
|
+
* Credentials are read from the environment inside this module and never leave the server.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
isReportName,
|
|
17
|
+
type DateRange,
|
|
18
|
+
type RangeKey,
|
|
19
|
+
type ReportEnvelope,
|
|
20
|
+
type SourceName,
|
|
21
|
+
type SourceStatus,
|
|
22
|
+
} from '../types'
|
|
23
|
+
import { assertValidSiteConfig, type SiteAnalyticsConfig } from '../core/siteConfig'
|
|
24
|
+
import { coverageNotices } from '../core/cutover'
|
|
25
|
+
import { resolveRange, provisionalNotice } from '../core/ranges'
|
|
26
|
+
import { applyCors, requireStudioUser, type HandlerRequest, type HandlerResponse } from './auth'
|
|
27
|
+
import { createGa4Client, type Ga4Client } from './ga4'
|
|
28
|
+
import { createVercelClient, type VercelClient } from './vercel'
|
|
29
|
+
import { parseServiceAccountKey } from './googleAuth'
|
|
30
|
+
import { cacheKey, withCache, DEFAULT_TTL_MS } from './cache'
|
|
31
|
+
import type { SanityQueryClient } from './orders'
|
|
32
|
+
import { measurementHealth } from './reports/measurementHealth'
|
|
33
|
+
import { acquisition } from './reports/acquisition'
|
|
34
|
+
import { journey } from './reports/journey'
|
|
35
|
+
import { typefaceInterest } from './reports/typefaceInterest'
|
|
36
|
+
import { JOURNEY_STEPS } from './reports/journey'
|
|
37
|
+
|
|
38
|
+
/** Environment variables this handler reads. Names are fixed so all three sites match. */
|
|
39
|
+
export const ENV_VARS = {
|
|
40
|
+
/** Service-account JSON (raw or base64) with Viewer on the GA4 property. */
|
|
41
|
+
googleServiceAccount: 'VISITOR_INSIGHTS_GA4_SERVICE_ACCOUNT',
|
|
42
|
+
/** Vercel API token with read access to the project. */
|
|
43
|
+
vercelToken: 'VISITOR_INSIGHTS_VERCEL_TOKEN',
|
|
44
|
+
} as const
|
|
45
|
+
|
|
46
|
+
/** Options for building a handler. */
|
|
47
|
+
export interface HandlerOptions {
|
|
48
|
+
/** This site's description of itself. */
|
|
49
|
+
config: SiteAnalyticsConfig
|
|
50
|
+
/**
|
|
51
|
+
* Sanity client used for order counts. Supply the site's existing server-side client so this
|
|
52
|
+
* package does not need its own Sanity credentials.
|
|
53
|
+
*/
|
|
54
|
+
sanityClient?: SanityQueryClient
|
|
55
|
+
/** Sanity project id used to verify Studio tokens. Defaults to `SANITY_STUDIO_PROJECT_ID`. */
|
|
56
|
+
sanityProjectId?: string
|
|
57
|
+
/** Cache lifetime for report responses. */
|
|
58
|
+
cacheTtlMs?: number
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Valid range keys, as an allow-list for the query parameter. */
|
|
62
|
+
const RANGE_KEYS: RangeKey[] = ['week', 'quarter', 'year']
|
|
63
|
+
|
|
64
|
+
/** Read a query parameter that may arrive as a string or an array. */
|
|
65
|
+
function param(req: HandlerRequest, name: string): string | undefined {
|
|
66
|
+
const raw = req.query?.[name]
|
|
67
|
+
return Array.isArray(raw) ? raw[0] : raw
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Build the API-route handler for a site.
|
|
72
|
+
*
|
|
73
|
+
* @param options - the site config and its Sanity client
|
|
74
|
+
* @returns a Next.js Pages Router API handler
|
|
75
|
+
*/
|
|
76
|
+
export function createVisitorInsightsHandler(options: HandlerOptions) {
|
|
77
|
+
// Fail at construction rather than per-request, so a misconfiguration surfaces on deploy.
|
|
78
|
+
assertValidSiteConfig(options.config)
|
|
79
|
+
|
|
80
|
+
const config = options.config
|
|
81
|
+
const ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS
|
|
82
|
+
|
|
83
|
+
return async function handler(req: HandlerRequest, res: HandlerResponse): Promise<void> {
|
|
84
|
+
if (applyCors(req, res, config.allowedStudioOrigins ?? [])) return
|
|
85
|
+
|
|
86
|
+
if (req.method !== 'GET') {
|
|
87
|
+
res.status(405).json({ error: 'Method not allowed' })
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const sanityProjectId = options.sanityProjectId ?? process.env.SANITY_STUDIO_PROJECT_ID ?? ''
|
|
92
|
+
const user = await requireStudioUser(req, res, sanityProjectId)
|
|
93
|
+
if (!user) return
|
|
94
|
+
|
|
95
|
+
// Strict allow-list. The report name selects from a fixed set and is never used to build
|
|
96
|
+
// an upstream request path or to look up code dynamically.
|
|
97
|
+
const reportName = param(req, 'report')
|
|
98
|
+
if (!isReportName(reportName)) {
|
|
99
|
+
res.status(400).json({ error: 'Unknown report' })
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const rangeKey = param(req, 'range') ?? 'week'
|
|
104
|
+
if (!RANGE_KEYS.includes(rangeKey as RangeKey)) {
|
|
105
|
+
res.status(400).json({ error: 'Unknown range' })
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Ranges are anchored to the GA4 property's timezone so all three sources agree on
|
|
110
|
+
// where a day begins. Falls back to UTC only when GA4 is not configured at all.
|
|
111
|
+
const timezone = config.ga4?.timezone ?? 'UTC'
|
|
112
|
+
const range = resolveRange(rangeKey as RangeKey, timezone)
|
|
113
|
+
|
|
114
|
+
const sources: Partial<Record<SourceName, SourceStatus>> = {}
|
|
115
|
+
|
|
116
|
+
const serviceAccount = parseServiceAccountKey(process.env[ENV_VARS.googleServiceAccount])
|
|
117
|
+
let ga4: Ga4Client | null = null
|
|
118
|
+
if (!config.ga4) {
|
|
119
|
+
sources.ga4 = { status: 'unconfigured' }
|
|
120
|
+
} else if (!serviceAccount) {
|
|
121
|
+
sources.ga4 = { status: 'error', message: 'Service account missing or unparseable' }
|
|
122
|
+
} else {
|
|
123
|
+
ga4 = createGa4Client(config.ga4.propertyId, serviceAccount)
|
|
124
|
+
sources.ga4 = { status: 'ok' }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const vercelToken = process.env[ENV_VARS.vercelToken]
|
|
128
|
+
let vercel: VercelClient | null = null
|
|
129
|
+
if (!config.vercel) {
|
|
130
|
+
sources.vercel = { status: 'unconfigured' }
|
|
131
|
+
} else if (!vercelToken) {
|
|
132
|
+
sources.vercel = { status: 'error', message: 'Vercel token missing' }
|
|
133
|
+
} else {
|
|
134
|
+
vercel = createVercelClient(config.vercel.projectId, vercelToken, config.vercel.teamId)
|
|
135
|
+
sources.vercel = { status: 'ok' }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const sanity = options.sanityClient ?? null
|
|
139
|
+
sources.sanity = sanity ? { status: 'ok' } : { status: 'unconfigured' }
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const key = cacheKey(['vi', config.siteId, reportName, range.key, range.start, range.end])
|
|
143
|
+
|
|
144
|
+
const envelope = await withCache<ReportEnvelope<unknown>>(key, ttl, async () => {
|
|
145
|
+
const notices: string[] = []
|
|
146
|
+
|
|
147
|
+
const provisional = provisionalNotice(range)
|
|
148
|
+
if (provisional) notices.push(provisional)
|
|
149
|
+
|
|
150
|
+
const data = await runReport(reportName, { config, range, ga4, vercel, sanity, notices })
|
|
151
|
+
|
|
152
|
+
return { report: reportName, range, sources, notices, data }
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
res.status(200).json(envelope)
|
|
156
|
+
} catch (e) {
|
|
157
|
+
// Upstream messages can echo request detail, so only a generic message crosses the wire.
|
|
158
|
+
console.error(`Visitor insights: report "${reportName}" failed:`, (e as Error).message)
|
|
159
|
+
res.status(502).json({ error: 'Report failed', report: reportName })
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Arguments shared by every report. */
|
|
165
|
+
interface RunContext {
|
|
166
|
+
config: SiteAnalyticsConfig
|
|
167
|
+
range: DateRange
|
|
168
|
+
ga4: Ga4Client | null
|
|
169
|
+
vercel: VercelClient | null
|
|
170
|
+
sanity: SanityQueryClient | null
|
|
171
|
+
notices: string[]
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Dispatch to a report by name. A plain switch, so the set of reachable code paths is closed. */
|
|
175
|
+
async function runReport(report: string, ctx: RunContext): Promise<unknown> {
|
|
176
|
+
const { config, range, ga4, vercel, sanity, notices } = ctx
|
|
177
|
+
|
|
178
|
+
switch (report) {
|
|
179
|
+
case 'measurement-health':
|
|
180
|
+
return measurementHealth({ config, range, ga4, vercel, sanity })
|
|
181
|
+
|
|
182
|
+
case 'acquisition':
|
|
183
|
+
if (!ga4) throw new Error('GA4 is required for the acquisition report')
|
|
184
|
+
return acquisition(ga4, range)
|
|
185
|
+
|
|
186
|
+
case 'journey': {
|
|
187
|
+
if (!ga4) throw new Error('GA4 is required for the journey report')
|
|
188
|
+
notices.push(...coverageNotices(config.eventCutovers, JOURNEY_STEPS.map((s) => s.event), range))
|
|
189
|
+
return journey(config, ga4, range)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
case 'typeface-interest':
|
|
193
|
+
if (!ga4) throw new Error('GA4 is required for the typeface-interest report')
|
|
194
|
+
return typefaceInterest({ config, range, ga4, sanity })
|
|
195
|
+
|
|
196
|
+
default:
|
|
197
|
+
throw new Error(`Unhandled report: ${report}`)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GA4 Data API client.
|
|
3
|
+
*
|
|
4
|
+
* Only `runReport` and `batchRunReports` are used. `runFunnelReport` is deliberately not called:
|
|
5
|
+
* it is an alpha surface with its own stricter quota, and it returns step-conversion marginals
|
|
6
|
+
* rather than observed paths — drawing a flow diagram from it would imply co-occurrence that was
|
|
7
|
+
* never measured. The journey report approximates instead, and says so.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { getAccessToken, type ServiceAccountKey } from './googleAuth'
|
|
11
|
+
|
|
12
|
+
const DATA_API_BASE = 'https://analyticsdata.googleapis.com/v1beta'
|
|
13
|
+
|
|
14
|
+
/** A GA4 report request, narrowed to the fields this package sets. */
|
|
15
|
+
export interface Ga4ReportRequest {
|
|
16
|
+
dimensions?: Array<{ name: string }>
|
|
17
|
+
metrics?: Array<{ name: string }>
|
|
18
|
+
dateRanges: Array<{ startDate: string; endDate: string }>
|
|
19
|
+
dimensionFilter?: unknown
|
|
20
|
+
orderBys?: unknown
|
|
21
|
+
limit?: number
|
|
22
|
+
keepEmptyRows?: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A parsed report row: dimension values and metric values, positionally aligned to the request. */
|
|
26
|
+
export interface Ga4Row {
|
|
27
|
+
dimensions: string[]
|
|
28
|
+
metrics: number[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A parsed GA4 report. */
|
|
32
|
+
export interface Ga4Report {
|
|
33
|
+
rows: Ga4Row[]
|
|
34
|
+
/** True when GA4 withheld rows for privacy thresholding — totals are then incomplete. */
|
|
35
|
+
thresholded: boolean
|
|
36
|
+
/** True when GA4 answered from a sample rather than the full data set. */
|
|
37
|
+
sampled: boolean
|
|
38
|
+
/** Total row count GA4 reports, which may exceed rows returned when a limit applied. */
|
|
39
|
+
rowCount: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Raw Data API response shape, narrowed to what is read here. */
|
|
43
|
+
interface RawReport {
|
|
44
|
+
rows?: Array<{
|
|
45
|
+
dimensionValues?: Array<{ value?: string }>
|
|
46
|
+
metricValues?: Array<{ value?: string }>
|
|
47
|
+
}>
|
|
48
|
+
rowCount?: number
|
|
49
|
+
metadata?: { subjectToThresholding?: boolean; samplingMetadatas?: unknown[] }
|
|
50
|
+
propertyQuota?: unknown
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Coerce a GA4 metric cell to a number.
|
|
55
|
+
*
|
|
56
|
+
* The Data API returns every value as a string, and returns an empty string for suppressed cells.
|
|
57
|
+
* `Number('')` is 0, which would turn a withheld row into a real-looking zero — so empty is
|
|
58
|
+
* treated as NaN and filtered by the caller rather than silently becoming a data point.
|
|
59
|
+
*/
|
|
60
|
+
function toMetricNumber(raw: string | undefined): number {
|
|
61
|
+
if (raw === undefined || raw === '') return Number.NaN
|
|
62
|
+
const parsed = Number(raw)
|
|
63
|
+
return Number.isFinite(parsed) ? parsed : Number.NaN
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Parse a raw Data API report into the shape the reports consume. */
|
|
67
|
+
function parseReport(raw: RawReport): Ga4Report {
|
|
68
|
+
const rows: Ga4Row[] = (raw.rows ?? []).map((row) => ({
|
|
69
|
+
dimensions: (row.dimensionValues ?? []).map((d) => d.value ?? ''),
|
|
70
|
+
metrics: (row.metricValues ?? []).map((m) => toMetricNumber(m.value)),
|
|
71
|
+
}))
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
rows,
|
|
75
|
+
thresholded: raw.metadata?.subjectToThresholding === true,
|
|
76
|
+
sampled: Array.isArray(raw.metadata?.samplingMetadatas) && raw.metadata.samplingMetadatas.length > 0,
|
|
77
|
+
rowCount: raw.rowCount ?? rows.length,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A GA4 client bound to one property. */
|
|
82
|
+
export interface Ga4Client {
|
|
83
|
+
runReport(request: Ga4ReportRequest): Promise<Ga4Report>
|
|
84
|
+
batchRunReports(requests: Ga4ReportRequest[]): Promise<Ga4Report[]>
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Build a GA4 client for one property.
|
|
89
|
+
*
|
|
90
|
+
* @param propertyId - numeric GA4 property id
|
|
91
|
+
* @param key - service-account key with Viewer on that property
|
|
92
|
+
*/
|
|
93
|
+
export function createGa4Client(propertyId: string, key: ServiceAccountKey): Ga4Client {
|
|
94
|
+
async function post<T>(path: string, body: unknown): Promise<T> {
|
|
95
|
+
const token = await getAccessToken(key)
|
|
96
|
+
|
|
97
|
+
const response = await fetch(`${DATA_API_BASE}/properties/${propertyId}:${path}`, {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: {
|
|
100
|
+
Authorization: `Bearer ${token}`,
|
|
101
|
+
'Content-Type': 'application/json',
|
|
102
|
+
},
|
|
103
|
+
body: JSON.stringify(body),
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
// GA4 error bodies can echo the request; keep only the status for the caller's log.
|
|
108
|
+
throw new Error(`GA4 ${path} failed with ${response.status}`)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return (await response.json()) as T
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
async runReport(request) {
|
|
116
|
+
const raw = await post<RawReport>('runReport', request)
|
|
117
|
+
return parseReport(raw)
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Run several reports in one call. Preferred over parallel runReport calls: it is one
|
|
122
|
+
* quota-charged request and one round trip rather than N of each.
|
|
123
|
+
*/
|
|
124
|
+
async batchRunReports(requests) {
|
|
125
|
+
if (requests.length === 0) return []
|
|
126
|
+
|
|
127
|
+
const raw = await post<{ reports?: RawReport[] }>('batchRunReports', { requests })
|
|
128
|
+
return (raw.reports ?? []).map(parseReport)
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Build a dimension filter matching a single event name. */
|
|
134
|
+
export function eventNameFilter(eventName: string): unknown {
|
|
135
|
+
return {
|
|
136
|
+
filter: {
|
|
137
|
+
fieldName: 'eventName',
|
|
138
|
+
stringFilter: { matchType: 'EXACT', value: eventName },
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Sum a report's first metric across all rows, ignoring suppressed cells. */
|
|
144
|
+
export function sumFirstMetric(report: Ga4Report): number {
|
|
145
|
+
return report.rows.reduce((total, row) => {
|
|
146
|
+
const value = row.metrics[0]
|
|
147
|
+
return value !== undefined && Number.isFinite(value) ? total + value : total
|
|
148
|
+
}, 0)
|
|
149
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service-account access tokens for the GA4 Data API.
|
|
3
|
+
*
|
|
4
|
+
* Implemented directly against Google's OAuth2 token endpoint rather than through `googleapis`,
|
|
5
|
+
* which is a very large dependency to pull into a package that only needs one grant type. Signing
|
|
6
|
+
* a JWT with node:crypto keeps the dependency surface at zero and removes any risk of a
|
|
7
|
+
* credential-bearing SDK being reachable from the Studio entry point.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createSign } from 'node:crypto'
|
|
11
|
+
|
|
12
|
+
/** Read-only scope for the GA4 Data API — the least privilege this package needs. */
|
|
13
|
+
const ANALYTICS_READONLY_SCOPE = 'https://www.googleapis.com/auth/analytics.readonly'
|
|
14
|
+
|
|
15
|
+
/** Google's OAuth2 token endpoint for the JWT bearer grant. */
|
|
16
|
+
const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token'
|
|
17
|
+
|
|
18
|
+
/** Seconds an assertion stays valid. Google caps this at one hour. */
|
|
19
|
+
const ASSERTION_TTL_SECONDS = 3600
|
|
20
|
+
|
|
21
|
+
/** Refresh this many seconds before actual expiry, so a token never expires mid-flight. */
|
|
22
|
+
const REFRESH_MARGIN_SECONDS = 60
|
|
23
|
+
|
|
24
|
+
/** The parts of a service-account JSON key this module uses. */
|
|
25
|
+
export interface ServiceAccountKey {
|
|
26
|
+
client_email: string
|
|
27
|
+
private_key: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface CachedToken {
|
|
31
|
+
token: string
|
|
32
|
+
expiresAt: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Access tokens are cached per service account for their lifetime. */
|
|
36
|
+
const tokenCache = new Map<string, CachedToken>()
|
|
37
|
+
|
|
38
|
+
/** Base64url without padding, as JWT requires. */
|
|
39
|
+
function base64url(input: string | Buffer): string {
|
|
40
|
+
return Buffer.from(input).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Parse a service-account key from an environment variable.
|
|
45
|
+
*
|
|
46
|
+
* Accepts either raw JSON or base64-encoded JSON, because multi-line JSON with embedded newlines
|
|
47
|
+
* is awkward to set in some dashboards and gets mangled often enough to be worth tolerating both.
|
|
48
|
+
*
|
|
49
|
+
* @param raw - the environment variable's value
|
|
50
|
+
* @returns the parsed key, or null when unset or unparseable
|
|
51
|
+
*/
|
|
52
|
+
export function parseServiceAccountKey(raw: string | undefined): ServiceAccountKey | null {
|
|
53
|
+
if (!raw) return null
|
|
54
|
+
|
|
55
|
+
let text = raw.trim()
|
|
56
|
+
|
|
57
|
+
// Base64 payloads contain no braces; JSON always starts with one.
|
|
58
|
+
if (!text.startsWith('{')) {
|
|
59
|
+
try {
|
|
60
|
+
text = Buffer.from(text, 'base64').toString('utf8')
|
|
61
|
+
} catch {
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const parsed = JSON.parse(text) as Partial<ServiceAccountKey>
|
|
68
|
+
if (!parsed.client_email || !parsed.private_key) return null
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
client_email: parsed.client_email,
|
|
72
|
+
// Escaped newlines survive most env-var round trips; real newlines do not always.
|
|
73
|
+
private_key: parsed.private_key.replace(/\\n/g, '\n'),
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Get an access token for the GA4 Data API, minting one only when the cached token is near expiry.
|
|
82
|
+
*
|
|
83
|
+
* @param key - the service-account key
|
|
84
|
+
* @returns a bearer token
|
|
85
|
+
* @throws when Google rejects the assertion
|
|
86
|
+
*/
|
|
87
|
+
export async function getAccessToken(key: ServiceAccountKey): Promise<string> {
|
|
88
|
+
const now = Math.floor(Date.now() / 1000)
|
|
89
|
+
const cached = tokenCache.get(key.client_email)
|
|
90
|
+
|
|
91
|
+
if (cached && cached.expiresAt - REFRESH_MARGIN_SECONDS > now) {
|
|
92
|
+
return cached.token
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }))
|
|
96
|
+
const claims = base64url(
|
|
97
|
+
JSON.stringify({
|
|
98
|
+
iss: key.client_email,
|
|
99
|
+
scope: ANALYTICS_READONLY_SCOPE,
|
|
100
|
+
aud: TOKEN_ENDPOINT,
|
|
101
|
+
exp: now + ASSERTION_TTL_SECONDS,
|
|
102
|
+
iat: now,
|
|
103
|
+
}),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
const signer = createSign('RSA-SHA256')
|
|
107
|
+
signer.update(`${header}.${claims}`)
|
|
108
|
+
const signature = base64url(signer.sign(key.private_key))
|
|
109
|
+
const assertion = `${header}.${claims}.${signature}`
|
|
110
|
+
|
|
111
|
+
const response = await fetch(TOKEN_ENDPOINT, {
|
|
112
|
+
method: 'POST',
|
|
113
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
114
|
+
body: new URLSearchParams({
|
|
115
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
|
116
|
+
assertion,
|
|
117
|
+
}),
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
if (!response.ok) {
|
|
121
|
+
// Deliberately does not include the response body, which can echo parts of the assertion.
|
|
122
|
+
throw new Error(`Google token exchange failed with ${response.status}`)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const body = (await response.json()) as { access_token?: string; expires_in?: number }
|
|
126
|
+
if (!body.access_token) throw new Error('Google token exchange returned no access token')
|
|
127
|
+
|
|
128
|
+
tokenCache.set(key.client_email, {
|
|
129
|
+
token: body.access_token,
|
|
130
|
+
expiresAt: now + (body.expires_in ?? ASSERTION_TTL_SECONDS),
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
return body.access_token
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Clear the token cache. Test seam. */
|
|
137
|
+
export function resetTokenCache(): void {
|
|
138
|
+
tokenCache.clear()
|
|
139
|
+
}
|