@liiift-studio/sanity-visitor-insights 0.2.0 → 0.2.2
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/dist/server.d.mts +3 -2
- package/dist/server.d.ts +3 -2
- package/dist/server.js +38 -22
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +35 -22
- package/dist/server.mjs.map +1 -1
- package/dist/testing.d.mts +9 -3
- package/dist/testing.d.ts +9 -3
- package/dist/testing.js +4 -2
- package/dist/testing.js.map +1 -1
- package/dist/testing.mjs +4 -2
- package/dist/testing.mjs.map +1 -1
- package/dist/vercel-BMEO-Jcw.d.mts +123 -0
- package/dist/vercel-BMEO-Jcw.d.ts +123 -0
- package/package.json +2 -2
- package/src/server/vercel.ts +59 -33
- package/src/server.ts +6 -0
- package/src/testing/fakes.ts +11 -3
- package/dist/vercel-D19ArNAY.d.mts +0 -80
- package/dist/vercel-D19ArNAY.d.ts +0 -80
package/dist/testing.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { G as Ga4Client,
|
|
1
|
+
import { G as Ga4Client, b as Ga4ReportRequest, S as SanityQueryClient, a as Ga4Report, d as VercelPageviews, V as VercelClient } from './vercel-BMEO-Jcw.mjs';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Test doubles and fixture builders for the report layer.
|
|
@@ -48,8 +48,14 @@ interface Ga4Script {
|
|
|
48
48
|
declare function createFakeGa4Client(script?: Ga4Script): FakeGa4Client;
|
|
49
49
|
/** Build a Vercel client fake returning a fixed total. */
|
|
50
50
|
declare function createFakeVercelClient(result: VercelPageviews | Error): VercelClient;
|
|
51
|
-
/**
|
|
52
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Build a Vercel pageviews fixture.
|
|
53
|
+
*
|
|
54
|
+
* `visitors` defaults to roughly two thirds of pageviews rather than to the pageview total,
|
|
55
|
+
* because they are genuinely different numbers — a fixture where they match would let a bug that
|
|
56
|
+
* confuses the two pass unnoticed.
|
|
57
|
+
*/
|
|
58
|
+
declare function makeVercelPageviews(byDate: Record<string, number>, visitors?: number): VercelPageviews;
|
|
53
59
|
/** A Sanity fake that also records the GROQ it was asked to run. */
|
|
54
60
|
interface FakeSanityClient extends SanityQueryClient {
|
|
55
61
|
/** Every query, with its params. Assert against this to prove no PII field was projected. */
|
package/dist/testing.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { G as Ga4Client,
|
|
1
|
+
import { G as Ga4Client, b as Ga4ReportRequest, S as SanityQueryClient, a as Ga4Report, d as VercelPageviews, V as VercelClient } from './vercel-BMEO-Jcw.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Test doubles and fixture builders for the report layer.
|
|
@@ -48,8 +48,14 @@ interface Ga4Script {
|
|
|
48
48
|
declare function createFakeGa4Client(script?: Ga4Script): FakeGa4Client;
|
|
49
49
|
/** Build a Vercel client fake returning a fixed total. */
|
|
50
50
|
declare function createFakeVercelClient(result: VercelPageviews | Error): VercelClient;
|
|
51
|
-
/**
|
|
52
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Build a Vercel pageviews fixture.
|
|
53
|
+
*
|
|
54
|
+
* `visitors` defaults to roughly two thirds of pageviews rather than to the pageview total,
|
|
55
|
+
* because they are genuinely different numbers — a fixture where they match would let a bug that
|
|
56
|
+
* confuses the two pass unnoticed.
|
|
57
|
+
*/
|
|
58
|
+
declare function makeVercelPageviews(byDate: Record<string, number>, visitors?: number): VercelPageviews;
|
|
53
59
|
/** A Sanity fake that also records the GROQ it was asked to run. */
|
|
54
60
|
interface FakeSanityClient extends SanityQueryClient {
|
|
55
61
|
/** Every query, with its params. Assert against this to prove no PII field was projected. */
|
package/dist/testing.js
CHANGED
|
@@ -74,10 +74,12 @@ function createFakeVercelClient(result) {
|
|
|
74
74
|
}
|
|
75
75
|
};
|
|
76
76
|
}
|
|
77
|
-
function makeVercelPageviews(byDate) {
|
|
77
|
+
function makeVercelPageviews(byDate, visitors) {
|
|
78
|
+
const total = Object.values(byDate).reduce((sum, n) => sum + n, 0);
|
|
78
79
|
return {
|
|
79
80
|
byDate,
|
|
80
|
-
total
|
|
81
|
+
total,
|
|
82
|
+
visitors: visitors ?? Math.round(total * 0.66)
|
|
81
83
|
};
|
|
82
84
|
}
|
|
83
85
|
function createFakeSanityClient(respond) {
|
package/dist/testing.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/testing.ts","../src/testing/fakes.ts"],"sourcesContent":["/**\n * Testing entry point — test doubles for the report layer.\n *\n * Behind its own subpath so these never reach a production server or Studio bundle, while still\n * being available to a consuming site that wants to exercise these reports before it has\n * credentials to point at.\n *\n * import { createFakeGa4Client } from '@liiift-studio/sanity-visitor-insights/testing'\n */\n\nexport * from './testing/fakes'\n","/**\n * Test doubles and fixture builders for the report layer.\n *\n * The reports are thin wrappers over three upstreams, but the reasoning inside them — which\n * absence means \"unavailable\" rather than zero, which ratios may be computed at all, which source\n * failure is allowed to degrade which figure — is exactly the part that silently corrupts a\n * dashboard. That reasoning needs to be exercised without credentials, so these fakes stand in for\n * GA4, Vercel and Sanity and record what they were asked.\n *\n * Exported from the package (not just used internally) so a consuming site can drive the same\n * fakes in its own integration tests before wiring real credentials.\n */\n\nimport type { Ga4Client, Ga4Report, Ga4ReportRequest, Ga4Row } from '../server/ga4'\nimport type { VercelClient, VercelPageviews } from '../server/vercel'\nimport type { SanityQueryClient } from '../server/orders'\n\n/** Build a GA4 report fixture from plain rows. */\nexport function makeGa4Report(\n\trows: Array<{ dimensions?: string[]; metrics: number[] }>,\n\toptions: { thresholded?: boolean; sampled?: boolean } = {},\n): Ga4Report {\n\tconst parsed: Ga4Row[] = rows.map((row) => ({\n\t\tdimensions: row.dimensions ?? [],\n\t\tmetrics: row.metrics,\n\t}))\n\n\treturn {\n\t\trows: parsed,\n\t\tthresholded: options.thresholded ?? false,\n\t\tsampled: options.sampled ?? false,\n\t\trowCount: parsed.length,\n\t}\n}\n\n/** Shorthand for a single-metric, single-row report — the common case. */\nexport function makeGa4Total(total: number): Ga4Report {\n\treturn makeGa4Report([{ metrics: [total] }])\n}\n\n/** A GA4 fake that also records what it was asked. */\nexport interface FakeGa4Client extends Ga4Client {\n\t/** Every batchRunReports call, as arrays of requests. */\n\tbatchCalls: Ga4ReportRequest[][]\n\t/** Every runReport call. */\n\tsingleCalls: Ga4ReportRequest[]\n}\n\n/** Script controlling what the GA4 fake returns. */\nexport interface Ga4Script {\n\t/** Handles batchRunReports. Receives the requests, returns one report per request. */\n\tbatch?: (requests: Ga4ReportRequest[]) => Ga4Report[]\n\t/** Handles runReport. */\n\tsingle?: (request: Ga4ReportRequest) => Ga4Report\n\t/** When set, every call rejects with this error instead. */\n\tfailWith?: Error\n}\n\n/**\n * Build a GA4 client fake.\n *\n * Unhandled calls return an empty report rather than throwing, so a test only has to script the\n * behaviour it actually cares about.\n */\nexport function createFakeGa4Client(script: Ga4Script = {}): FakeGa4Client {\n\tconst batchCalls: Ga4ReportRequest[][] = []\n\tconst singleCalls: Ga4ReportRequest[] = []\n\n\treturn {\n\t\tbatchCalls,\n\t\tsingleCalls,\n\n\t\tasync batchRunReports(requests) {\n\t\t\tbatchCalls.push(requests)\n\t\t\tif (script.failWith) throw script.failWith\n\t\t\tif (script.batch) return script.batch(requests)\n\t\t\treturn requests.map(() => makeGa4Report([]))\n\t\t},\n\n\t\tasync runReport(request) {\n\t\t\tsingleCalls.push(request)\n\t\t\tif (script.failWith) throw script.failWith\n\t\t\tif (script.single) return script.single(request)\n\t\t\treturn makeGa4Report([])\n\t\t},\n\t}\n}\n\n/** Build a Vercel client fake returning a fixed total. */\nexport function createFakeVercelClient(\n\tresult: VercelPageviews | Error,\n): VercelClient {\n\treturn {\n\t\tasync pageviews() {\n\t\t\tif (result instanceof Error) throw result\n\t\t\treturn result\n\t\t},\n\t}\n}\n\n
|
|
1
|
+
{"version":3,"sources":["../src/testing.ts","../src/testing/fakes.ts"],"sourcesContent":["/**\n * Testing entry point — test doubles for the report layer.\n *\n * Behind its own subpath so these never reach a production server or Studio bundle, while still\n * being available to a consuming site that wants to exercise these reports before it has\n * credentials to point at.\n *\n * import { createFakeGa4Client } from '@liiift-studio/sanity-visitor-insights/testing'\n */\n\nexport * from './testing/fakes'\n","/**\n * Test doubles and fixture builders for the report layer.\n *\n * The reports are thin wrappers over three upstreams, but the reasoning inside them — which\n * absence means \"unavailable\" rather than zero, which ratios may be computed at all, which source\n * failure is allowed to degrade which figure — is exactly the part that silently corrupts a\n * dashboard. That reasoning needs to be exercised without credentials, so these fakes stand in for\n * GA4, Vercel and Sanity and record what they were asked.\n *\n * Exported from the package (not just used internally) so a consuming site can drive the same\n * fakes in its own integration tests before wiring real credentials.\n */\n\nimport type { Ga4Client, Ga4Report, Ga4ReportRequest, Ga4Row } from '../server/ga4'\nimport type { VercelClient, VercelPageviews } from '../server/vercel'\nimport type { SanityQueryClient } from '../server/orders'\n\n/** Build a GA4 report fixture from plain rows. */\nexport function makeGa4Report(\n\trows: Array<{ dimensions?: string[]; metrics: number[] }>,\n\toptions: { thresholded?: boolean; sampled?: boolean } = {},\n): Ga4Report {\n\tconst parsed: Ga4Row[] = rows.map((row) => ({\n\t\tdimensions: row.dimensions ?? [],\n\t\tmetrics: row.metrics,\n\t}))\n\n\treturn {\n\t\trows: parsed,\n\t\tthresholded: options.thresholded ?? false,\n\t\tsampled: options.sampled ?? false,\n\t\trowCount: parsed.length,\n\t}\n}\n\n/** Shorthand for a single-metric, single-row report — the common case. */\nexport function makeGa4Total(total: number): Ga4Report {\n\treturn makeGa4Report([{ metrics: [total] }])\n}\n\n/** A GA4 fake that also records what it was asked. */\nexport interface FakeGa4Client extends Ga4Client {\n\t/** Every batchRunReports call, as arrays of requests. */\n\tbatchCalls: Ga4ReportRequest[][]\n\t/** Every runReport call. */\n\tsingleCalls: Ga4ReportRequest[]\n}\n\n/** Script controlling what the GA4 fake returns. */\nexport interface Ga4Script {\n\t/** Handles batchRunReports. Receives the requests, returns one report per request. */\n\tbatch?: (requests: Ga4ReportRequest[]) => Ga4Report[]\n\t/** Handles runReport. */\n\tsingle?: (request: Ga4ReportRequest) => Ga4Report\n\t/** When set, every call rejects with this error instead. */\n\tfailWith?: Error\n}\n\n/**\n * Build a GA4 client fake.\n *\n * Unhandled calls return an empty report rather than throwing, so a test only has to script the\n * behaviour it actually cares about.\n */\nexport function createFakeGa4Client(script: Ga4Script = {}): FakeGa4Client {\n\tconst batchCalls: Ga4ReportRequest[][] = []\n\tconst singleCalls: Ga4ReportRequest[] = []\n\n\treturn {\n\t\tbatchCalls,\n\t\tsingleCalls,\n\n\t\tasync batchRunReports(requests) {\n\t\t\tbatchCalls.push(requests)\n\t\t\tif (script.failWith) throw script.failWith\n\t\t\tif (script.batch) return script.batch(requests)\n\t\t\treturn requests.map(() => makeGa4Report([]))\n\t\t},\n\n\t\tasync runReport(request) {\n\t\t\tsingleCalls.push(request)\n\t\t\tif (script.failWith) throw script.failWith\n\t\t\tif (script.single) return script.single(request)\n\t\t\treturn makeGa4Report([])\n\t\t},\n\t}\n}\n\n/** Build a Vercel client fake returning a fixed total. */\nexport function createFakeVercelClient(\n\tresult: VercelPageviews | Error,\n): VercelClient {\n\treturn {\n\t\tasync pageviews() {\n\t\t\tif (result instanceof Error) throw result\n\t\t\treturn result\n\t\t},\n\t}\n}\n\n/**\n * Build a Vercel pageviews fixture.\n *\n * `visitors` defaults to roughly two thirds of pageviews rather than to the pageview total,\n * because they are genuinely different numbers — a fixture where they match would let a bug that\n * confuses the two pass unnoticed.\n */\nexport function makeVercelPageviews(byDate: Record<string, number>, visitors?: number): VercelPageviews {\n\tconst total = Object.values(byDate).reduce((sum, n) => sum + n, 0)\n\treturn {\n\t\tbyDate,\n\t\ttotal,\n\t\tvisitors: visitors ?? Math.round(total * 0.66),\n\t}\n}\n\n/** A Sanity fake that also records the GROQ it was asked to run. */\nexport interface FakeSanityClient extends SanityQueryClient {\n\t/** Every query, with its params. Assert against this to prove no PII field was projected. */\n\tqueries: Array<{ query: string; params?: Record<string, unknown> }>\n}\n\n/**\n * Build a Sanity client fake.\n *\n * @param respond - returns the documents for a given query, or throws to simulate failure\n */\nexport function createFakeSanityClient(respond: (query: string) => unknown): FakeSanityClient {\n\tconst queries: Array<{ query: string; params?: Record<string, unknown> }> = []\n\n\treturn {\n\t\tqueries,\n\t\tasync fetch<T>(query: string, params?: Record<string, unknown>): Promise<T> {\n\t\t\tqueries.push({ query, params })\n\t\t\treturn respond(query) as T\n\t\t},\n\t}\n}\n\n/** Build order documents at given dates, in the shape the safe projection returns. */\nexport function makeOrders(dates: string[]): Array<{ _createdAt: string; orderStatus: string }> {\n\treturn dates.map((date) => ({ _createdAt: `${date}T12:00:00Z`, orderStatus: 'complete' }))\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACkBO,SAAS,cACf,MACA,UAAwD,CAAC,GAC7C;AACZ,QAAM,SAAmB,KAAK,IAAI,CAAC,SAAS;AAAA,IAC3C,YAAY,IAAI,cAAc,CAAC;AAAA,IAC/B,SAAS,IAAI;AAAA,EACd,EAAE;AAEF,SAAO;AAAA,IACN,MAAM;AAAA,IACN,aAAa,QAAQ,eAAe;AAAA,IACpC,SAAS,QAAQ,WAAW;AAAA,IAC5B,UAAU,OAAO;AAAA,EAClB;AACD;AAGO,SAAS,aAAa,OAA0B;AACtD,SAAO,cAAc,CAAC,EAAE,SAAS,CAAC,KAAK,EAAE,CAAC,CAAC;AAC5C;AA0BO,SAAS,oBAAoB,SAAoB,CAAC,GAAkB;AAC1E,QAAM,aAAmC,CAAC;AAC1C,QAAM,cAAkC,CAAC;AAEzC,SAAO;AAAA,IACN;AAAA,IACA;AAAA,IAEA,MAAM,gBAAgB,UAAU;AAC/B,iBAAW,KAAK,QAAQ;AACxB,UAAI,OAAO,SAAU,OAAM,OAAO;AAClC,UAAI,OAAO,MAAO,QAAO,OAAO,MAAM,QAAQ;AAC9C,aAAO,SAAS,IAAI,MAAM,cAAc,CAAC,CAAC,CAAC;AAAA,IAC5C;AAAA,IAEA,MAAM,UAAU,SAAS;AACxB,kBAAY,KAAK,OAAO;AACxB,UAAI,OAAO,SAAU,OAAM,OAAO;AAClC,UAAI,OAAO,OAAQ,QAAO,OAAO,OAAO,OAAO;AAC/C,aAAO,cAAc,CAAC,CAAC;AAAA,IACxB;AAAA,EACD;AACD;AAGO,SAAS,uBACf,QACe;AACf,SAAO;AAAA,IACN,MAAM,YAAY;AACjB,UAAI,kBAAkB,MAAO,OAAM;AACnC,aAAO;AAAA,IACR;AAAA,EACD;AACD;AASO,SAAS,oBAAoB,QAAgC,UAAoC;AACvG,QAAM,QAAQ,OAAO,OAAO,MAAM,EAAE,OAAO,CAAC,KAAK,MAAM,MAAM,GAAG,CAAC;AACjE,SAAO;AAAA,IACN;AAAA,IACA;AAAA,IACA,UAAU,YAAY,KAAK,MAAM,QAAQ,IAAI;AAAA,EAC9C;AACD;AAaO,SAAS,uBAAuB,SAAuD;AAC7F,QAAM,UAAsE,CAAC;AAE7E,SAAO;AAAA,IACN;AAAA,IACA,MAAM,MAAS,OAAe,QAA8C;AAC3E,cAAQ,KAAK,EAAE,OAAO,OAAO,CAAC;AAC9B,aAAO,QAAQ,KAAK;AAAA,IACrB;AAAA,EACD;AACD;AAGO,SAAS,WAAW,OAAqE;AAC/F,SAAO,MAAM,IAAI,CAAC,UAAU,EAAE,YAAY,GAAG,IAAI,cAAc,aAAa,WAAW,EAAE;AAC1F;","names":[]}
|
package/dist/testing.mjs
CHANGED
|
@@ -42,10 +42,12 @@ function createFakeVercelClient(result) {
|
|
|
42
42
|
}
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
|
-
function makeVercelPageviews(byDate) {
|
|
45
|
+
function makeVercelPageviews(byDate, visitors) {
|
|
46
|
+
const total = Object.values(byDate).reduce((sum, n) => sum + n, 0);
|
|
46
47
|
return {
|
|
47
48
|
byDate,
|
|
48
|
-
total
|
|
49
|
+
total,
|
|
50
|
+
visitors: visitors ?? Math.round(total * 0.66)
|
|
49
51
|
};
|
|
50
52
|
}
|
|
51
53
|
function createFakeSanityClient(respond) {
|
package/dist/testing.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/testing/fakes.ts"],"sourcesContent":["/**\n * Test doubles and fixture builders for the report layer.\n *\n * The reports are thin wrappers over three upstreams, but the reasoning inside them — which\n * absence means \"unavailable\" rather than zero, which ratios may be computed at all, which source\n * failure is allowed to degrade which figure — is exactly the part that silently corrupts a\n * dashboard. That reasoning needs to be exercised without credentials, so these fakes stand in for\n * GA4, Vercel and Sanity and record what they were asked.\n *\n * Exported from the package (not just used internally) so a consuming site can drive the same\n * fakes in its own integration tests before wiring real credentials.\n */\n\nimport type { Ga4Client, Ga4Report, Ga4ReportRequest, Ga4Row } from '../server/ga4'\nimport type { VercelClient, VercelPageviews } from '../server/vercel'\nimport type { SanityQueryClient } from '../server/orders'\n\n/** Build a GA4 report fixture from plain rows. */\nexport function makeGa4Report(\n\trows: Array<{ dimensions?: string[]; metrics: number[] }>,\n\toptions: { thresholded?: boolean; sampled?: boolean } = {},\n): Ga4Report {\n\tconst parsed: Ga4Row[] = rows.map((row) => ({\n\t\tdimensions: row.dimensions ?? [],\n\t\tmetrics: row.metrics,\n\t}))\n\n\treturn {\n\t\trows: parsed,\n\t\tthresholded: options.thresholded ?? false,\n\t\tsampled: options.sampled ?? false,\n\t\trowCount: parsed.length,\n\t}\n}\n\n/** Shorthand for a single-metric, single-row report — the common case. */\nexport function makeGa4Total(total: number): Ga4Report {\n\treturn makeGa4Report([{ metrics: [total] }])\n}\n\n/** A GA4 fake that also records what it was asked. */\nexport interface FakeGa4Client extends Ga4Client {\n\t/** Every batchRunReports call, as arrays of requests. */\n\tbatchCalls: Ga4ReportRequest[][]\n\t/** Every runReport call. */\n\tsingleCalls: Ga4ReportRequest[]\n}\n\n/** Script controlling what the GA4 fake returns. */\nexport interface Ga4Script {\n\t/** Handles batchRunReports. Receives the requests, returns one report per request. */\n\tbatch?: (requests: Ga4ReportRequest[]) => Ga4Report[]\n\t/** Handles runReport. */\n\tsingle?: (request: Ga4ReportRequest) => Ga4Report\n\t/** When set, every call rejects with this error instead. */\n\tfailWith?: Error\n}\n\n/**\n * Build a GA4 client fake.\n *\n * Unhandled calls return an empty report rather than throwing, so a test only has to script the\n * behaviour it actually cares about.\n */\nexport function createFakeGa4Client(script: Ga4Script = {}): FakeGa4Client {\n\tconst batchCalls: Ga4ReportRequest[][] = []\n\tconst singleCalls: Ga4ReportRequest[] = []\n\n\treturn {\n\t\tbatchCalls,\n\t\tsingleCalls,\n\n\t\tasync batchRunReports(requests) {\n\t\t\tbatchCalls.push(requests)\n\t\t\tif (script.failWith) throw script.failWith\n\t\t\tif (script.batch) return script.batch(requests)\n\t\t\treturn requests.map(() => makeGa4Report([]))\n\t\t},\n\n\t\tasync runReport(request) {\n\t\t\tsingleCalls.push(request)\n\t\t\tif (script.failWith) throw script.failWith\n\t\t\tif (script.single) return script.single(request)\n\t\t\treturn makeGa4Report([])\n\t\t},\n\t}\n}\n\n/** Build a Vercel client fake returning a fixed total. */\nexport function createFakeVercelClient(\n\tresult: VercelPageviews | Error,\n): VercelClient {\n\treturn {\n\t\tasync pageviews() {\n\t\t\tif (result instanceof Error) throw result\n\t\t\treturn result\n\t\t},\n\t}\n}\n\n
|
|
1
|
+
{"version":3,"sources":["../src/testing/fakes.ts"],"sourcesContent":["/**\n * Test doubles and fixture builders for the report layer.\n *\n * The reports are thin wrappers over three upstreams, but the reasoning inside them — which\n * absence means \"unavailable\" rather than zero, which ratios may be computed at all, which source\n * failure is allowed to degrade which figure — is exactly the part that silently corrupts a\n * dashboard. That reasoning needs to be exercised without credentials, so these fakes stand in for\n * GA4, Vercel and Sanity and record what they were asked.\n *\n * Exported from the package (not just used internally) so a consuming site can drive the same\n * fakes in its own integration tests before wiring real credentials.\n */\n\nimport type { Ga4Client, Ga4Report, Ga4ReportRequest, Ga4Row } from '../server/ga4'\nimport type { VercelClient, VercelPageviews } from '../server/vercel'\nimport type { SanityQueryClient } from '../server/orders'\n\n/** Build a GA4 report fixture from plain rows. */\nexport function makeGa4Report(\n\trows: Array<{ dimensions?: string[]; metrics: number[] }>,\n\toptions: { thresholded?: boolean; sampled?: boolean } = {},\n): Ga4Report {\n\tconst parsed: Ga4Row[] = rows.map((row) => ({\n\t\tdimensions: row.dimensions ?? [],\n\t\tmetrics: row.metrics,\n\t}))\n\n\treturn {\n\t\trows: parsed,\n\t\tthresholded: options.thresholded ?? false,\n\t\tsampled: options.sampled ?? false,\n\t\trowCount: parsed.length,\n\t}\n}\n\n/** Shorthand for a single-metric, single-row report — the common case. */\nexport function makeGa4Total(total: number): Ga4Report {\n\treturn makeGa4Report([{ metrics: [total] }])\n}\n\n/** A GA4 fake that also records what it was asked. */\nexport interface FakeGa4Client extends Ga4Client {\n\t/** Every batchRunReports call, as arrays of requests. */\n\tbatchCalls: Ga4ReportRequest[][]\n\t/** Every runReport call. */\n\tsingleCalls: Ga4ReportRequest[]\n}\n\n/** Script controlling what the GA4 fake returns. */\nexport interface Ga4Script {\n\t/** Handles batchRunReports. Receives the requests, returns one report per request. */\n\tbatch?: (requests: Ga4ReportRequest[]) => Ga4Report[]\n\t/** Handles runReport. */\n\tsingle?: (request: Ga4ReportRequest) => Ga4Report\n\t/** When set, every call rejects with this error instead. */\n\tfailWith?: Error\n}\n\n/**\n * Build a GA4 client fake.\n *\n * Unhandled calls return an empty report rather than throwing, so a test only has to script the\n * behaviour it actually cares about.\n */\nexport function createFakeGa4Client(script: Ga4Script = {}): FakeGa4Client {\n\tconst batchCalls: Ga4ReportRequest[][] = []\n\tconst singleCalls: Ga4ReportRequest[] = []\n\n\treturn {\n\t\tbatchCalls,\n\t\tsingleCalls,\n\n\t\tasync batchRunReports(requests) {\n\t\t\tbatchCalls.push(requests)\n\t\t\tif (script.failWith) throw script.failWith\n\t\t\tif (script.batch) return script.batch(requests)\n\t\t\treturn requests.map(() => makeGa4Report([]))\n\t\t},\n\n\t\tasync runReport(request) {\n\t\t\tsingleCalls.push(request)\n\t\t\tif (script.failWith) throw script.failWith\n\t\t\tif (script.single) return script.single(request)\n\t\t\treturn makeGa4Report([])\n\t\t},\n\t}\n}\n\n/** Build a Vercel client fake returning a fixed total. */\nexport function createFakeVercelClient(\n\tresult: VercelPageviews | Error,\n): VercelClient {\n\treturn {\n\t\tasync pageviews() {\n\t\t\tif (result instanceof Error) throw result\n\t\t\treturn result\n\t\t},\n\t}\n}\n\n/**\n * Build a Vercel pageviews fixture.\n *\n * `visitors` defaults to roughly two thirds of pageviews rather than to the pageview total,\n * because they are genuinely different numbers — a fixture where they match would let a bug that\n * confuses the two pass unnoticed.\n */\nexport function makeVercelPageviews(byDate: Record<string, number>, visitors?: number): VercelPageviews {\n\tconst total = Object.values(byDate).reduce((sum, n) => sum + n, 0)\n\treturn {\n\t\tbyDate,\n\t\ttotal,\n\t\tvisitors: visitors ?? Math.round(total * 0.66),\n\t}\n}\n\n/** A Sanity fake that also records the GROQ it was asked to run. */\nexport interface FakeSanityClient extends SanityQueryClient {\n\t/** Every query, with its params. Assert against this to prove no PII field was projected. */\n\tqueries: Array<{ query: string; params?: Record<string, unknown> }>\n}\n\n/**\n * Build a Sanity client fake.\n *\n * @param respond - returns the documents for a given query, or throws to simulate failure\n */\nexport function createFakeSanityClient(respond: (query: string) => unknown): FakeSanityClient {\n\tconst queries: Array<{ query: string; params?: Record<string, unknown> }> = []\n\n\treturn {\n\t\tqueries,\n\t\tasync fetch<T>(query: string, params?: Record<string, unknown>): Promise<T> {\n\t\t\tqueries.push({ query, params })\n\t\t\treturn respond(query) as T\n\t\t},\n\t}\n}\n\n/** Build order documents at given dates, in the shape the safe projection returns. */\nexport function makeOrders(dates: string[]): Array<{ _createdAt: string; orderStatus: string }> {\n\treturn dates.map((date) => ({ _createdAt: `${date}T12:00:00Z`, orderStatus: 'complete' }))\n}\n"],"mappings":";AAkBO,SAAS,cACf,MACA,UAAwD,CAAC,GAC7C;AACZ,QAAM,SAAmB,KAAK,IAAI,CAAC,SAAS;AAAA,IAC3C,YAAY,IAAI,cAAc,CAAC;AAAA,IAC/B,SAAS,IAAI;AAAA,EACd,EAAE;AAEF,SAAO;AAAA,IACN,MAAM;AAAA,IACN,aAAa,QAAQ,eAAe;AAAA,IACpC,SAAS,QAAQ,WAAW;AAAA,IAC5B,UAAU,OAAO;AAAA,EAClB;AACD;AAGO,SAAS,aAAa,OAA0B;AACtD,SAAO,cAAc,CAAC,EAAE,SAAS,CAAC,KAAK,EAAE,CAAC,CAAC;AAC5C;AA0BO,SAAS,oBAAoB,SAAoB,CAAC,GAAkB;AAC1E,QAAM,aAAmC,CAAC;AAC1C,QAAM,cAAkC,CAAC;AAEzC,SAAO;AAAA,IACN;AAAA,IACA;AAAA,IAEA,MAAM,gBAAgB,UAAU;AAC/B,iBAAW,KAAK,QAAQ;AACxB,UAAI,OAAO,SAAU,OAAM,OAAO;AAClC,UAAI,OAAO,MAAO,QAAO,OAAO,MAAM,QAAQ;AAC9C,aAAO,SAAS,IAAI,MAAM,cAAc,CAAC,CAAC,CAAC;AAAA,IAC5C;AAAA,IAEA,MAAM,UAAU,SAAS;AACxB,kBAAY,KAAK,OAAO;AACxB,UAAI,OAAO,SAAU,OAAM,OAAO;AAClC,UAAI,OAAO,OAAQ,QAAO,OAAO,OAAO,OAAO;AAC/C,aAAO,cAAc,CAAC,CAAC;AAAA,IACxB;AAAA,EACD;AACD;AAGO,SAAS,uBACf,QACe;AACf,SAAO;AAAA,IACN,MAAM,YAAY;AACjB,UAAI,kBAAkB,MAAO,OAAM;AACnC,aAAO;AAAA,IACR;AAAA,EACD;AACD;AASO,SAAS,oBAAoB,QAAgC,UAAoC;AACvG,QAAM,QAAQ,OAAO,OAAO,MAAM,EAAE,OAAO,CAAC,KAAK,MAAM,MAAM,GAAG,CAAC;AACjE,SAAO;AAAA,IACN;AAAA,IACA;AAAA,IACA,UAAU,YAAY,KAAK,MAAM,QAAQ,IAAI;AAAA,EAC9C;AACD;AAaO,SAAS,uBAAuB,SAAuD;AAC7F,QAAM,UAAsE,CAAC;AAE7E,SAAO;AAAA,IACN;AAAA,IACA,MAAM,MAAS,OAAe,QAA8C;AAC3E,cAAQ,KAAK,EAAE,OAAO,OAAO,CAAC;AAC9B,aAAO,QAAQ,KAAK;AAAA,IACrB;AAAA,EACD;AACD;AAGO,SAAS,WAAW,OAAqE;AAC/F,SAAO,MAAM,IAAI,CAAC,UAAU,EAAE,YAAY,GAAG,IAAI,cAAc,aAAa,WAAW,EAAE;AAC1F;","names":[]}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/** What this module needs from a Sanity client — kept minimal so it is trivial to stub in tests. */
|
|
2
|
+
interface SanityQueryClient {
|
|
3
|
+
fetch<T>(query: string, params?: Record<string, unknown>): Promise<T>;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Service-account access tokens for the GA4 Data API.
|
|
8
|
+
*
|
|
9
|
+
* Implemented directly against Google's OAuth2 token endpoint rather than through `googleapis`,
|
|
10
|
+
* which is a very large dependency to pull into a package that only needs one grant type. Signing
|
|
11
|
+
* a JWT with node:crypto keeps the dependency surface at zero and removes any risk of a
|
|
12
|
+
* credential-bearing SDK being reachable from the Studio entry point.
|
|
13
|
+
*/
|
|
14
|
+
/** The parts of a service-account JSON key this module uses. */
|
|
15
|
+
interface ServiceAccountKey {
|
|
16
|
+
client_email: string;
|
|
17
|
+
private_key: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Parse a service-account key from an environment variable.
|
|
21
|
+
*
|
|
22
|
+
* Accepts either raw JSON or base64-encoded JSON, because multi-line JSON with embedded newlines
|
|
23
|
+
* is awkward to set in some dashboards and gets mangled often enough to be worth tolerating both.
|
|
24
|
+
*
|
|
25
|
+
* @param raw - the environment variable's value
|
|
26
|
+
* @returns the parsed key, or null when unset or unparseable
|
|
27
|
+
*/
|
|
28
|
+
declare function parseServiceAccountKey(raw: string | undefined): ServiceAccountKey | null;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* GA4 Data API client.
|
|
32
|
+
*
|
|
33
|
+
* Only `runReport` and `batchRunReports` are used. `runFunnelReport` is deliberately not called:
|
|
34
|
+
* it is an alpha surface with its own stricter quota, and it returns step-conversion marginals
|
|
35
|
+
* rather than observed paths — drawing a flow diagram from it would imply co-occurrence that was
|
|
36
|
+
* never measured. The journey report approximates instead, and says so.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** A GA4 report request, narrowed to the fields this package sets. */
|
|
40
|
+
interface Ga4ReportRequest {
|
|
41
|
+
dimensions?: Array<{
|
|
42
|
+
name: string;
|
|
43
|
+
}>;
|
|
44
|
+
metrics?: Array<{
|
|
45
|
+
name: string;
|
|
46
|
+
}>;
|
|
47
|
+
dateRanges: Array<{
|
|
48
|
+
startDate: string;
|
|
49
|
+
endDate: string;
|
|
50
|
+
}>;
|
|
51
|
+
dimensionFilter?: unknown;
|
|
52
|
+
orderBys?: unknown;
|
|
53
|
+
limit?: number;
|
|
54
|
+
keepEmptyRows?: boolean;
|
|
55
|
+
}
|
|
56
|
+
/** A parsed report row: dimension values and metric values, positionally aligned to the request. */
|
|
57
|
+
interface Ga4Row {
|
|
58
|
+
dimensions: string[];
|
|
59
|
+
metrics: number[];
|
|
60
|
+
}
|
|
61
|
+
/** A parsed GA4 report. */
|
|
62
|
+
interface Ga4Report {
|
|
63
|
+
rows: Ga4Row[];
|
|
64
|
+
/** True when GA4 withheld rows for privacy thresholding — totals are then incomplete. */
|
|
65
|
+
thresholded: boolean;
|
|
66
|
+
/** True when GA4 answered from a sample rather than the full data set. */
|
|
67
|
+
sampled: boolean;
|
|
68
|
+
/** Total row count GA4 reports, which may exceed rows returned when a limit applied. */
|
|
69
|
+
rowCount: number;
|
|
70
|
+
/**
|
|
71
|
+
* The property's configured timezone, as GA4 reports it. Worth capturing because every range
|
|
72
|
+
* is anchored to the timezone in site config, and a mismatch silently shifts day boundaries.
|
|
73
|
+
*/
|
|
74
|
+
timeZone?: string;
|
|
75
|
+
}
|
|
76
|
+
/** A GA4 client bound to one property. */
|
|
77
|
+
interface Ga4Client {
|
|
78
|
+
runReport(request: Ga4ReportRequest): Promise<Ga4Report>;
|
|
79
|
+
batchRunReports(requests: Ga4ReportRequest[]): Promise<Ga4Report[]>;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Build a GA4 client for one property.
|
|
83
|
+
*
|
|
84
|
+
* @param propertyId - numeric GA4 property id
|
|
85
|
+
* @param key - service-account key with Viewer on that property
|
|
86
|
+
*/
|
|
87
|
+
declare function createGa4Client(propertyId: string, key: ServiceAccountKey): Ga4Client;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Vercel Web Analytics client.
|
|
91
|
+
*
|
|
92
|
+
* Uses the documented Web Analytics query API under `/v1/query/web-analytics/visits/*`. An earlier
|
|
93
|
+
* version of this file guessed at `/v1/web-analytics/timeseries`, which does not exist and returned
|
|
94
|
+
* 404 for every project — indistinguishable, from the outside, from the feature being switched off.
|
|
95
|
+
* That mistake is why `unavailable` carries a reason: a 404 here now surfaces as a source error to
|
|
96
|
+
* be investigated rather than as an absence of traffic.
|
|
97
|
+
*
|
|
98
|
+
* Vercel is counted purely as a second, cookieless pageview measurement. It is never treated as
|
|
99
|
+
* ground truth, and never subtracted from a GA4 session count — those are different units.
|
|
100
|
+
*
|
|
101
|
+
* Note the API defaults to the production environment only, which is what the reports want.
|
|
102
|
+
*/
|
|
103
|
+
/** Daily pageview counts, keyed by ISO date. */
|
|
104
|
+
interface VercelPageviews {
|
|
105
|
+
byDate: Record<string, number>;
|
|
106
|
+
total: number;
|
|
107
|
+
/** Distinct visitors over the whole range. Not a sum of the daily figures — visitors dedupe. */
|
|
108
|
+
visitors: number;
|
|
109
|
+
}
|
|
110
|
+
/** A Vercel client bound to one project. */
|
|
111
|
+
interface VercelClient {
|
|
112
|
+
pageviews(start: string, end: string): Promise<VercelPageviews>;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Build a Vercel Web Analytics client.
|
|
116
|
+
*
|
|
117
|
+
* @param projectId - the Vercel project id
|
|
118
|
+
* @param token - a Vercel API token with read access to that project
|
|
119
|
+
* @param teamId - team scope, required when the project belongs to a team
|
|
120
|
+
*/
|
|
121
|
+
declare function createVercelClient(projectId: string, token: string, teamId?: string): VercelClient;
|
|
122
|
+
|
|
123
|
+
export { type Ga4Client as G, type SanityQueryClient as S, type VercelClient as V, type Ga4Report as a, type Ga4ReportRequest as b, type ServiceAccountKey as c, type VercelPageviews as d, createGa4Client as e, createVercelClient as f, parseServiceAccountKey as p };
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/** What this module needs from a Sanity client — kept minimal so it is trivial to stub in tests. */
|
|
2
|
+
interface SanityQueryClient {
|
|
3
|
+
fetch<T>(query: string, params?: Record<string, unknown>): Promise<T>;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Service-account access tokens for the GA4 Data API.
|
|
8
|
+
*
|
|
9
|
+
* Implemented directly against Google's OAuth2 token endpoint rather than through `googleapis`,
|
|
10
|
+
* which is a very large dependency to pull into a package that only needs one grant type. Signing
|
|
11
|
+
* a JWT with node:crypto keeps the dependency surface at zero and removes any risk of a
|
|
12
|
+
* credential-bearing SDK being reachable from the Studio entry point.
|
|
13
|
+
*/
|
|
14
|
+
/** The parts of a service-account JSON key this module uses. */
|
|
15
|
+
interface ServiceAccountKey {
|
|
16
|
+
client_email: string;
|
|
17
|
+
private_key: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Parse a service-account key from an environment variable.
|
|
21
|
+
*
|
|
22
|
+
* Accepts either raw JSON or base64-encoded JSON, because multi-line JSON with embedded newlines
|
|
23
|
+
* is awkward to set in some dashboards and gets mangled often enough to be worth tolerating both.
|
|
24
|
+
*
|
|
25
|
+
* @param raw - the environment variable's value
|
|
26
|
+
* @returns the parsed key, or null when unset or unparseable
|
|
27
|
+
*/
|
|
28
|
+
declare function parseServiceAccountKey(raw: string | undefined): ServiceAccountKey | null;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* GA4 Data API client.
|
|
32
|
+
*
|
|
33
|
+
* Only `runReport` and `batchRunReports` are used. `runFunnelReport` is deliberately not called:
|
|
34
|
+
* it is an alpha surface with its own stricter quota, and it returns step-conversion marginals
|
|
35
|
+
* rather than observed paths — drawing a flow diagram from it would imply co-occurrence that was
|
|
36
|
+
* never measured. The journey report approximates instead, and says so.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** A GA4 report request, narrowed to the fields this package sets. */
|
|
40
|
+
interface Ga4ReportRequest {
|
|
41
|
+
dimensions?: Array<{
|
|
42
|
+
name: string;
|
|
43
|
+
}>;
|
|
44
|
+
metrics?: Array<{
|
|
45
|
+
name: string;
|
|
46
|
+
}>;
|
|
47
|
+
dateRanges: Array<{
|
|
48
|
+
startDate: string;
|
|
49
|
+
endDate: string;
|
|
50
|
+
}>;
|
|
51
|
+
dimensionFilter?: unknown;
|
|
52
|
+
orderBys?: unknown;
|
|
53
|
+
limit?: number;
|
|
54
|
+
keepEmptyRows?: boolean;
|
|
55
|
+
}
|
|
56
|
+
/** A parsed report row: dimension values and metric values, positionally aligned to the request. */
|
|
57
|
+
interface Ga4Row {
|
|
58
|
+
dimensions: string[];
|
|
59
|
+
metrics: number[];
|
|
60
|
+
}
|
|
61
|
+
/** A parsed GA4 report. */
|
|
62
|
+
interface Ga4Report {
|
|
63
|
+
rows: Ga4Row[];
|
|
64
|
+
/** True when GA4 withheld rows for privacy thresholding — totals are then incomplete. */
|
|
65
|
+
thresholded: boolean;
|
|
66
|
+
/** True when GA4 answered from a sample rather than the full data set. */
|
|
67
|
+
sampled: boolean;
|
|
68
|
+
/** Total row count GA4 reports, which may exceed rows returned when a limit applied. */
|
|
69
|
+
rowCount: number;
|
|
70
|
+
/**
|
|
71
|
+
* The property's configured timezone, as GA4 reports it. Worth capturing because every range
|
|
72
|
+
* is anchored to the timezone in site config, and a mismatch silently shifts day boundaries.
|
|
73
|
+
*/
|
|
74
|
+
timeZone?: string;
|
|
75
|
+
}
|
|
76
|
+
/** A GA4 client bound to one property. */
|
|
77
|
+
interface Ga4Client {
|
|
78
|
+
runReport(request: Ga4ReportRequest): Promise<Ga4Report>;
|
|
79
|
+
batchRunReports(requests: Ga4ReportRequest[]): Promise<Ga4Report[]>;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Build a GA4 client for one property.
|
|
83
|
+
*
|
|
84
|
+
* @param propertyId - numeric GA4 property id
|
|
85
|
+
* @param key - service-account key with Viewer on that property
|
|
86
|
+
*/
|
|
87
|
+
declare function createGa4Client(propertyId: string, key: ServiceAccountKey): Ga4Client;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Vercel Web Analytics client.
|
|
91
|
+
*
|
|
92
|
+
* Uses the documented Web Analytics query API under `/v1/query/web-analytics/visits/*`. An earlier
|
|
93
|
+
* version of this file guessed at `/v1/web-analytics/timeseries`, which does not exist and returned
|
|
94
|
+
* 404 for every project — indistinguishable, from the outside, from the feature being switched off.
|
|
95
|
+
* That mistake is why `unavailable` carries a reason: a 404 here now surfaces as a source error to
|
|
96
|
+
* be investigated rather than as an absence of traffic.
|
|
97
|
+
*
|
|
98
|
+
* Vercel is counted purely as a second, cookieless pageview measurement. It is never treated as
|
|
99
|
+
* ground truth, and never subtracted from a GA4 session count — those are different units.
|
|
100
|
+
*
|
|
101
|
+
* Note the API defaults to the production environment only, which is what the reports want.
|
|
102
|
+
*/
|
|
103
|
+
/** Daily pageview counts, keyed by ISO date. */
|
|
104
|
+
interface VercelPageviews {
|
|
105
|
+
byDate: Record<string, number>;
|
|
106
|
+
total: number;
|
|
107
|
+
/** Distinct visitors over the whole range. Not a sum of the daily figures — visitors dedupe. */
|
|
108
|
+
visitors: number;
|
|
109
|
+
}
|
|
110
|
+
/** A Vercel client bound to one project. */
|
|
111
|
+
interface VercelClient {
|
|
112
|
+
pageviews(start: string, end: string): Promise<VercelPageviews>;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Build a Vercel Web Analytics client.
|
|
116
|
+
*
|
|
117
|
+
* @param projectId - the Vercel project id
|
|
118
|
+
* @param token - a Vercel API token with read access to that project
|
|
119
|
+
* @param teamId - team scope, required when the project belongs to a team
|
|
120
|
+
*/
|
|
121
|
+
declare function createVercelClient(projectId: string, token: string, teamId?: string): VercelClient;
|
|
122
|
+
|
|
123
|
+
export { type Ga4Client as G, type SanityQueryClient as S, type VercelClient as V, type Ga4Report as a, type Ga4ReportRequest as b, type ServiceAccountKey as c, type VercelPageviews as d, createGa4Client as e, createVercelClient as f, parseServiceAccountKey as p };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liiift-studio/sanity-visitor-insights",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "Visitor-behaviour analytics for type-foundry Sanity Studios
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"description": "Visitor-behaviour analytics for type-foundry Sanity Studios — GA4, Vercel and order data reconciled honestly",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.mjs",
|
|
7
7
|
"types": "dist/index.d.ts",
|
package/src/server/vercel.ts
CHANGED
|
@@ -1,22 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Vercel Web Analytics client.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Uses the documented Web Analytics query API under `/v1/query/web-analytics/visits/*`. An earlier
|
|
5
|
+
* version of this file guessed at `/v1/web-analytics/timeseries`, which does not exist and returned
|
|
6
|
+
* 404 for every project — indistinguishable, from the outside, from the feature being switched off.
|
|
7
|
+
* That mistake is why `unavailable` carries a reason: a 404 here now surfaces as a source error to
|
|
8
|
+
* be investigated rather than as an absence of traffic.
|
|
9
9
|
*
|
|
10
|
-
* Vercel is counted
|
|
11
|
-
*
|
|
10
|
+
* Vercel is counted purely as a second, cookieless pageview measurement. It is never treated as
|
|
11
|
+
* ground truth, and never subtracted from a GA4 session count — those are different units.
|
|
12
|
+
*
|
|
13
|
+
* Note the API defaults to the production environment only, which is what the reports want.
|
|
12
14
|
*/
|
|
13
15
|
|
|
14
|
-
const API_BASE = 'https://api.vercel.com'
|
|
16
|
+
const API_BASE = 'https://api.vercel.com/v1/query/web-analytics'
|
|
15
17
|
|
|
16
18
|
/** Daily pageview counts, keyed by ISO date. */
|
|
17
19
|
export interface VercelPageviews {
|
|
18
20
|
byDate: Record<string, number>
|
|
19
21
|
total: number
|
|
22
|
+
/** Distinct visitors over the whole range. Not a sum of the daily figures — visitors dedupe. */
|
|
23
|
+
visitors: number
|
|
20
24
|
}
|
|
21
25
|
|
|
22
26
|
/** A Vercel client bound to one project. */
|
|
@@ -24,6 +28,13 @@ export interface VercelClient {
|
|
|
24
28
|
pageviews(start: string, end: string): Promise<VercelPageviews>
|
|
25
29
|
}
|
|
26
30
|
|
|
31
|
+
/** Shape of the aggregate response rows. */
|
|
32
|
+
interface AggregateRow {
|
|
33
|
+
timestamp?: string
|
|
34
|
+
pageviews?: number
|
|
35
|
+
visitors?: number
|
|
36
|
+
}
|
|
37
|
+
|
|
27
38
|
/**
|
|
28
39
|
* Build a Vercel Web Analytics client.
|
|
29
40
|
*
|
|
@@ -32,37 +43,52 @@ export interface VercelClient {
|
|
|
32
43
|
* @param teamId - team scope, required when the project belongs to a team
|
|
33
44
|
*/
|
|
34
45
|
export function createVercelClient(projectId: string, token: string, teamId?: string): VercelClient {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
projectId,
|
|
39
|
-
since: `${start}T00:00:00.000Z`,
|
|
40
|
-
until: `${end}T23:59:59.999Z`,
|
|
41
|
-
})
|
|
42
|
-
if (teamId) params.set('teamId', teamId)
|
|
46
|
+
async function query<T>(path: string, extra: Record<string, string>): Promise<T> {
|
|
47
|
+
const params = new URLSearchParams({ projectId, ...extra })
|
|
48
|
+
if (teamId) params.set('teamId', teamId)
|
|
43
49
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
50
|
+
const response = await fetch(`${API_BASE}/${path}?${params.toString()}`, {
|
|
51
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
52
|
+
})
|
|
47
53
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
54
|
+
if (!response.ok) {
|
|
55
|
+
// 404 here means the endpoint or project is wrong, or Web Analytics is not enabled —
|
|
56
|
+
// the status is the only useful detail, and the body can echo the request.
|
|
57
|
+
throw new Error(`Vercel Web Analytics ${path} failed with ${response.status}`)
|
|
58
|
+
}
|
|
51
59
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
60
|
+
return (await response.json()) as T
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
async pageviews(start, end) {
|
|
65
|
+
// Two calls on purpose. The count endpoint gives range totals with visitors deduped
|
|
66
|
+
// across the whole window; summing the daily rows would overcount visitors, because a
|
|
67
|
+
// person returning on three days is three daily visitors but one range visitor.
|
|
68
|
+
const [totals, series] = await Promise.all([
|
|
69
|
+
query<{ data?: { pageviews?: number; visitors?: number } }>('visits/count', {
|
|
70
|
+
since: start,
|
|
71
|
+
until: end,
|
|
72
|
+
}),
|
|
73
|
+
query<{ data?: AggregateRow[] }>('visits/aggregate', {
|
|
74
|
+
since: start,
|
|
75
|
+
until: end,
|
|
76
|
+
by: 'day',
|
|
77
|
+
limit: '100',
|
|
78
|
+
}),
|
|
79
|
+
])
|
|
55
80
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const count = typeof point.total === 'number' ? point.total : 0
|
|
61
|
-
byDate[date] = (byDate[date] ?? 0) + count
|
|
62
|
-
total += count
|
|
81
|
+
const byDate: Record<string, number> = {}
|
|
82
|
+
for (const row of series.data ?? []) {
|
|
83
|
+
if (!row.timestamp) continue
|
|
84
|
+
byDate[row.timestamp.slice(0, 10)] = row.pageviews ?? 0
|
|
63
85
|
}
|
|
64
86
|
|
|
65
|
-
return {
|
|
87
|
+
return {
|
|
88
|
+
byDate,
|
|
89
|
+
total: totals.data?.pageviews ?? 0,
|
|
90
|
+
visitors: totals.data?.visitors ?? 0,
|
|
91
|
+
}
|
|
66
92
|
},
|
|
67
93
|
}
|
|
68
94
|
}
|
package/src/server.ts
CHANGED
|
@@ -20,6 +20,12 @@ export type { TypefaceInterestData, TypefaceInterestRow } from './server/reports
|
|
|
20
20
|
export { JOURNEY_STEPS } from './server/reports/journey'
|
|
21
21
|
export { DESIGN_INDUSTRY_SOURCES } from './server/reports/acquisition'
|
|
22
22
|
export { runDiagnostics, type DiagnosticsInput } from './server/diagnostics'
|
|
23
|
+
|
|
24
|
+
// Client constructors. Needed to call runDiagnostics headlessly, which the README documents —
|
|
25
|
+
// without these exported that example could not actually be written.
|
|
26
|
+
export { createGa4Client, type Ga4Client, type Ga4Report, type Ga4ReportRequest } from './server/ga4'
|
|
27
|
+
export { createVercelClient, type VercelClient, type VercelPageviews } from './server/vercel'
|
|
28
|
+
export { parseServiceAccountKey, type ServiceAccountKey } from './server/googleAuth'
|
|
23
29
|
export type { CheckStatus, DiagnosticCheck, DiagnosticReport } from './reportData'
|
|
24
30
|
|
|
25
31
|
// Shared contract, re-exported so the server entry is self-sufficient.
|
package/src/testing/fakes.ts
CHANGED
|
@@ -98,11 +98,19 @@ export function createFakeVercelClient(
|
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
/**
|
|
102
|
-
|
|
101
|
+
/**
|
|
102
|
+
* Build a Vercel pageviews fixture.
|
|
103
|
+
*
|
|
104
|
+
* `visitors` defaults to roughly two thirds of pageviews rather than to the pageview total,
|
|
105
|
+
* because they are genuinely different numbers — a fixture where they match would let a bug that
|
|
106
|
+
* confuses the two pass unnoticed.
|
|
107
|
+
*/
|
|
108
|
+
export function makeVercelPageviews(byDate: Record<string, number>, visitors?: number): VercelPageviews {
|
|
109
|
+
const total = Object.values(byDate).reduce((sum, n) => sum + n, 0)
|
|
103
110
|
return {
|
|
104
111
|
byDate,
|
|
105
|
-
total
|
|
112
|
+
total,
|
|
113
|
+
visitors: visitors ?? Math.round(total * 0.66),
|
|
106
114
|
}
|
|
107
115
|
}
|
|
108
116
|
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
/** What this module needs from a Sanity client — kept minimal so it is trivial to stub in tests. */
|
|
2
|
-
interface SanityQueryClient {
|
|
3
|
-
fetch<T>(query: string, params?: Record<string, unknown>): Promise<T>;
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* GA4 Data API client.
|
|
8
|
-
*
|
|
9
|
-
* Only `runReport` and `batchRunReports` are used. `runFunnelReport` is deliberately not called:
|
|
10
|
-
* it is an alpha surface with its own stricter quota, and it returns step-conversion marginals
|
|
11
|
-
* rather than observed paths — drawing a flow diagram from it would imply co-occurrence that was
|
|
12
|
-
* never measured. The journey report approximates instead, and says so.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
/** A GA4 report request, narrowed to the fields this package sets. */
|
|
16
|
-
interface Ga4ReportRequest {
|
|
17
|
-
dimensions?: Array<{
|
|
18
|
-
name: string;
|
|
19
|
-
}>;
|
|
20
|
-
metrics?: Array<{
|
|
21
|
-
name: string;
|
|
22
|
-
}>;
|
|
23
|
-
dateRanges: Array<{
|
|
24
|
-
startDate: string;
|
|
25
|
-
endDate: string;
|
|
26
|
-
}>;
|
|
27
|
-
dimensionFilter?: unknown;
|
|
28
|
-
orderBys?: unknown;
|
|
29
|
-
limit?: number;
|
|
30
|
-
keepEmptyRows?: boolean;
|
|
31
|
-
}
|
|
32
|
-
/** A parsed report row: dimension values and metric values, positionally aligned to the request. */
|
|
33
|
-
interface Ga4Row {
|
|
34
|
-
dimensions: string[];
|
|
35
|
-
metrics: number[];
|
|
36
|
-
}
|
|
37
|
-
/** A parsed GA4 report. */
|
|
38
|
-
interface Ga4Report {
|
|
39
|
-
rows: Ga4Row[];
|
|
40
|
-
/** True when GA4 withheld rows for privacy thresholding — totals are then incomplete. */
|
|
41
|
-
thresholded: boolean;
|
|
42
|
-
/** True when GA4 answered from a sample rather than the full data set. */
|
|
43
|
-
sampled: boolean;
|
|
44
|
-
/** Total row count GA4 reports, which may exceed rows returned when a limit applied. */
|
|
45
|
-
rowCount: number;
|
|
46
|
-
/**
|
|
47
|
-
* The property's configured timezone, as GA4 reports it. Worth capturing because every range
|
|
48
|
-
* is anchored to the timezone in site config, and a mismatch silently shifts day boundaries.
|
|
49
|
-
*/
|
|
50
|
-
timeZone?: string;
|
|
51
|
-
}
|
|
52
|
-
/** A GA4 client bound to one property. */
|
|
53
|
-
interface Ga4Client {
|
|
54
|
-
runReport(request: Ga4ReportRequest): Promise<Ga4Report>;
|
|
55
|
-
batchRunReports(requests: Ga4ReportRequest[]): Promise<Ga4Report[]>;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Vercel Web Analytics client.
|
|
60
|
-
*
|
|
61
|
-
* Vercel's Web Analytics read API is not a stable, versioned public surface the way the GA4 Data
|
|
62
|
-
* API is, and access depends on plan tier. This client is therefore written to degrade rather than
|
|
63
|
-
* throw: a missing token, an unavailable plan, or a changed endpoint all surface as an unconfigured
|
|
64
|
-
* or errored source in the report envelope, so the Measurement Health panel can say "Vercel did not
|
|
65
|
-
* answer" instead of implying the site had no traffic.
|
|
66
|
-
*
|
|
67
|
-
* Vercel is counted here purely as a second, cookieless pageview measurement. It is never treated
|
|
68
|
-
* as ground truth, and never subtracted from a GA4 session count — those are different units.
|
|
69
|
-
*/
|
|
70
|
-
/** Daily pageview counts, keyed by ISO date. */
|
|
71
|
-
interface VercelPageviews {
|
|
72
|
-
byDate: Record<string, number>;
|
|
73
|
-
total: number;
|
|
74
|
-
}
|
|
75
|
-
/** A Vercel client bound to one project. */
|
|
76
|
-
interface VercelClient {
|
|
77
|
-
pageviews(start: string, end: string): Promise<VercelPageviews>;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export type { Ga4Client as G, SanityQueryClient as S, VercelClient as V, Ga4ReportRequest as a, Ga4Report as b, VercelPageviews as c };
|