@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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +181 -0
  3. package/dist/index.d.mts +155 -0
  4. package/dist/index.d.ts +155 -0
  5. package/dist/index.js +652 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/index.mjs +609 -0
  8. package/dist/index.mjs.map +1 -0
  9. package/dist/ranges-D6AZwpmm.d.mts +282 -0
  10. package/dist/ranges-D6AZwpmm.d.ts +282 -0
  11. package/dist/server.d.mts +280 -0
  12. package/dist/server.d.ts +280 -0
  13. package/dist/server.js +937 -0
  14. package/dist/server.js.map +1 -0
  15. package/dist/server.mjs +882 -0
  16. package/dist/server.mjs.map +1 -0
  17. package/package.json +68 -0
  18. package/src/boundary.test.ts +93 -0
  19. package/src/core/core.test.ts +175 -0
  20. package/src/core/cutover.ts +109 -0
  21. package/src/core/ranges.ts +103 -0
  22. package/src/core/siteConfig.ts +150 -0
  23. package/src/index.ts +86 -0
  24. package/src/reportData.ts +120 -0
  25. package/src/server/auth.ts +133 -0
  26. package/src/server/cache.ts +75 -0
  27. package/src/server/createHandler.ts +199 -0
  28. package/src/server/ga4.ts +149 -0
  29. package/src/server/googleAuth.ts +139 -0
  30. package/src/server/orders.ts +127 -0
  31. package/src/server/reports/acquisition.ts +85 -0
  32. package/src/server/reports/journey.ts +112 -0
  33. package/src/server/reports/measurementHealth.ts +170 -0
  34. package/src/server/reports/typefaceInterest.ts +140 -0
  35. package/src/server/vercel.ts +68 -0
  36. package/src/server.ts +27 -0
  37. package/src/studio/Figure.tsx +175 -0
  38. package/src/studio/VisitorInsightsTool.tsx +247 -0
  39. package/src/studio/panels.tsx +233 -0
  40. package/src/studio/useReport.ts +99 -0
  41. 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
+ }