@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.
- package/README.md +7 -0
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +26 -7
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +26 -7
- package/dist/index.mjs.map +1 -1
- package/dist/{ranges-DSFapooC.d.mts → ranges-Bh9WCyYL.d.mts} +45 -9
- package/dist/{ranges-DSFapooC.d.ts → ranges-Bh9WCyYL.d.ts} +45 -9
- package/dist/server.d.mts +4 -4
- package/dist/server.d.ts +4 -4
- package/dist/server.js +52 -9
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +49 -9
- package/dist/server.mjs.map +1 -1
- package/dist/testing.d.mts +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/{vercel-BMEO-Jcw.d.mts → vercel-B6d1Ng48.d.mts} +5 -1
- package/dist/{vercel-BMEO-Jcw.d.ts → vercel-B6d1Ng48.d.ts} +5 -1
- package/package.json +2 -2
- package/src/core/core.test.ts +65 -1
- package/src/core/cutover.ts +113 -19
- package/src/server.ts +1 -1
- package/src/studio/Figure.tsx +1 -0
- package/src/types.ts +2 -0
package/dist/testing.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { G as Ga4Client, b as Ga4ReportRequest, S as SanityQueryClient, a as Ga4Report, d as VercelPageviews, V as VercelClient } from './vercel-
|
|
1
|
+
import { G as Ga4Client, b as Ga4ReportRequest, S as SanityQueryClient, a as Ga4Report, d as VercelPageviews, V as VercelClient } from './vercel-B6d1Ng48.mjs';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Test doubles and fixture builders for the report layer.
|
package/dist/testing.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { G as Ga4Client, b as Ga4ReportRequest, S as SanityQueryClient, a as Ga4Report, d as VercelPageviews, V as VercelClient } from './vercel-
|
|
1
|
+
import { G as Ga4Client, b as Ga4ReportRequest, S as SanityQueryClient, a as Ga4Report, d as VercelPageviews, V as VercelClient } from './vercel-B6d1Ng48.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Test doubles and fixture builders for the report layer.
|
|
@@ -85,6 +85,10 @@ interface Ga4Client {
|
|
|
85
85
|
* @param key - service-account key with Viewer on that property
|
|
86
86
|
*/
|
|
87
87
|
declare function createGa4Client(propertyId: string, key: ServiceAccountKey): Ga4Client;
|
|
88
|
+
/** Build a dimension filter matching a single event name. */
|
|
89
|
+
declare function eventNameFilter(eventName: string): unknown;
|
|
90
|
+
/** Sum a report's first metric across all rows, ignoring suppressed cells. */
|
|
91
|
+
declare function sumFirstMetric(report: Ga4Report): number;
|
|
88
92
|
|
|
89
93
|
/**
|
|
90
94
|
* Vercel Web Analytics client.
|
|
@@ -120,4 +124,4 @@ interface VercelClient {
|
|
|
120
124
|
*/
|
|
121
125
|
declare function createVercelClient(projectId: string, token: string, teamId?: string): VercelClient;
|
|
122
126
|
|
|
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 };
|
|
127
|
+
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, eventNameFilter as g, parseServiceAccountKey as p, sumFirstMetric as s };
|
|
@@ -85,6 +85,10 @@ interface Ga4Client {
|
|
|
85
85
|
* @param key - service-account key with Viewer on that property
|
|
86
86
|
*/
|
|
87
87
|
declare function createGa4Client(propertyId: string, key: ServiceAccountKey): Ga4Client;
|
|
88
|
+
/** Build a dimension filter matching a single event name. */
|
|
89
|
+
declare function eventNameFilter(eventName: string): unknown;
|
|
90
|
+
/** Sum a report's first metric across all rows, ignoring suppressed cells. */
|
|
91
|
+
declare function sumFirstMetric(report: Ga4Report): number;
|
|
88
92
|
|
|
89
93
|
/**
|
|
90
94
|
* Vercel Web Analytics client.
|
|
@@ -120,4 +124,4 @@ interface VercelClient {
|
|
|
120
124
|
*/
|
|
121
125
|
declare function createVercelClient(projectId: string, token: string, teamId?: string): VercelClient;
|
|
122
126
|
|
|
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 };
|
|
127
|
+
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, eventNameFilter as g, parseServiceAccountKey as p, sumFirstMetric as s };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liiift-studio/sanity-visitor-insights",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Visitor-behaviour analytics for type-foundry Sanity Studios
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Visitor-behaviour analytics for type-foundry Sanity Studios \u2014 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/core/core.test.ts
CHANGED
|
@@ -49,7 +49,9 @@ describe('coverageForRange', () => {
|
|
|
49
49
|
it('reports partial coverage when the range straddles the cutover', () => {
|
|
50
50
|
expect(coverageForRange(cutovers, 'begin_checkout', { start: '2026-08-01', end: '2026-10-01' })).toEqual({
|
|
51
51
|
status: 'partial',
|
|
52
|
+
reason: 'spans_cutover',
|
|
52
53
|
cutover: '2026-09-01',
|
|
54
|
+
outages: [],
|
|
53
55
|
})
|
|
54
56
|
})
|
|
55
57
|
|
|
@@ -69,7 +71,7 @@ describe('applyCoverage', () => {
|
|
|
69
71
|
})
|
|
70
72
|
|
|
71
73
|
it('marks a straddling range as partial rather than complete', () => {
|
|
72
|
-
const metric = applyCoverage(120, { status: 'partial', cutover: '2026-09-01' })
|
|
74
|
+
const metric = applyCoverage(120, { status: 'partial', reason: 'spans_cutover', cutover: '2026-09-01', outages: [] })
|
|
73
75
|
expect(metric.status).toBe('partial')
|
|
74
76
|
expect(valueOrNull(metric)).toBe(120)
|
|
75
77
|
})
|
|
@@ -97,6 +99,68 @@ describe('coverageNotices', () => {
|
|
|
97
99
|
})
|
|
98
100
|
})
|
|
99
101
|
|
|
102
|
+
describe('outages', () => {
|
|
103
|
+
// Darden's real case: ecommerce events kept firing in code but stopped reaching GA4 for
|
|
104
|
+
// nine months. Every one of those days returns an honest zero from the API.
|
|
105
|
+
const withOutage: Record<string, EventCutover> = {
|
|
106
|
+
purchase: {
|
|
107
|
+
from: PREEXISTING,
|
|
108
|
+
outages: [{ start: '2025-11-20', until: '2026-08-30', reason: 'gtag loader moved to lazyOnload' }],
|
|
109
|
+
},
|
|
110
|
+
still_broken: {
|
|
111
|
+
from: PREEXISTING,
|
|
112
|
+
outages: [{ start: '2026-01-01', until: null }],
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
it('reports a range sitting entirely inside an outage as unavailable, not zero', () => {
|
|
117
|
+
const coverage = coverageForRange(withOutage, 'purchase', { start: '2026-01-01', end: '2026-03-01' })
|
|
118
|
+
expect(coverage.status).toBe('none')
|
|
119
|
+
expect(coverage).toMatchObject({ reason: 'outage' })
|
|
120
|
+
|
|
121
|
+
// The crucial assertion: a real GA4 zero must not become a charted zero.
|
|
122
|
+
const metric = applyCoverage(0, coverage)
|
|
123
|
+
expect(metric.status).toBe('unavailable')
|
|
124
|
+
expect(valueOrNull(metric)).toBeNull()
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('reports a range straddling the end of an outage as partial and undercounted', () => {
|
|
128
|
+
const coverage = coverageForRange(withOutage, 'purchase', { start: '2026-08-01', end: '2026-09-30' })
|
|
129
|
+
expect(coverage).toMatchObject({ status: 'partial', reason: 'spans_outage' })
|
|
130
|
+
|
|
131
|
+
const metric = applyCoverage(12, coverage)
|
|
132
|
+
expect(metric.status).toBe('partial')
|
|
133
|
+
if (metric.status === 'partial') expect(metric.note).toContain('Undercounted')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('treats a range fully after the outage as complete', () => {
|
|
137
|
+
expect(coverageForRange(withOutage, 'purchase', { start: '2026-09-01', end: '2026-09-30' })).toEqual({ status: 'full' })
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('treats a range fully before the outage as complete', () => {
|
|
141
|
+
expect(coverageForRange(withOutage, 'purchase', { start: '2025-06-01', end: '2025-11-01' })).toEqual({ status: 'full' })
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('treats an unresolved outage as ongoing', () => {
|
|
145
|
+
const coverage = coverageForRange(withOutage, 'still_broken', { start: '2026-06-01', end: '2026-08-30' })
|
|
146
|
+
expect(coverage).toMatchObject({ status: 'none', reason: 'outage' })
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('still accepts the plain shorthand forms', () => {
|
|
150
|
+
expect(coverageForRange({ a: PREEXISTING }, 'a', { start: '2026-01-01', end: '2026-02-01' })).toEqual({ status: 'full' })
|
|
151
|
+
expect(coverageForRange({ a: null }, 'a', { start: '2026-01-01', end: '2026-02-01' })).toEqual({
|
|
152
|
+
status: 'none', reason: 'not_instrumented',
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('explains an outage in the notices rather than leaving a silent dip', () => {
|
|
157
|
+
const notices = coverageNotices(withOutage, ['purchase'], { start: '2026-08-01', end: '2026-09-30' })
|
|
158
|
+
expect(notices).toHaveLength(1)
|
|
159
|
+
expect(notices[0]).toContain('not a real decline')
|
|
160
|
+
expect(notices[0]).toContain('lazyOnload')
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
100
164
|
describe('ranges', () => {
|
|
101
165
|
it('anchors the range end to the property timezone, not the host timezone', () => {
|
|
102
166
|
// 02:00 UTC on the 26th is still the 25th in Los Angeles. A range anchored to UTC would
|
package/src/core/cutover.ts
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Event instrumentation
|
|
2
|
+
* Event instrumentation history — when an event started, and when it stopped.
|
|
3
3
|
*
|
|
4
4
|
* GA4 cannot backfill events: data from before an event was instrumented does not exist and never
|
|
5
5
|
* will. A range spanning a cutover therefore mixes two measurement regimes, and a chart drawn
|
|
6
6
|
* across it reads as a change in visitor behaviour when it is really a change in what was counted.
|
|
7
7
|
*
|
|
8
|
+
* Outages are the same problem arriving from the other direction, and are easy to miss because the
|
|
9
|
+
* event still exists in the code. Darden is the worked example: its ecommerce events fired normally
|
|
10
|
+
* until a script-loading change on 2025-11-20 stopped them reaching GA4 for nine months. Every one
|
|
11
|
+
* of those days returns a real, honest zero from the API — a zero that means "not recorded", not
|
|
12
|
+
* "no sales". Without an outage recorded here, a yearly funnel would render that as a collapse in
|
|
13
|
+
* trade rather than as a gap in measurement.
|
|
14
|
+
*
|
|
8
15
|
* This lives in the package rather than in each site repo so all three foundries share one model
|
|
9
16
|
* and one set of semantics, and so the Studio panel and the server handler agree by construction.
|
|
10
17
|
*/
|
|
@@ -12,26 +19,74 @@
|
|
|
12
19
|
import type { DateRange, MetricValue } from '../types'
|
|
13
20
|
import { unavailable, partial } from '../types'
|
|
14
21
|
|
|
15
|
-
/** An event that predates this work — long-running, with no discontinuity to mark. */
|
|
22
|
+
/** An event that predates this work — long-running, with no start discontinuity to mark. */
|
|
16
23
|
export const PREEXISTING = 'preexisting' as const
|
|
17
24
|
|
|
18
25
|
/**
|
|
19
|
-
*
|
|
26
|
+
* A period during which an event stopped reaching GA4 despite still existing in the code.
|
|
27
|
+
* `until: null` means it is still broken as of now.
|
|
28
|
+
*/
|
|
29
|
+
export interface EventOutage {
|
|
30
|
+
/** First ISO date with no data. */
|
|
31
|
+
start: string
|
|
32
|
+
/** ISO date it resumed, or null if unresolved. */
|
|
33
|
+
until: string | null
|
|
34
|
+
/** Short human-readable cause, shown in the UI next to the affected figure. */
|
|
35
|
+
reason?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** An event with a start date and, optionally, periods where it stopped firing. */
|
|
39
|
+
export interface EventHistory {
|
|
40
|
+
/** When it began firing: `PREEXISTING`, or an ISO `YYYY-MM-DD` deploy date. */
|
|
41
|
+
from: typeof PREEXISTING | string
|
|
42
|
+
/** Periods where it stopped. Order does not matter. */
|
|
43
|
+
outages?: EventOutage[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* When an event fired on a site.
|
|
20
48
|
* `PREEXISTING` = live before this work began. `null` = planned, not yet deployed.
|
|
21
|
-
* An ISO `YYYY-MM-DD` string = the deploy date
|
|
49
|
+
* An ISO `YYYY-MM-DD` string = the deploy date. An `EventHistory` adds outages.
|
|
22
50
|
*/
|
|
23
|
-
export type EventCutover = typeof PREEXISTING | string | null
|
|
51
|
+
export type EventCutover = typeof PREEXISTING | string | null | EventHistory
|
|
24
52
|
|
|
25
53
|
/** How completely an event covers a requested range. */
|
|
26
54
|
export type Coverage =
|
|
27
55
|
| { status: 'full' }
|
|
28
|
-
| {
|
|
29
|
-
|
|
56
|
+
| {
|
|
57
|
+
status: 'partial'
|
|
58
|
+
reason: 'spans_cutover' | 'spans_outage'
|
|
59
|
+
/** Present when the range starts before the event was instrumented. */
|
|
60
|
+
cutover?: string
|
|
61
|
+
/** Outages overlapping the range. Empty for a pure cutover overlap. */
|
|
62
|
+
outages: EventOutage[]
|
|
63
|
+
}
|
|
64
|
+
| {
|
|
65
|
+
status: 'none'
|
|
66
|
+
reason: 'not_instrumented' | 'before_cutover' | 'unknown_event' | 'outage'
|
|
67
|
+
cutover?: string
|
|
68
|
+
/** Present when the whole range fell inside an outage. */
|
|
69
|
+
outages?: EventOutage[]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Normalise the shorthand forms into an EventHistory, or null when not instrumented. */
|
|
73
|
+
function toHistory(cutover: EventCutover | undefined): EventHistory | null {
|
|
74
|
+
if (cutover === null || cutover === undefined) return null
|
|
75
|
+
if (typeof cutover === 'string') return { from: cutover }
|
|
76
|
+
if (!cutover.from) return null
|
|
77
|
+
return cutover
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Whether two inclusive date ranges overlap at all. */
|
|
81
|
+
function overlaps(aStart: string, aEnd: string | null, bStart: string, bEnd: string): boolean {
|
|
82
|
+
// A null end means "ongoing", so it extends past any range end.
|
|
83
|
+
return aStart <= bEnd && (aEnd === null || aEnd >= bStart)
|
|
84
|
+
}
|
|
30
85
|
|
|
31
86
|
/**
|
|
32
87
|
* Determine how well an event covers a date range.
|
|
33
88
|
*
|
|
34
|
-
* @param cutovers - the site's event
|
|
89
|
+
* @param cutovers - the site's event history map
|
|
35
90
|
* @param eventName - GA4 event name to look up
|
|
36
91
|
* @param range - the requested range
|
|
37
92
|
*/
|
|
@@ -42,17 +97,32 @@ export function coverageForRange(
|
|
|
42
97
|
): Coverage {
|
|
43
98
|
if (!(eventName in cutovers)) return { status: 'none', reason: 'unknown_event' }
|
|
44
99
|
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
if (cutover === null || cutover === undefined) return { status: 'none', reason: 'not_instrumented' }
|
|
48
|
-
if (cutover === PREEXISTING) return { status: 'full' }
|
|
100
|
+
const history = toHistory(cutovers[eventName])
|
|
101
|
+
if (!history) return { status: 'none', reason: 'not_instrumented' }
|
|
49
102
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
103
|
+
// --- start date -----------------------------------------------------------
|
|
104
|
+
let cutover: string | undefined
|
|
105
|
+
let startsBeforeCutover = false
|
|
53
106
|
|
|
54
|
-
if (
|
|
55
|
-
|
|
107
|
+
if (history.from !== PREEXISTING) {
|
|
108
|
+
const cutoverTime = Date.parse(history.from)
|
|
109
|
+
// An unparseable date is a config error; treat it as unknown rather than silently trusting it.
|
|
110
|
+
if (Number.isNaN(cutoverTime)) return { status: 'none', reason: 'unknown_event' }
|
|
111
|
+
|
|
112
|
+
cutover = history.from
|
|
113
|
+
if (Date.parse(range.end) < cutoverTime) return { status: 'none', reason: 'before_cutover', cutover }
|
|
114
|
+
startsBeforeCutover = Date.parse(range.start) < cutoverTime
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// --- outages --------------------------------------------------------------
|
|
118
|
+
const hit = (history.outages ?? []).filter((o) => overlaps(o.start, o.until, range.start, range.end))
|
|
119
|
+
|
|
120
|
+
// A range sitting entirely inside one outage has no usable data at all.
|
|
121
|
+
const swallowed = hit.find((o) => o.start <= range.start && (o.until === null || o.until > range.end))
|
|
122
|
+
if (swallowed) return { status: 'none', reason: 'outage', outages: [swallowed], cutover }
|
|
123
|
+
|
|
124
|
+
if (hit.length > 0) return { status: 'partial', reason: 'spans_outage', outages: hit, cutover }
|
|
125
|
+
if (startsBeforeCutover) return { status: 'partial', reason: 'spans_cutover', outages: [], cutover }
|
|
56
126
|
|
|
57
127
|
return { status: 'full' }
|
|
58
128
|
}
|
|
@@ -68,6 +138,10 @@ export function coverageForRange(
|
|
|
68
138
|
*/
|
|
69
139
|
export function applyCoverage(count: number | null, coverage: Coverage): MetricValue {
|
|
70
140
|
if (coverage.status === 'none') {
|
|
141
|
+
if (coverage.reason === 'outage') {
|
|
142
|
+
const outage = coverage.outages?.[0]
|
|
143
|
+
return unavailable('outage', outage ? describeOutage(outage) : undefined)
|
|
144
|
+
}
|
|
71
145
|
return unavailable(
|
|
72
146
|
coverage.reason === 'before_cutover' ? 'before_cutover' : 'not_instrumented',
|
|
73
147
|
coverage.cutover ? `Instrumented from ${coverage.cutover}` : undefined,
|
|
@@ -77,12 +151,27 @@ export function applyCoverage(count: number | null, coverage: Coverage): MetricV
|
|
|
77
151
|
if (count === null) return unavailable('source_error')
|
|
78
152
|
|
|
79
153
|
if (coverage.status === 'partial') {
|
|
80
|
-
|
|
154
|
+
if (coverage.reason === 'spans_outage') {
|
|
155
|
+
const outage = coverage.outages[0]
|
|
156
|
+
// coveredFrom is the outage end where known, since that is when data resumes.
|
|
157
|
+
const from = outage?.until ?? outage?.start ?? coverage.cutover ?? ''
|
|
158
|
+
return partial(count, from, `Undercounted: ${describeOutage(outage)}`)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const from = coverage.cutover ?? ''
|
|
162
|
+
return partial(count, from, `Only counted from ${from}, when this event was added`)
|
|
81
163
|
}
|
|
82
164
|
|
|
83
165
|
return { status: 'ok', value: count }
|
|
84
166
|
}
|
|
85
167
|
|
|
168
|
+
/** One-line description of an outage, for display beside an affected figure. */
|
|
169
|
+
export function describeOutage(outage: EventOutage | undefined): string {
|
|
170
|
+
if (!outage) return 'an outage affected part of this range'
|
|
171
|
+
const period = outage.until ? `${outage.start} to ${outage.until}` : `${outage.start} onwards`
|
|
172
|
+
return outage.reason ? `not recorded ${period} (${outage.reason})` : `not recorded ${period}`
|
|
173
|
+
}
|
|
174
|
+
|
|
86
175
|
/**
|
|
87
176
|
* Human-readable notices for every event in a range that is not fully covered.
|
|
88
177
|
* Surfaced in the report envelope so the panel can show caveats without recomputing them.
|
|
@@ -96,8 +185,13 @@ export function coverageNotices(
|
|
|
96
185
|
|
|
97
186
|
for (const name of eventNames) {
|
|
98
187
|
const coverage = coverageForRange(cutovers, name, range)
|
|
99
|
-
|
|
188
|
+
|
|
189
|
+
if (coverage.status === 'partial' && coverage.reason === 'spans_outage') {
|
|
190
|
+
notices.push(`${name} was ${describeOutage(coverage.outages[0])}, so figures for this range are too low — not a real decline.`)
|
|
191
|
+
} else if (coverage.status === 'partial') {
|
|
100
192
|
notices.push(`${name} was instrumented on ${coverage.cutover}; figures before that date are missing, not zero.`)
|
|
193
|
+
} else if (coverage.status === 'none' && coverage.reason === 'outage') {
|
|
194
|
+
notices.push(`${name} was ${describeOutage(coverage.outages?.[0])}, covering this whole range. The figure is unavailable, not zero.`)
|
|
101
195
|
} else if (coverage.status === 'none' && coverage.reason === 'not_instrumented') {
|
|
102
196
|
notices.push(`${name} is not instrumented on this site, so it cannot be reported for any range.`)
|
|
103
197
|
} else if (coverage.status === 'none' && coverage.reason === 'before_cutover') {
|
package/src/server.ts
CHANGED
|
@@ -23,7 +23,7 @@ export { runDiagnostics, type DiagnosticsInput } from './server/diagnostics'
|
|
|
23
23
|
|
|
24
24
|
// Client constructors. Needed to call runDiagnostics headlessly, which the README documents —
|
|
25
25
|
// without these exported that example could not actually be written.
|
|
26
|
-
export { createGa4Client, type Ga4Client, type Ga4Report, type Ga4ReportRequest } from './server/ga4'
|
|
26
|
+
export { createGa4Client, eventNameFilter, sumFirstMetric, type Ga4Client, type Ga4Report, type Ga4ReportRequest } from './server/ga4'
|
|
27
27
|
export { createVercelClient, type VercelClient, type VercelPageviews } from './server/vercel'
|
|
28
28
|
export { parseServiceAccountKey, type ServiceAccountKey } from './server/googleAuth'
|
|
29
29
|
export type { CheckStatus, DiagnosticCheck, DiagnosticReport } from './reportData'
|
package/src/studio/Figure.tsx
CHANGED
|
@@ -18,6 +18,7 @@ const REASON_TEXT: Record<UnavailableReason, string> = {
|
|
|
18
18
|
not_instrumented: 'Not tracked on this site',
|
|
19
19
|
before_cutover: 'Not tracked during this period',
|
|
20
20
|
suppressed: 'Withheld by GA4 for privacy',
|
|
21
|
+
outage: 'Not recorded during part of this period',
|
|
21
22
|
source_error: 'Source did not respond',
|
|
22
23
|
not_applicable: 'Does not apply to this site',
|
|
23
24
|
}
|
package/src/types.ts
CHANGED
|
@@ -16,6 +16,8 @@ export type UnavailableReason =
|
|
|
16
16
|
| 'before_cutover'
|
|
17
17
|
/** GA4 suppressed the row for privacy thresholding. A real number exists; we may not see it. */
|
|
18
18
|
| 'suppressed'
|
|
19
|
+
/** The event exists in the code but stopped reaching GA4 for this period. Not a real zero. */
|
|
20
|
+
| 'outage'
|
|
19
21
|
/** The upstream source errored or timed out. Retryable, unlike the others. */
|
|
20
22
|
| 'source_error'
|
|
21
23
|
/** The metric is not defined for this site at all (e.g. a flow that does not exist there). */
|