@liiift-studio/sanity-visitor-insights 0.2.2 → 0.3.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.
@@ -11,6 +11,8 @@ type UnavailableReason =
11
11
  | 'before_cutover'
12
12
  /** GA4 suppressed the row for privacy thresholding. A real number exists; we may not see it. */
13
13
  | 'suppressed'
14
+ /** The event exists in the code but stopped reaching GA4 for this period. Not a real zero. */
15
+ | 'outage'
14
16
  /** The upstream source errored or timed out. Retryable, unlike the others. */
15
17
  | 'source_error'
16
18
  /** The metric is not defined for this site at all (e.g. a flow that does not exist there). */
@@ -220,39 +222,71 @@ interface TypefaceInterestData {
220
222
  }
221
223
 
222
224
  /**
223
- * Event instrumentation cutovers.
225
+ * Event instrumentation history — when an event started, and when it stopped.
224
226
  *
225
227
  * GA4 cannot backfill events: data from before an event was instrumented does not exist and never
226
228
  * will. A range spanning a cutover therefore mixes two measurement regimes, and a chart drawn
227
229
  * across it reads as a change in visitor behaviour when it is really a change in what was counted.
228
230
  *
231
+ * Outages are the same problem arriving from the other direction, and are easy to miss because the
232
+ * event still exists in the code. Darden is the worked example: its ecommerce events fired normally
233
+ * until a script-loading change on 2025-11-20 stopped them reaching GA4 for nine months. Every one
234
+ * of those days returns a real, honest zero from the API — a zero that means "not recorded", not
235
+ * "no sales". Without an outage recorded here, a yearly funnel would render that as a collapse in
236
+ * trade rather than as a gap in measurement.
237
+ *
229
238
  * This lives in the package rather than in each site repo so all three foundries share one model
230
239
  * and one set of semantics, and so the Studio panel and the server handler agree by construction.
231
240
  */
232
241
 
233
- /** An event that predates this work — long-running, with no discontinuity to mark. */
242
+ /** An event that predates this work — long-running, with no start discontinuity to mark. */
234
243
  declare const PREEXISTING: "preexisting";
235
244
  /**
236
- * When an event began firing on a site.
245
+ * A period during which an event stopped reaching GA4 despite still existing in the code.
246
+ * `until: null` means it is still broken as of now.
247
+ */
248
+ interface EventOutage {
249
+ /** First ISO date with no data. */
250
+ start: string;
251
+ /** ISO date it resumed, or null if unresolved. */
252
+ until: string | null;
253
+ /** Short human-readable cause, shown in the UI next to the affected figure. */
254
+ reason?: string;
255
+ }
256
+ /** An event with a start date and, optionally, periods where it stopped firing. */
257
+ interface EventHistory {
258
+ /** When it began firing: `PREEXISTING`, or an ISO `YYYY-MM-DD` deploy date. */
259
+ from: typeof PREEXISTING | string;
260
+ /** Periods where it stopped. Order does not matter. */
261
+ outages?: EventOutage[];
262
+ }
263
+ /**
264
+ * When an event fired on a site.
237
265
  * `PREEXISTING` = live before this work began. `null` = planned, not yet deployed.
238
- * An ISO `YYYY-MM-DD` string = the deploy date, set in the commit that ships the event.
266
+ * An ISO `YYYY-MM-DD` string = the deploy date. An `EventHistory` adds outages.
239
267
  */
240
- type EventCutover = typeof PREEXISTING | string | null;
268
+ type EventCutover = typeof PREEXISTING | string | null | EventHistory;
241
269
  /** How completely an event covers a requested range. */
242
270
  type Coverage = {
243
271
  status: 'full';
244
272
  } | {
245
273
  status: 'partial';
246
- cutover: string;
274
+ reason: 'spans_cutover' | 'spans_outage';
275
+ /** Present when the range starts before the event was instrumented. */
276
+ cutover?: string;
277
+ /** Outages overlapping the range. Empty for a pure cutover overlap. */
278
+ outages: EventOutage[];
247
279
  } | {
248
280
  status: 'none';
249
- reason: 'not_instrumented' | 'before_cutover' | 'unknown_event';
281
+ reason: 'not_instrumented' | 'before_cutover' | 'unknown_event' | 'outage';
250
282
  cutover?: string;
283
+ /** Present when the whole range fell inside an outage. */
284
+ outages?: EventOutage[];
251
285
  };
252
286
  /**
253
287
  * Determine how well an event covers a date range.
254
288
  *
255
- * @param cutovers - the site's event cutover map
289
+ * @param cutovers - the site's event history map
256
290
  * @param eventName - GA4 event name to look up
257
291
  * @param range - the requested range
258
292
  */
@@ -267,6 +301,8 @@ declare function coverageForRange(cutovers: Readonly<Record<string, EventCutover
267
301
  * @param coverage - result of coverageForRange for the same event and range
268
302
  */
269
303
  declare function applyCoverage(count: number | null, coverage: Coverage): MetricValue;
304
+ /** One-line description of an outage, for display beside an affected figure. */
305
+ declare function describeOutage(outage: EventOutage | undefined): string;
270
306
  /**
271
307
  * Human-readable notices for every event in a range that is not fully covered.
272
308
  * Surfaced in the report envelope so the panel can show caveats without recomputing them.
@@ -393,4 +429,4 @@ declare function provisionalDates(range: DateRange, now?: Date): string[];
393
429
  */
394
430
  declare function provisionalNotice(range: DateRange, now?: Date): string | null;
395
431
 
396
- export { type AcquisitionData as A, assertValidSiteConfig as B, type Coverage as C, type DiagnosticReport as D, type EventCutover as E, coverageNotices as F, GA4_PROCESSING_LAG_DAYS as G, daysBetween as H, formatInTimeZone as I, type JourneyData as J, isValidTimeZone as K, provisionalDates as L, type MetricValue as M, provisionalNotice as N, type OrdersConfig as O, PREEXISTING as P, shiftDays as Q, type ReportEnvelope as R, type SiteAnalyticsConfig as S, type TypefaceInterestData as T, type UnavailableReason as U, type VercelConfig as V, type ReportName as a, type RangeKey as b, type MeasurementHealthData as c, type DateRange as d, REPORT_NAMES as e, type ReportError as f, type SourceName as g, type SourceStatus as h, coverageForRange as i, isReportName as j, previousRange as k, valueOrNull as l, type CheckStatus as m, type ConfigProblem as n, ok as o, partial as p, type DiagnosticCheck as q, resolveRange as r, type ExitPage as s, type Ga4Config as t, unavailable as u, validateSiteConfig as v, type JourneyStep as w, type SourceRow as x, type TypefaceInterestRow as y, applyCoverage as z };
432
+ export { type AcquisitionData as A, type TypefaceInterestRow as B, type Coverage as C, type DiagnosticReport as D, type EventCutover as E, applyCoverage as F, GA4_PROCESSING_LAG_DAYS as G, assertValidSiteConfig as H, coverageNotices as I, type JourneyData as J, daysBetween as K, describeOutage as L, type MetricValue as M, formatInTimeZone as N, type OrdersConfig as O, PREEXISTING as P, isValidTimeZone as Q, type ReportEnvelope as R, type SiteAnalyticsConfig as S, type TypefaceInterestData as T, type UnavailableReason as U, type VercelConfig as V, provisionalDates as W, provisionalNotice as X, shiftDays as Y, type ReportName as a, type RangeKey as b, type MeasurementHealthData as c, type DateRange as d, REPORT_NAMES as e, type ReportError as f, type SourceName as g, type SourceStatus as h, coverageForRange as i, isReportName as j, previousRange as k, valueOrNull as l, type CheckStatus as m, type ConfigProblem as n, ok as o, partial as p, type DiagnosticCheck as q, resolveRange as r, type EventHistory as s, type EventOutage as t, unavailable as u, validateSiteConfig as v, type ExitPage as w, type Ga4Config as x, type JourneyStep as y, type SourceRow as z };
package/dist/server.d.mts CHANGED
@@ -1,7 +1,7 @@
1
- import { S as SiteAnalyticsConfig, D as DiagnosticReport } from './ranges-DSFapooC.mjs';
2
- export { A as AcquisitionData, m as CheckStatus, n as ConfigProblem, C as Coverage, d as DateRange, q as DiagnosticCheck, E as EventCutover, s as ExitPage, G as GA4_PROCESSING_LAG_DAYS, t as Ga4Config, J as JourneyData, w as JourneyStep, c as MeasurementHealthData, M as MetricValue, O as OrdersConfig, P as PREEXISTING, e as REPORT_NAMES, b as RangeKey, R as ReportEnvelope, f as ReportError, a as ReportName, g as SourceName, x as SourceRow, h as SourceStatus, T as TypefaceInterestData, y as TypefaceInterestRow, U as UnavailableReason, V as VercelConfig, z as applyCoverage, B as assertValidSiteConfig, i as coverageForRange, F as coverageNotices, H as daysBetween, I as formatInTimeZone, j as isReportName, K as isValidTimeZone, o as ok, p as partial, k as previousRange, L as provisionalDates, N as provisionalNotice, r as resolveRange, Q as shiftDays, u as unavailable, v as validateSiteConfig, l as valueOrNull } from './ranges-DSFapooC.mjs';
3
- import { S as SanityQueryClient, G as Ga4Client, V as VercelClient } from './vercel-BMEO-Jcw.mjs';
4
- export { a as Ga4Report, b as Ga4ReportRequest, c as ServiceAccountKey, d as VercelPageviews, e as createGa4Client, f as createVercelClient, p as parseServiceAccountKey } from './vercel-BMEO-Jcw.mjs';
1
+ import { S as SiteAnalyticsConfig, D as DiagnosticReport } from './ranges-Bh9WCyYL.mjs';
2
+ export { A as AcquisitionData, m as CheckStatus, n as ConfigProblem, C as Coverage, d as DateRange, q as DiagnosticCheck, E as EventCutover, s as EventHistory, t as EventOutage, w as ExitPage, G as GA4_PROCESSING_LAG_DAYS, x as Ga4Config, J as JourneyData, y as JourneyStep, c as MeasurementHealthData, M as MetricValue, O as OrdersConfig, P as PREEXISTING, e as REPORT_NAMES, b as RangeKey, R as ReportEnvelope, f as ReportError, a as ReportName, g as SourceName, z as SourceRow, h as SourceStatus, T as TypefaceInterestData, B as TypefaceInterestRow, U as UnavailableReason, V as VercelConfig, F as applyCoverage, H as assertValidSiteConfig, i as coverageForRange, I as coverageNotices, K as daysBetween, L as describeOutage, N as formatInTimeZone, j as isReportName, Q as isValidTimeZone, o as ok, p as partial, k as previousRange, W as provisionalDates, X as provisionalNotice, r as resolveRange, Y as shiftDays, u as unavailable, v as validateSiteConfig, l as valueOrNull } from './ranges-Bh9WCyYL.mjs';
3
+ import { S as SanityQueryClient, G as Ga4Client, V as VercelClient } from './vercel-B6d1Ng48.mjs';
4
+ export { a as Ga4Report, b as Ga4ReportRequest, c as ServiceAccountKey, d as VercelPageviews, e as createGa4Client, f as createVercelClient, g as eventNameFilter, p as parseServiceAccountKey, s as sumFirstMetric } from './vercel-B6d1Ng48.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-DSFapooC.js';
2
- export { A as AcquisitionData, m as CheckStatus, n as ConfigProblem, C as Coverage, d as DateRange, q as DiagnosticCheck, E as EventCutover, s as ExitPage, G as GA4_PROCESSING_LAG_DAYS, t as Ga4Config, J as JourneyData, w as JourneyStep, c as MeasurementHealthData, M as MetricValue, O as OrdersConfig, P as PREEXISTING, e as REPORT_NAMES, b as RangeKey, R as ReportEnvelope, f as ReportError, a as ReportName, g as SourceName, x as SourceRow, h as SourceStatus, T as TypefaceInterestData, y as TypefaceInterestRow, U as UnavailableReason, V as VercelConfig, z as applyCoverage, B as assertValidSiteConfig, i as coverageForRange, F as coverageNotices, H as daysBetween, I as formatInTimeZone, j as isReportName, K as isValidTimeZone, o as ok, p as partial, k as previousRange, L as provisionalDates, N as provisionalNotice, r as resolveRange, Q as shiftDays, u as unavailable, v as validateSiteConfig, l as valueOrNull } from './ranges-DSFapooC.js';
3
- import { S as SanityQueryClient, G as Ga4Client, V as VercelClient } from './vercel-BMEO-Jcw.js';
4
- export { a as Ga4Report, b as Ga4ReportRequest, c as ServiceAccountKey, d as VercelPageviews, e as createGa4Client, f as createVercelClient, p as parseServiceAccountKey } from './vercel-BMEO-Jcw.js';
1
+ import { S as SiteAnalyticsConfig, D as DiagnosticReport } from './ranges-Bh9WCyYL.js';
2
+ export { A as AcquisitionData, m as CheckStatus, n as ConfigProblem, C as Coverage, d as DateRange, q as DiagnosticCheck, E as EventCutover, s as EventHistory, t as EventOutage, w as ExitPage, G as GA4_PROCESSING_LAG_DAYS, x as Ga4Config, J as JourneyData, y as JourneyStep, c as MeasurementHealthData, M as MetricValue, O as OrdersConfig, P as PREEXISTING, e as REPORT_NAMES, b as RangeKey, R as ReportEnvelope, f as ReportError, a as ReportName, g as SourceName, z as SourceRow, h as SourceStatus, T as TypefaceInterestData, B as TypefaceInterestRow, U as UnavailableReason, V as VercelConfig, F as applyCoverage, H as assertValidSiteConfig, i as coverageForRange, I as coverageNotices, K as daysBetween, L as describeOutage, N as formatInTimeZone, j as isReportName, Q as isValidTimeZone, o as ok, p as partial, k as previousRange, W as provisionalDates, X as provisionalNotice, r as resolveRange, Y as shiftDays, u as unavailable, v as validateSiteConfig, l as valueOrNull } from './ranges-Bh9WCyYL.js';
3
+ import { S as SanityQueryClient, G as Ga4Client, V as VercelClient } from './vercel-B6d1Ng48.js';
4
+ export { a as Ga4Report, b as Ga4ReportRequest, c as ServiceAccountKey, d as VercelPageviews, e as createGa4Client, f as createVercelClient, g as eventNameFilter, p as parseServiceAccountKey, s as sumFirstMetric } from './vercel-B6d1Ng48.js';
5
5
 
6
6
  /**
7
7
  * Request authentication and CORS for the report handler.
package/dist/server.js CHANGED
@@ -36,6 +36,8 @@ __export(server_exports, {
36
36
  createVercelClient: () => createVercelClient,
37
37
  createVisitorInsightsHandler: () => createVisitorInsightsHandler,
38
38
  daysBetween: () => daysBetween,
39
+ describeOutage: () => describeOutage,
40
+ eventNameFilter: () => eventNameFilter,
39
41
  formatInTimeZone: () => formatInTimeZone,
40
42
  isReportName: () => isReportName,
41
43
  isValidTimeZone: () => isValidTimeZone,
@@ -49,6 +51,7 @@ __export(server_exports, {
49
51
  resolveRange: () => resolveRange,
50
52
  runDiagnostics: () => runDiagnostics,
51
53
  shiftDays: () => shiftDays,
54
+ sumFirstMetric: () => sumFirstMetric,
52
55
  unavailable: () => unavailable,
53
56
  validateSiteConfig: () => validateSiteConfig,
54
57
  valueOrNull: () => valueOrNull,
@@ -144,19 +147,41 @@ ${detail}`);
144
147
 
145
148
  // src/core/cutover.ts
146
149
  var PREEXISTING = "preexisting";
150
+ function toHistory(cutover) {
151
+ if (cutover === null || cutover === void 0) return null;
152
+ if (typeof cutover === "string") return { from: cutover };
153
+ if (!cutover.from) return null;
154
+ return cutover;
155
+ }
156
+ function overlaps(aStart, aEnd, bStart, bEnd) {
157
+ return aStart <= bEnd && (aEnd === null || aEnd >= bStart);
158
+ }
147
159
  function coverageForRange(cutovers, eventName, range) {
148
160
  if (!(eventName in cutovers)) return { status: "none", reason: "unknown_event" };
149
- const cutover = cutovers[eventName];
150
- if (cutover === null || cutover === void 0) return { status: "none", reason: "not_instrumented" };
151
- if (cutover === PREEXISTING) return { status: "full" };
152
- const cutoverTime = Date.parse(cutover);
153
- if (Number.isNaN(cutoverTime)) return { status: "none", reason: "unknown_event" };
154
- if (Date.parse(range.end) < cutoverTime) return { status: "none", reason: "before_cutover", cutover };
155
- if (Date.parse(range.start) < cutoverTime) return { status: "partial", cutover };
161
+ const history = toHistory(cutovers[eventName]);
162
+ if (!history) return { status: "none", reason: "not_instrumented" };
163
+ let cutover;
164
+ let startsBeforeCutover = false;
165
+ if (history.from !== PREEXISTING) {
166
+ const cutoverTime = Date.parse(history.from);
167
+ if (Number.isNaN(cutoverTime)) return { status: "none", reason: "unknown_event" };
168
+ cutover = history.from;
169
+ if (Date.parse(range.end) < cutoverTime) return { status: "none", reason: "before_cutover", cutover };
170
+ startsBeforeCutover = Date.parse(range.start) < cutoverTime;
171
+ }
172
+ const hit = (history.outages ?? []).filter((o) => overlaps(o.start, o.until, range.start, range.end));
173
+ const swallowed = hit.find((o) => o.start <= range.start && (o.until === null || o.until > range.end));
174
+ if (swallowed) return { status: "none", reason: "outage", outages: [swallowed], cutover };
175
+ if (hit.length > 0) return { status: "partial", reason: "spans_outage", outages: hit, cutover };
176
+ if (startsBeforeCutover) return { status: "partial", reason: "spans_cutover", outages: [], cutover };
156
177
  return { status: "full" };
157
178
  }
158
179
  function applyCoverage(count, coverage) {
159
180
  if (coverage.status === "none") {
181
+ if (coverage.reason === "outage") {
182
+ const outage = coverage.outages?.[0];
183
+ return unavailable("outage", outage ? describeOutage(outage) : void 0);
184
+ }
160
185
  return unavailable(
161
186
  coverage.reason === "before_cutover" ? "before_cutover" : "not_instrumented",
162
187
  coverage.cutover ? `Instrumented from ${coverage.cutover}` : void 0
@@ -164,16 +189,31 @@ function applyCoverage(count, coverage) {
164
189
  }
165
190
  if (count === null) return unavailable("source_error");
166
191
  if (coverage.status === "partial") {
167
- return partial(count, coverage.cutover, `Only counted from ${coverage.cutover}, when this event was added`);
192
+ if (coverage.reason === "spans_outage") {
193
+ const outage = coverage.outages[0];
194
+ const from2 = outage?.until ?? outage?.start ?? coverage.cutover ?? "";
195
+ return partial(count, from2, `Undercounted: ${describeOutage(outage)}`);
196
+ }
197
+ const from = coverage.cutover ?? "";
198
+ return partial(count, from, `Only counted from ${from}, when this event was added`);
168
199
  }
169
200
  return { status: "ok", value: count };
170
201
  }
202
+ function describeOutage(outage) {
203
+ if (!outage) return "an outage affected part of this range";
204
+ const period = outage.until ? `${outage.start} to ${outage.until}` : `${outage.start} onwards`;
205
+ return outage.reason ? `not recorded ${period} (${outage.reason})` : `not recorded ${period}`;
206
+ }
171
207
  function coverageNotices(cutovers, eventNames, range) {
172
208
  const notices = [];
173
209
  for (const name of eventNames) {
174
210
  const coverage = coverageForRange(cutovers, name, range);
175
- if (coverage.status === "partial") {
211
+ if (coverage.status === "partial" && coverage.reason === "spans_outage") {
212
+ notices.push(`${name} was ${describeOutage(coverage.outages[0])}, so figures for this range are too low \u2014 not a real decline.`);
213
+ } else if (coverage.status === "partial") {
176
214
  notices.push(`${name} was instrumented on ${coverage.cutover}; figures before that date are missing, not zero.`);
215
+ } else if (coverage.status === "none" && coverage.reason === "outage") {
216
+ notices.push(`${name} was ${describeOutage(coverage.outages?.[0])}, covering this whole range. The figure is unavailable, not zero.`);
177
217
  } else if (coverage.status === "none" && coverage.reason === "not_instrumented") {
178
218
  notices.push(`${name} is not instrumented on this site, so it cannot be reported for any range.`);
179
219
  } else if (coverage.status === "none" && coverage.reason === "before_cutover") {
@@ -1210,6 +1250,8 @@ async function runReport(report, ctx) {
1210
1250
  createVercelClient,
1211
1251
  createVisitorInsightsHandler,
1212
1252
  daysBetween,
1253
+ describeOutage,
1254
+ eventNameFilter,
1213
1255
  formatInTimeZone,
1214
1256
  isReportName,
1215
1257
  isValidTimeZone,
@@ -1223,6 +1265,7 @@ async function runReport(report, ctx) {
1223
1265
  resolveRange,
1224
1266
  runDiagnostics,
1225
1267
  shiftDays,
1268
+ sumFirstMetric,
1226
1269
  unavailable,
1227
1270
  validateSiteConfig,
1228
1271
  valueOrNull,