@liiift-studio/sanity-visitor-insights 0.35.0 → 0.36.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/dist/server.js +65 -6
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +65 -6
- package/dist/server.mjs.map +1 -1
- package/package.json +1 -1
- package/src/server/cache.ts +18 -2
- package/src/server/createHandler.ts +12 -0
- package/src/server/fetchWithTimeout.ts +66 -0
- package/src/server/ga4.ts +51 -4
- package/src/server/mailchimp.ts +2 -1
- package/src/server/orders.ts +15 -0
- package/src/server/reports/reports.test.ts +97 -0
- package/src/server/vercel.ts +2 -1
package/dist/server.js
CHANGED
|
@@ -470,6 +470,30 @@ async function requireStudioUser(req, res, sanityProjectId, requiredRoles) {
|
|
|
470
470
|
return result.user;
|
|
471
471
|
}
|
|
472
472
|
|
|
473
|
+
// src/server/fetchWithTimeout.ts
|
|
474
|
+
var REQUEST_TIMEOUT_MS = 12e3;
|
|
475
|
+
async function fetchWithTimeout(input, init = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
476
|
+
const controller = new AbortController();
|
|
477
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
478
|
+
const caller = init.signal;
|
|
479
|
+
const onCallerAbort = () => controller.abort();
|
|
480
|
+
if (caller) {
|
|
481
|
+
if (caller.aborted) controller.abort();
|
|
482
|
+
else caller.addEventListener("abort", onCallerAbort, { once: true });
|
|
483
|
+
}
|
|
484
|
+
try {
|
|
485
|
+
return await fetch(input, { ...init, signal: controller.signal });
|
|
486
|
+
} catch (error) {
|
|
487
|
+
if (controller.signal.aborted && !caller?.aborted) {
|
|
488
|
+
throw new Error(`Request timed out after ${timeoutMs}ms`);
|
|
489
|
+
}
|
|
490
|
+
throw error;
|
|
491
|
+
} finally {
|
|
492
|
+
clearTimeout(timer);
|
|
493
|
+
caller?.removeEventListener("abort", onCallerAbort);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
473
497
|
// src/server/googleAuth.ts
|
|
474
498
|
var import_node_crypto = require("crypto");
|
|
475
499
|
var ANALYTICS_READONLY_SCOPE = "https://www.googleapis.com/auth/analytics.readonly";
|
|
@@ -545,6 +569,7 @@ async function getAccessToken(key) {
|
|
|
545
569
|
// src/server/ga4.ts
|
|
546
570
|
var DATA_API_BASE = "https://analyticsdata.googleapis.com/v1beta";
|
|
547
571
|
var DATA_API_ALPHA = "https://analyticsdata.googleapis.com/v1alpha";
|
|
572
|
+
var EMPTY_REPORT = { rows: [], thresholded: false, sampled: false, rowCount: 0 };
|
|
548
573
|
function toMetricNumber(raw) {
|
|
549
574
|
if (raw === void 0 || raw === "") return Number.NaN;
|
|
550
575
|
const parsed = Number(raw);
|
|
@@ -616,6 +641,16 @@ function andFilters(...filters) {
|
|
|
616
641
|
if (present.length === 1) return present[0];
|
|
617
642
|
return { andGroup: { expressions: present } };
|
|
618
643
|
}
|
|
644
|
+
function alignBatch(chunks) {
|
|
645
|
+
return chunks.flatMap(({ reports, expected }) => {
|
|
646
|
+
if (reports.length !== expected) {
|
|
647
|
+
console.error(
|
|
648
|
+
`Visitor insights: GA4 returned ${reports.length} of ${expected} reports in a batch; padding to keep results aligned.`
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
return Array.from({ length: expected }, (_, i) => reports[i] ?? EMPTY_REPORT);
|
|
652
|
+
});
|
|
653
|
+
}
|
|
619
654
|
function createGa4Client(propertyId, key, options = {}) {
|
|
620
655
|
const hosts = options.hostnames && options.hostnames.length > 0 ? options.hostnames : null;
|
|
621
656
|
const hostFilter = hosts ? hostnameFilter(hosts) : void 0;
|
|
@@ -625,7 +660,7 @@ function createGa4Client(propertyId, key, options = {}) {
|
|
|
625
660
|
}
|
|
626
661
|
async function post(path, body, base = DATA_API_BASE) {
|
|
627
662
|
const token = await getAccessToken(key);
|
|
628
|
-
const response = await
|
|
663
|
+
const response = await fetchWithTimeout(`${base}/properties/${propertyId}:${path}`, {
|
|
629
664
|
method: "POST",
|
|
630
665
|
headers: {
|
|
631
666
|
Authorization: `Bearer ${token}`,
|
|
@@ -658,7 +693,12 @@ function createGa4Client(propertyId, key, options = {}) {
|
|
|
658
693
|
(chunk) => post("batchRunReports", { requests: chunk.map(narrow) })
|
|
659
694
|
)
|
|
660
695
|
);
|
|
661
|
-
return
|
|
696
|
+
return alignBatch(
|
|
697
|
+
responses.map((raw, index) => ({
|
|
698
|
+
reports: (raw.reports ?? []).map(parseReport),
|
|
699
|
+
expected: chunks[index].length
|
|
700
|
+
}))
|
|
701
|
+
);
|
|
662
702
|
},
|
|
663
703
|
async runFunnelReport(steps, range) {
|
|
664
704
|
const body = {
|
|
@@ -741,7 +781,7 @@ function createVercelClient(projectId, token, teamId) {
|
|
|
741
781
|
async function query(path, extra) {
|
|
742
782
|
const params = new URLSearchParams({ projectId, ...extra });
|
|
743
783
|
if (teamId) params.set("teamId", teamId);
|
|
744
|
-
const response = await
|
|
784
|
+
const response = await fetchWithTimeout(`${API_BASE}/${path}?${params.toString()}`, {
|
|
745
785
|
headers: { Authorization: `Bearer ${token}` }
|
|
746
786
|
});
|
|
747
787
|
if (!response.ok) {
|
|
@@ -816,7 +856,7 @@ function createMailchimpClient(apiKey, listId) {
|
|
|
816
856
|
const auth = `Basic ${Buffer.from(`key:${apiKey}`).toString("base64")}`;
|
|
817
857
|
async function get(path, params = {}) {
|
|
818
858
|
const query = new URLSearchParams(params).toString();
|
|
819
|
-
const response = await
|
|
859
|
+
const response = await fetchWithTimeout(`${base}${path}${query ? `?${query}` : ""}`, {
|
|
820
860
|
headers: { Authorization: auth }
|
|
821
861
|
});
|
|
822
862
|
if (!response.ok) {
|
|
@@ -903,11 +943,12 @@ function setCached(key, value, ttlMs = DEFAULT_TTL_MS) {
|
|
|
903
943
|
}
|
|
904
944
|
store.set(key, { value, expiresAt: Date.now() + ttlMs });
|
|
905
945
|
}
|
|
906
|
-
async function withCache(key, ttlMs, compute) {
|
|
946
|
+
async function withCache(key, ttlMs, compute, ttlFor) {
|
|
907
947
|
const hit = getCached(key);
|
|
908
948
|
if (hit !== void 0) return hit;
|
|
909
949
|
const value = await compute();
|
|
910
|
-
|
|
950
|
+
const ttl = ttlFor ? ttlFor(value) : ttlMs;
|
|
951
|
+
if (ttl > 0) setCached(key, value, ttl);
|
|
911
952
|
return value;
|
|
912
953
|
}
|
|
913
954
|
function clearCache() {
|
|
@@ -934,6 +975,21 @@ function orderQueryOptions(orders, range) {
|
|
|
934
975
|
function orderFilter(documentType, excludeFilter) {
|
|
935
976
|
const clauses = [
|
|
936
977
|
`_type == $documentType`,
|
|
978
|
+
/*
|
|
979
|
+
* Published only.
|
|
980
|
+
*
|
|
981
|
+
* @sanity/client v6 defaults to the `raw` perspective for a token-authenticated request, and
|
|
982
|
+
* the sites pass a token — so an order that anyone has opened and edited in the Studio comes
|
|
983
|
+
* back twice, as `orderId` and as `drafts.orderId`. That double-counts it in the order total,
|
|
984
|
+
* in the revenue, and in its day's stem: a 14% error at seven orders a quarter, in the one
|
|
985
|
+
* figure this tool calls exact and calibrates the whole capture model against.
|
|
986
|
+
*
|
|
987
|
+
* Darden currently has no draft orders, so this is latent rather than live — which is exactly
|
|
988
|
+
* the kind of thing that stays latent until the day someone opens an order to check it.
|
|
989
|
+
* Filtering here rather than relying on a perspective the consumer configures, because the
|
|
990
|
+
* consumer is a site that has no reason to know this report depends on it.
|
|
991
|
+
*/
|
|
992
|
+
`!(_id in path("drafts.**"))`,
|
|
937
993
|
`_createdAt >= $start`,
|
|
938
994
|
`_createdAt < $end`
|
|
939
995
|
];
|
|
@@ -2445,6 +2501,9 @@ function createVisitorInsightsHandler(options) {
|
|
|
2445
2501
|
}
|
|
2446
2502
|
} : {}
|
|
2447
2503
|
};
|
|
2504
|
+
}, (envelope2) => {
|
|
2505
|
+
const degraded = Object.values(envelope2.sources).some((source) => source?.status !== "ok");
|
|
2506
|
+
return degraded ? Math.min(ttl, 2e4) : ttl;
|
|
2448
2507
|
});
|
|
2449
2508
|
res.status(200).json(envelope);
|
|
2450
2509
|
} catch (e) {
|