@liiift-studio/sanity-visitor-insights 0.44.0 → 0.46.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/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/{ranges-Cc3Bv5-l.d.mts → ranges-CWGvBi3C.d.mts} +1 -1
- package/dist/{ranges-Cc3Bv5-l.d.ts → ranges-CWGvBi3C.d.ts} +1 -1
- package/dist/server.d.mts +4 -4
- package/dist/server.d.ts +4 -4
- package/dist/server.js +57 -18
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +57 -18
- package/dist/server.mjs.map +1 -1
- package/dist/testing.d.mts +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/{vercel-Da9lu56v.d.mts → vercel-CGDmz9Bt.d.mts} +4 -0
- package/dist/{vercel-Da9lu56v.d.ts → vercel-CGDmz9Bt.d.ts} +4 -0
- package/package.json +1 -1
- package/src/core/capture.ts +30 -5
- package/src/server/diagnostics.ts +7 -2
- package/src/server/mailchimp.ts +43 -6
- package/src/server/reports/acquisition.ts +18 -2
- package/src/server/reports/measurementHealth.ts +43 -7
- package/src/server/reports/reports.test.ts +47 -0
- package/src/server/vercel.test.ts +66 -0
package/dist/index.d.mts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as sanity from 'sanity';
|
|
2
2
|
import React from 'react';
|
|
3
|
-
import { R as ReportEnvelope, a as ReportName, b as RangeKey, M as MetricValue, A as AcquisitionData, c as MeasurementHealthData, D as DiagnosticReport, J as JourneyData, T as TypefaceInterestData } from './ranges-
|
|
4
|
-
export { C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, g as Coverage, h as CrossSourceDay, i as DailyPoint, j as DateRange, k as DiagnosticCheck, E as EmailCampaign, l as EventCutover, m as JourneyOutcome, n as JourneyStep, L as LandingPage, o as LicenceTierRow, P as PREEXISTING, p as REPORT_NAMES, q as ReportError, S as SiteAnalyticsConfig, r as SourceName, s as SourceRow, t as SourceStatus, u as TimelineEvent, v as TypefaceInterestRow, U as UnavailableReason, w as coverageForRange, x as estimated, y as isReportName, z as ok, B as partial, F as previousRange, G as resolveRange, H as unavailable, I as validateSiteConfig, K as valueOrNull } from './ranges-
|
|
3
|
+
import { R as ReportEnvelope, a as ReportName, b as RangeKey, M as MetricValue, A as AcquisitionData, c as MeasurementHealthData, D as DiagnosticReport, J as JourneyData, T as TypefaceInterestData } from './ranges-CWGvBi3C.mjs';
|
|
4
|
+
export { C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, g as Coverage, h as CrossSourceDay, i as DailyPoint, j as DateRange, k as DiagnosticCheck, E as EmailCampaign, l as EventCutover, m as JourneyOutcome, n as JourneyStep, L as LandingPage, o as LicenceTierRow, P as PREEXISTING, p as REPORT_NAMES, q as ReportError, S as SiteAnalyticsConfig, r as SourceName, s as SourceRow, t as SourceStatus, u as TimelineEvent, v as TypefaceInterestRow, U as UnavailableReason, w as coverageForRange, x as estimated, y as isReportName, z as ok, B as partial, F as previousRange, G as resolveRange, H as unavailable, I as validateSiteConfig, K as valueOrNull } from './ranges-CWGvBi3C.mjs';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Data hook for the Studio panels.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as sanity from 'sanity';
|
|
2
2
|
import React from 'react';
|
|
3
|
-
import { R as ReportEnvelope, a as ReportName, b as RangeKey, M as MetricValue, A as AcquisitionData, c as MeasurementHealthData, D as DiagnosticReport, J as JourneyData, T as TypefaceInterestData } from './ranges-
|
|
4
|
-
export { C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, g as Coverage, h as CrossSourceDay, i as DailyPoint, j as DateRange, k as DiagnosticCheck, E as EmailCampaign, l as EventCutover, m as JourneyOutcome, n as JourneyStep, L as LandingPage, o as LicenceTierRow, P as PREEXISTING, p as REPORT_NAMES, q as ReportError, S as SiteAnalyticsConfig, r as SourceName, s as SourceRow, t as SourceStatus, u as TimelineEvent, v as TypefaceInterestRow, U as UnavailableReason, w as coverageForRange, x as estimated, y as isReportName, z as ok, B as partial, F as previousRange, G as resolveRange, H as unavailable, I as validateSiteConfig, K as valueOrNull } from './ranges-
|
|
3
|
+
import { R as ReportEnvelope, a as ReportName, b as RangeKey, M as MetricValue, A as AcquisitionData, c as MeasurementHealthData, D as DiagnosticReport, J as JourneyData, T as TypefaceInterestData } from './ranges-CWGvBi3C.js';
|
|
4
|
+
export { C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, g as Coverage, h as CrossSourceDay, i as DailyPoint, j as DateRange, k as DiagnosticCheck, E as EmailCampaign, l as EventCutover, m as JourneyOutcome, n as JourneyStep, L as LandingPage, o as LicenceTierRow, P as PREEXISTING, p as REPORT_NAMES, q as ReportError, S as SiteAnalyticsConfig, r as SourceName, s as SourceRow, t as SourceStatus, u as TimelineEvent, v as TypefaceInterestRow, U as UnavailableReason, w as coverageForRange, x as estimated, y as isReportName, z as ok, B as partial, F as previousRange, G as resolveRange, H as unavailable, I as validateSiteConfig, K as valueOrNull } from './ranges-CWGvBi3C.js';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Data hook for the Studio panels.
|
|
@@ -227,7 +227,7 @@ declare function fromPageviews(ga4Pageviews: number, vercelPageviews: number): C
|
|
|
227
227
|
* single known source, so a shortfall here that the others do not show points at attribution — a
|
|
228
228
|
* tagging or redirect problem — rather than at measurement generally.
|
|
229
229
|
*/
|
|
230
|
-
declare function fromEmail(
|
|
230
|
+
declare function fromEmail(ga4Users: number, mailchimpClicks: number): CaptureEstimate | null;
|
|
231
231
|
/** The combined view of GA4's capture rate. */
|
|
232
232
|
interface CaptureModel {
|
|
233
233
|
/** Every estimate that had a usable denominator, best first. */
|
|
@@ -227,7 +227,7 @@ declare function fromPageviews(ga4Pageviews: number, vercelPageviews: number): C
|
|
|
227
227
|
* single known source, so a shortfall here that the others do not show points at attribution — a
|
|
228
228
|
* tagging or redirect problem — rather than at measurement generally.
|
|
229
229
|
*/
|
|
230
|
-
declare function fromEmail(
|
|
230
|
+
declare function fromEmail(ga4Users: number, mailchimpClicks: number): CaptureEstimate | null;
|
|
231
231
|
/** The combined view of GA4's capture rate. */
|
|
232
232
|
interface CaptureModel {
|
|
233
233
|
/** Every estimate that had a usable denominator, best first. */
|
package/dist/server.d.mts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { S as SiteAnalyticsConfig, D as DiagnosticReport } from './ranges-
|
|
2
|
-
export { A as AcquisitionData, C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, N as ConfigProblem, g as Coverage, h as CrossSourceDay, O as CustomRangeError, i as DailyPoint, j as DateRange, k as DiagnosticCheck, E as EmailCampaign, l as EventCutover, Q as EventHistory, V as EventNameMap, W as EventOutage, X as GA4_PROCESSING_LAG_DAYS, Y as Ga4Config, J as JourneyData, m as JourneyOutcome, n as JourneyStep, L as LandingPage, o as LicenceTierRow, Z as MAX_CUSTOM_RANGE_DAYS, c as MeasurementHealthData, M as MetricValue, _ as OrdersConfig, P as PREEXISTING, $ as RANGE_DAYS, p as REPORT_NAMES, b as RangeKey, R as ReportEnvelope, q as ReportError, a as ReportName, r as SourceName, s as SourceRow, t as SourceStatus, u as TimelineEvent, T as TypefaceInterestData, v as TypefaceInterestRow, U as UnavailableReason, a0 as VercelConfig, a1 as applyCoverage, a2 as assertValidSiteConfig, a3 as captureModel, a4 as coverageForAny, w as coverageForRange, a5 as coverageNotices, a6 as daysBetween, a7 as describeOutage, x as estimated, a8 as formatInTimeZone, a9 as fromEmail, aa as fromOrders, ab as fromPageviews, ac as grossUp, y as isReportName, ad as isValidTimeZone, z as ok, B as partial, F as previousRange, ae as provisionalDates, af as provisionalNotice, ag as resolveCustomRange, G as resolveRange, ah as shiftDays, H as unavailable, I as validateSiteConfig, K as valueOrNull, ai as zonedDayEndUtc, aj as zonedDayStartUtc } from './ranges-
|
|
3
|
-
import { S as SanityQueryClient, G as Ga4Client, V as VercelClient } from './vercel-
|
|
4
|
-
export { a as Ga4Report, b as Ga4ReportRequest, L as LicenceFieldMap, c as LicenceTier, M as MailchimpAudience, d as MailchimpCampaign, e as MailchimpClient, O as OrderCounts, f as OrderQueryOptions, g as ServiceAccountKey, T as TypefaceOrderCounts, U as UNKNOWN_STATUS, h as VercelPageviews, i as countLicenceTiers, j as countOrders, k as countOrdersByTypeface, l as createGa4Client, m as createMailchimpClient, n as createVercelClient, o as datacenterFromKey, p as eventNameFilter, q as orderQueryOptions, r as parseServiceAccountKey, s as sumFirstMetric } from './vercel-
|
|
1
|
+
import { S as SiteAnalyticsConfig, D as DiagnosticReport } from './ranges-CWGvBi3C.mjs';
|
|
2
|
+
export { A as AcquisitionData, C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, N as ConfigProblem, g as Coverage, h as CrossSourceDay, O as CustomRangeError, i as DailyPoint, j as DateRange, k as DiagnosticCheck, E as EmailCampaign, l as EventCutover, Q as EventHistory, V as EventNameMap, W as EventOutage, X as GA4_PROCESSING_LAG_DAYS, Y as Ga4Config, J as JourneyData, m as JourneyOutcome, n as JourneyStep, L as LandingPage, o as LicenceTierRow, Z as MAX_CUSTOM_RANGE_DAYS, c as MeasurementHealthData, M as MetricValue, _ as OrdersConfig, P as PREEXISTING, $ as RANGE_DAYS, p as REPORT_NAMES, b as RangeKey, R as ReportEnvelope, q as ReportError, a as ReportName, r as SourceName, s as SourceRow, t as SourceStatus, u as TimelineEvent, T as TypefaceInterestData, v as TypefaceInterestRow, U as UnavailableReason, a0 as VercelConfig, a1 as applyCoverage, a2 as assertValidSiteConfig, a3 as captureModel, a4 as coverageForAny, w as coverageForRange, a5 as coverageNotices, a6 as daysBetween, a7 as describeOutage, x as estimated, a8 as formatInTimeZone, a9 as fromEmail, aa as fromOrders, ab as fromPageviews, ac as grossUp, y as isReportName, ad as isValidTimeZone, z as ok, B as partial, F as previousRange, ae as provisionalDates, af as provisionalNotice, ag as resolveCustomRange, G as resolveRange, ah as shiftDays, H as unavailable, I as validateSiteConfig, K as valueOrNull, ai as zonedDayEndUtc, aj as zonedDayStartUtc } from './ranges-CWGvBi3C.mjs';
|
|
3
|
+
import { S as SanityQueryClient, G as Ga4Client, V as VercelClient } from './vercel-CGDmz9Bt.mjs';
|
|
4
|
+
export { a as Ga4Report, b as Ga4ReportRequest, L as LicenceFieldMap, c as LicenceTier, M as MailchimpAudience, d as MailchimpCampaign, e as MailchimpClient, O as OrderCounts, f as OrderQueryOptions, g as ServiceAccountKey, T as TypefaceOrderCounts, U as UNKNOWN_STATUS, h as VercelPageviews, i as countLicenceTiers, j as countOrders, k as countOrdersByTypeface, l as createGa4Client, m as createMailchimpClient, n as createVercelClient, o as datacenterFromKey, p as eventNameFilter, q as orderQueryOptions, r as parseServiceAccountKey, s as sumFirstMetric } from './vercel-CGDmz9Bt.mjs';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Request authentication and CORS for the report handler.
|
package/dist/server.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { S as SiteAnalyticsConfig, D as DiagnosticReport } from './ranges-
|
|
2
|
-
export { A as AcquisitionData, C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, N as ConfigProblem, g as Coverage, h as CrossSourceDay, O as CustomRangeError, i as DailyPoint, j as DateRange, k as DiagnosticCheck, E as EmailCampaign, l as EventCutover, Q as EventHistory, V as EventNameMap, W as EventOutage, X as GA4_PROCESSING_LAG_DAYS, Y as Ga4Config, J as JourneyData, m as JourneyOutcome, n as JourneyStep, L as LandingPage, o as LicenceTierRow, Z as MAX_CUSTOM_RANGE_DAYS, c as MeasurementHealthData, M as MetricValue, _ as OrdersConfig, P as PREEXISTING, $ as RANGE_DAYS, p as REPORT_NAMES, b as RangeKey, R as ReportEnvelope, q as ReportError, a as ReportName, r as SourceName, s as SourceRow, t as SourceStatus, u as TimelineEvent, T as TypefaceInterestData, v as TypefaceInterestRow, U as UnavailableReason, a0 as VercelConfig, a1 as applyCoverage, a2 as assertValidSiteConfig, a3 as captureModel, a4 as coverageForAny, w as coverageForRange, a5 as coverageNotices, a6 as daysBetween, a7 as describeOutage, x as estimated, a8 as formatInTimeZone, a9 as fromEmail, aa as fromOrders, ab as fromPageviews, ac as grossUp, y as isReportName, ad as isValidTimeZone, z as ok, B as partial, F as previousRange, ae as provisionalDates, af as provisionalNotice, ag as resolveCustomRange, G as resolveRange, ah as shiftDays, H as unavailable, I as validateSiteConfig, K as valueOrNull, ai as zonedDayEndUtc, aj as zonedDayStartUtc } from './ranges-
|
|
3
|
-
import { S as SanityQueryClient, G as Ga4Client, V as VercelClient } from './vercel-
|
|
4
|
-
export { a as Ga4Report, b as Ga4ReportRequest, L as LicenceFieldMap, c as LicenceTier, M as MailchimpAudience, d as MailchimpCampaign, e as MailchimpClient, O as OrderCounts, f as OrderQueryOptions, g as ServiceAccountKey, T as TypefaceOrderCounts, U as UNKNOWN_STATUS, h as VercelPageviews, i as countLicenceTiers, j as countOrders, k as countOrdersByTypeface, l as createGa4Client, m as createMailchimpClient, n as createVercelClient, o as datacenterFromKey, p as eventNameFilter, q as orderQueryOptions, r as parseServiceAccountKey, s as sumFirstMetric } from './vercel-
|
|
1
|
+
import { S as SiteAnalyticsConfig, D as DiagnosticReport } from './ranges-CWGvBi3C.js';
|
|
2
|
+
export { A as AcquisitionData, C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, N as ConfigProblem, g as Coverage, h as CrossSourceDay, O as CustomRangeError, i as DailyPoint, j as DateRange, k as DiagnosticCheck, E as EmailCampaign, l as EventCutover, Q as EventHistory, V as EventNameMap, W as EventOutage, X as GA4_PROCESSING_LAG_DAYS, Y as Ga4Config, J as JourneyData, m as JourneyOutcome, n as JourneyStep, L as LandingPage, o as LicenceTierRow, Z as MAX_CUSTOM_RANGE_DAYS, c as MeasurementHealthData, M as MetricValue, _ as OrdersConfig, P as PREEXISTING, $ as RANGE_DAYS, p as REPORT_NAMES, b as RangeKey, R as ReportEnvelope, q as ReportError, a as ReportName, r as SourceName, s as SourceRow, t as SourceStatus, u as TimelineEvent, T as TypefaceInterestData, v as TypefaceInterestRow, U as UnavailableReason, a0 as VercelConfig, a1 as applyCoverage, a2 as assertValidSiteConfig, a3 as captureModel, a4 as coverageForAny, w as coverageForRange, a5 as coverageNotices, a6 as daysBetween, a7 as describeOutage, x as estimated, a8 as formatInTimeZone, a9 as fromEmail, aa as fromOrders, ab as fromPageviews, ac as grossUp, y as isReportName, ad as isValidTimeZone, z as ok, B as partial, F as previousRange, ae as provisionalDates, af as provisionalNotice, ag as resolveCustomRange, G as resolveRange, ah as shiftDays, H as unavailable, I as validateSiteConfig, K as valueOrNull, ai as zonedDayEndUtc, aj as zonedDayStartUtc } from './ranges-CWGvBi3C.js';
|
|
3
|
+
import { S as SanityQueryClient, G as Ga4Client, V as VercelClient } from './vercel-CGDmz9Bt.js';
|
|
4
|
+
export { a as Ga4Report, b as Ga4ReportRequest, L as LicenceFieldMap, c as LicenceTier, M as MailchimpAudience, d as MailchimpCampaign, e as MailchimpClient, O as OrderCounts, f as OrderQueryOptions, g as ServiceAccountKey, T as TypefaceOrderCounts, U as UNKNOWN_STATUS, h as VercelPageviews, i as countLicenceTiers, j as countOrders, k as countOrdersByTypeface, l as createGa4Client, m as createMailchimpClient, n as createVercelClient, o as datacenterFromKey, p as eventNameFilter, q as orderQueryOptions, r as parseServiceAccountKey, s as sumFirstMetric } from './vercel-CGDmz9Bt.js';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Request authentication and CORS for the report handler.
|
package/dist/server.js
CHANGED
|
@@ -874,9 +874,11 @@ function createMailchimpClient(apiKey, listId) {
|
|
|
874
874
|
const month = range.start.slice(0, 7);
|
|
875
875
|
const history = await get(
|
|
876
876
|
`/lists/${listId}/growth-history/${month}`,
|
|
877
|
-
{ fields: "existing,imports,optins" }
|
|
877
|
+
{ fields: "subscribed,existing,imports,optins" }
|
|
878
878
|
);
|
|
879
|
-
|
|
879
|
+
const legacy = [history.existing, history.imports, history.optins];
|
|
880
|
+
const legacyTotal = legacy.some((v) => typeof v === "number") ? legacy.reduce((sum, v) => sum + count(v), 0) : null;
|
|
881
|
+
membersAtStart = typeof history.subscribed === "number" ? history.subscribed : legacyTotal;
|
|
880
882
|
} catch {
|
|
881
883
|
membersAtStart = null;
|
|
882
884
|
}
|
|
@@ -887,8 +889,17 @@ function createMailchimpClient(apiKey, listId) {
|
|
|
887
889
|
const reports = await get("/reports", {
|
|
888
890
|
// Bounded by the range on both sides, so a panel showing one week does not pick up a
|
|
889
891
|
// campaign from last year and attribute its clicks to this window.
|
|
890
|
-
|
|
891
|
-
|
|
892
|
+
/*
|
|
893
|
+
* Bounded in the PROPERTY timezone, like the orders are.
|
|
894
|
+
*
|
|
895
|
+
* These were bare UTC instants while `zonedDayStartUtc`/`zonedDayEndUtc` exist and
|
|
896
|
+
* orders.ts spends a paragraph on why that asks for a different window than the one GA4
|
|
897
|
+
* was asked for. For a US-Pacific property a campaign sent at 5pm local on the last day
|
|
898
|
+
* of the range fell outside this window while the GA4 sessions it produced fell inside —
|
|
899
|
+
* quietly shrinking the denominator of an estimate whose note calls it known exactly.
|
|
900
|
+
*/
|
|
901
|
+
since_send_time: zonedDayStartUtc(range.start, range.timezone ?? "UTC"),
|
|
902
|
+
before_send_time: zonedDayEndUtc(range.end, range.timezone ?? "UTC"),
|
|
892
903
|
/*
|
|
893
904
|
* The documented maximum, and `total_items` so truncation can be SEEN.
|
|
894
905
|
*
|
|
@@ -1198,14 +1209,14 @@ function fromPageviews(ga4Pageviews, vercelPageviews) {
|
|
|
1198
1209
|
note: "GA4 pageviews against Vercel\u2019s. Read it as a ceiling: Vercel\u2019s own counter is a script too \u2014 blocked by fewer lists than GA4, but blocked \u2014 so real traffic is higher than both and GA4\u2019s true share is this or less."
|
|
1199
1210
|
};
|
|
1200
1211
|
}
|
|
1201
|
-
function fromEmail(
|
|
1212
|
+
function fromEmail(ga4Users, mailchimpClicks) {
|
|
1202
1213
|
if (mailchimpClicks < MIN_DENOMINATOR) return null;
|
|
1203
1214
|
return {
|
|
1204
1215
|
basis: "email",
|
|
1205
|
-
rate:
|
|
1206
|
-
observed:
|
|
1216
|
+
rate: ga4Users / mailchimpClicks,
|
|
1217
|
+
observed: ga4Users,
|
|
1207
1218
|
actual: mailchimpClicks,
|
|
1208
|
-
note: "GA4
|
|
1219
|
+
note: "Distinct people GA4 saw arriving from email, against the distinct subscribers Mailchimp says clicked. Both sides count people, so the comparison holds \u2014 but it measures tagging as much as capture."
|
|
1209
1220
|
};
|
|
1210
1221
|
}
|
|
1211
1222
|
function samplingInterval(estimate) {
|
|
@@ -1235,13 +1246,16 @@ function describeDisagreement(estimates) {
|
|
|
1235
1246
|
if (orders && pageviews && Math.abs(orders.rate - pageviews.rate) > DISAGREEMENT_THRESHOLD) {
|
|
1236
1247
|
return orders.rate > pageviews.rate ? "GA4 is capturing purchases far better than pageviews. That points at the pageview comparison rather than at a measurement failure \u2014 Vercel counts crawlers and prefetches GA4 never sees. Conversion figures here are more trustworthy than traffic figures." : "GA4 is capturing pageviews but missing purchases. That is not consent or ad-blocking, which would cost both equally \u2014 look at the purchase event itself: whether it fires on a page some buyers never reach, or is blocked at checkout.";
|
|
1237
1248
|
}
|
|
1249
|
+
if (email && pageviews && Math.abs(email.rate - pageviews.rate) > DISAGREEMENT_THRESHOLD) {
|
|
1250
|
+
return email.rate < pageviews.rate ? "Far fewer people are arriving from your mailing list than GA4\u2019s general capture would predict. That points at the campaign links rather than at measurement \u2014 check they carry UTM tags, and that no redirect is stripping them." : "Email traffic is over-represented against GA4\u2019s general capture rate, which usually means campaign links are tagged while ordinary traffic is not \u2014 so email looks larger than it is.";
|
|
1251
|
+
}
|
|
1238
1252
|
if (email && orders && Math.abs(email.rate - orders.rate) > DISAGREEMENT_THRESHOLD) {
|
|
1239
1253
|
return email.rate < orders.rate ? "Traffic from email is arriving less often than GA4\u2019s general capture rate would predict. That is an attribution problem rather than a measurement one \u2014 check the campaign links for missing UTMs, or a redirect stripping them." : "Email traffic is over-represented against GA4\u2019s general capture rate, which usually means campaign links are tagged while ordinary traffic is not, so email looks larger than it is.";
|
|
1240
1254
|
}
|
|
1241
1255
|
return null;
|
|
1242
1256
|
}
|
|
1243
1257
|
function grossUp(observed, model, admissible = ["orders", "email", "pageviews"]) {
|
|
1244
|
-
const usable = model.estimates.filter((e) => admissible.includes(e.basis));
|
|
1258
|
+
const usable = model.estimates.filter((e) => admissible.includes(e.basis) && e.rate > 0 && e.rate < 1 && Number.isFinite(e.rate));
|
|
1245
1259
|
if (usable.length === 0) return null;
|
|
1246
1260
|
const rate = usable[0].rate;
|
|
1247
1261
|
const rates = usable.map((e) => e.rate);
|
|
@@ -1367,7 +1381,11 @@ async function measurementHealth(input) {
|
|
|
1367
1381
|
* what GA4 does not do.
|
|
1368
1382
|
*/
|
|
1369
1383
|
keepEmptyRows: true,
|
|
1370
|
-
|
|
1384
|
+
// A custom range may be 730 days, and this asked for 400 — so the tail simply did not
|
|
1385
|
+
// come back, drawing as absent on the chart whose job is dating when a gap opened.
|
|
1386
|
+
// Vercel has a whole mechanism for exactly this; GA4's own rowCount was parsed and
|
|
1387
|
+
// never checked here.
|
|
1388
|
+
limit: 800
|
|
1371
1389
|
},
|
|
1372
1390
|
{
|
|
1373
1391
|
// The same-event numerator for the capture model: GA4's purchase count against
|
|
@@ -1381,13 +1399,22 @@ async function measurementHealth(input) {
|
|
|
1381
1399
|
// Sessions GA4 attributes to email. The denominator side of this comes from
|
|
1382
1400
|
// Mailchimp's unique subscriber clicks, so the two must describe the same
|
|
1383
1401
|
// people — which they only do where campaign links carry a UTM.
|
|
1384
|
-
|
|
1402
|
+
// USERS, not sessions. Mailchimp counts distinct subscribers who clicked; one of them
|
|
1403
|
+
// arriving on two days is one click and two sessions, so dividing sessions by clicks
|
|
1404
|
+
// was not a capture rate at all — it was not even bounded by 1, on an estimate whose
|
|
1405
|
+
// own note calls it "a cohort whose true size is known exactly".
|
|
1406
|
+
metrics: [{ name: "totalUsers" }],
|
|
1385
1407
|
dateRanges: [{ startDate: range.start, endDate: range.end }],
|
|
1386
1408
|
dimensionFilter: {
|
|
1387
1409
|
filter: { fieldName: "sessionMedium", stringFilter: { matchType: "EXACT", value: "email" } }
|
|
1388
1410
|
}
|
|
1389
1411
|
}
|
|
1390
1412
|
]);
|
|
1413
|
+
if (daily2 && daily2.rowCount > daily2.rows.length) {
|
|
1414
|
+
input.notices?.push(
|
|
1415
|
+
`Google Analytics returned ${daily2.rows.length} of ${daily2.rowCount} days for this range, so the day-by-day comparison is incomplete. Choose a shorter range.`
|
|
1416
|
+
);
|
|
1417
|
+
}
|
|
1391
1418
|
if (daily2) {
|
|
1392
1419
|
for (const row of daily2.rows) {
|
|
1393
1420
|
const raw = row.dimensions[0];
|
|
@@ -1468,7 +1495,11 @@ async function measurementHealth(input) {
|
|
|
1468
1495
|
ordersByDate = counts.byDate;
|
|
1469
1496
|
ordersMeasured = true;
|
|
1470
1497
|
revenueByDate = counts.revenueByDate;
|
|
1471
|
-
revenue = counts.revenue === null ? unavailable("not_applicable", "No order total field is configured for this site") :
|
|
1498
|
+
revenue = counts.revenue === null ? unavailable("not_applicable", "No order total field is configured for this site") : counts.ordersMissingTotal > 0 ? partial(
|
|
1499
|
+
counts.revenue,
|
|
1500
|
+
range.start,
|
|
1501
|
+
`Covers ${counts.total - counts.ordersMissingTotal} of ${counts.total} counted orders \u2014 the rest carry no amount, so real revenue is higher.`
|
|
1502
|
+
) : ok(counts.revenue);
|
|
1472
1503
|
currency = config.orders.currency ?? null;
|
|
1473
1504
|
orderStatuses = counts.byStatus;
|
|
1474
1505
|
if (counts.ordersMissingTotal > 0) {
|
|
@@ -1522,7 +1553,7 @@ async function measurementHealth(input) {
|
|
|
1522
1553
|
try {
|
|
1523
1554
|
const [list, sent] = await Promise.all([
|
|
1524
1555
|
mailchimp.audience({ start: range.start, end: range.end }),
|
|
1525
|
-
mailchimp.campaigns({ start: range.start, end: range.end })
|
|
1556
|
+
mailchimp.campaigns({ start: range.start, end: range.end, timezone: range.timezone })
|
|
1526
1557
|
]);
|
|
1527
1558
|
audience = ok(list.members);
|
|
1528
1559
|
audienceGrowth = list.membersAtStart === null ? unavailable("not_applicable", "Mailchimp reports list growth by calendar month, so this range has no start figure") : ok(list.members - list.membersAtStart);
|
|
@@ -1584,7 +1615,10 @@ async function measurementHealth(input) {
|
|
|
1584
1615
|
revenue: revenueByDate ? revenueByDate[date] ?? 0 : null
|
|
1585
1616
|
}));
|
|
1586
1617
|
const timelineEvents = campaigns.filter((campaign) => campaign.sentAt).map((campaign) => ({
|
|
1587
|
-
|
|
1618
|
+
// The day the send happened where the reader lives, matching the order stems it is meant
|
|
1619
|
+
// to be compared against. The UTC day put an evening send on the following day — the exact
|
|
1620
|
+
// misalignment orders.ts was zoned to prevent.
|
|
1621
|
+
date: zonedDay(campaign.sentAt, range.timezone) ?? campaign.sentAt.slice(0, 10),
|
|
1588
1622
|
label: campaign.title,
|
|
1589
1623
|
detail: `${formatInt(campaign.sent)} sent, ${formatInt(campaign.clicks)} clicked`
|
|
1590
1624
|
}));
|
|
@@ -1696,6 +1730,7 @@ async function acquisition(input) {
|
|
|
1696
1730
|
const purchasesBySource = /* @__PURE__ */ new Map();
|
|
1697
1731
|
const revenueBySource = /* @__PURE__ */ new Map();
|
|
1698
1732
|
let purchasesAvailable = false;
|
|
1733
|
+
let purchasesTruncated = false;
|
|
1699
1734
|
try {
|
|
1700
1735
|
const purchaseReport = await ga4.runReport({
|
|
1701
1736
|
dimensions: [
|
|
@@ -1731,7 +1766,8 @@ async function acquisition(input) {
|
|
|
1731
1766
|
notices?.push("GA4 answered the purchase-attribution query from a sample, so the revenue split is approximate even before its own coverage is considered.");
|
|
1732
1767
|
}
|
|
1733
1768
|
if (purchaseReport.thresholded || purchaseReport.rowCount > purchaseReport.rows.length) {
|
|
1734
|
-
|
|
1769
|
+
purchasesTruncated = true;
|
|
1770
|
+
notices?.push("GA4 withheld or truncated some attributed purchases, so revenue cannot be split by channel for this range. Choose a shorter range or a narrower window.");
|
|
1735
1771
|
}
|
|
1736
1772
|
for (const row of purchaseReport.rows) {
|
|
1737
1773
|
const count2 = row.metrics[0];
|
|
@@ -1759,7 +1795,7 @@ async function acquisition(input) {
|
|
|
1759
1795
|
const MIN_COVERAGE = 0.25;
|
|
1760
1796
|
const MAX_COVERAGE = 1.2;
|
|
1761
1797
|
const coverage = actuals?.orders != null && actuals.orders > 0 ? trackedPurchases / actuals.orders : null;
|
|
1762
|
-
const splitIsSound = trackedPurchases >= MIN_TRACKED_PURCHASES && trackedRevenue > 0 && coverage !== null && coverage >= MIN_COVERAGE && coverage <= MAX_COVERAGE;
|
|
1798
|
+
const splitIsSound = !purchasesTruncated && trackedPurchases >= MIN_TRACKED_PURCHASES && trackedRevenue > 0 && coverage !== null && coverage >= MIN_COVERAGE && coverage <= MAX_COVERAGE;
|
|
1763
1799
|
if (splitIsSound) {
|
|
1764
1800
|
for (const row of rows) {
|
|
1765
1801
|
const money2 = row.trackedRevenue;
|
|
@@ -2397,8 +2433,11 @@ async function runDiagnostics(input) {
|
|
|
2397
2433
|
checks.push({
|
|
2398
2434
|
id: "vercel",
|
|
2399
2435
|
label: "Vercel Web Analytics reachable",
|
|
2400
|
-
|
|
2401
|
-
|
|
2436
|
+
// A response carrying no total is not a healthy source. This reported `pass` and
|
|
2437
|
+
// rendered "null pageviews in the last 7 days" — a check that says nothing is wrong
|
|
2438
|
+
// while printing the word null is worse than no check.
|
|
2439
|
+
status: result.total === null ? "warn" : "pass",
|
|
2440
|
+
detail: result.total === null ? "Vercel answered but returned no pageview total, so its figures cannot be trusted for this range." : `${result.total} pageviews in the last 7 days.`
|
|
2402
2441
|
});
|
|
2403
2442
|
} catch (e) {
|
|
2404
2443
|
checks.push({
|