forked from kubestellar/console
-
Notifications
You must be signed in to change notification settings - Fork 0
279 lines (253 loc) · 11.8 KB
/
ga4-mobile-monitor.yml
File metadata and controls
279 lines (253 loc) · 11.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
name: GA4 Mobile Traffic Monitor
# Checks GA4 daily for mobile traffic health. Creates a GitHub issue if
# mobile sessions drop below a percentage threshold for 3+ consecutive days,
# which may indicate a mobile-specific crash (e.g., React render loop).
#
# Context: On March 20, 2026, a render loop in Sidebar.tsx killed mobile
# completely — mobile dropped from ~20% to <3% of traffic. This wasn't
# detected for 19 days because the crash prevented analytics from loading.
# This monitor catches that pattern early.
#
# Secrets required:
# GA4_SERVICE_ACCOUNT_KEY — Google service account JSON with GA4 Data API access
# GA4_PROPERTY_ID — GA4 property ID
on:
schedule:
- cron: '15 8 * * *' # 8:15 UTC daily (after nightly tests complete)
workflow_dispatch:
inputs:
lookback_days:
description: 'Days to analyze (default: 7)'
required: false
default: '7'
type: string
min_mobile_pct:
description: 'Minimum expected mobile % (default: 5)'
required: false
default: '5'
type: string
env:
ISSUE_TAG: "[GA4-Mobile]"
# Minimum mobile traffic percentage expected (below this = potential crash)
MIN_MOBILE_PCT: 5
# Number of consecutive low-mobile days before alerting
CONSECUTIVE_DAYS_THRESHOLD: 3
# Minimum total sessions/day to consider (skip very low-traffic days).
# At <200 sessions/day, the 5% mobile threshold is dominated by sample-size
# noise — 1-2 users moves the percentage by 1-2 points, so the monitor flaps
# open/close daily. See issue #8136 for the data that motivated raising this
# floor from 10 to 200.
MIN_DAILY_SESSIONS: 200
LOOKBACK_DAYS: 7
permissions:
contents: read
issues: write
jobs:
monitor:
if: github.repository == 'kubestellar/console'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@v4
with:
sparse-checkout: |
.github
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install googleapis
run: npm install googleapis@144
- name: Check mobile traffic health
uses: actions/github-script@v8
env:
GA4_SERVICE_ACCOUNT_KEY: ${{ secrets.GA4_SERVICE_ACCOUNT_KEY }}
GA4_PROPERTY_ID: ${{ secrets.GA4_PROPERTY_ID }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { google } = require('googleapis');
// ── Config ──
const lookbackDays = parseInt('${{ inputs.lookback_days }}' || process.env.LOOKBACK_DAYS);
const minMobilePct = parseFloat('${{ inputs.min_mobile_pct }}' || process.env.MIN_MOBILE_PCT);
const consecutiveThreshold = parseInt(process.env.CONSECUTIVE_DAYS_THRESHOLD);
const minDailySessions = parseInt(process.env.MIN_DAILY_SESSIONS);
const tag = process.env.ISSUE_TAG;
const propertyId = process.env.GA4_PROPERTY_ID;
if (!process.env.GA4_SERVICE_ACCOUNT_KEY || !propertyId) {
core.warning('GA4_SERVICE_ACCOUNT_KEY or GA4_PROPERTY_ID not set — skipping');
return;
}
// ── Auth ──
const credentials = JSON.parse(process.env.GA4_SERVICE_ACCOUNT_KEY);
const auth = new google.auth.GoogleAuth({
credentials,
scopes: ['https://www.googleapis.com/auth/analytics.readonly'],
});
const analyticsData = google.analyticsdata({ version: 'v1beta', auth });
// ── Query GA4 for sessions by device category ──
const endDate = new Date();
const startDate = new Date(endDate.getTime() - lookbackDays * 86400000);
const fmt = (d) => d.toISOString().slice(0, 10);
const res = await analyticsData.properties.runReport({
property: `properties/${propertyId}`,
requestBody: {
dateRanges: [{ startDate: fmt(startDate), endDate: fmt(endDate) }],
dimensions: [{ name: 'date' }, { name: 'deviceCategory' }],
metrics: [{ name: 'sessions' }],
orderBys: [{ dimension: { dimensionName: 'date' } }],
limit: 500,
},
});
// ── Aggregate by date ──
const dailyData = new Map(); // date -> { desktop, mobile, tablet, total }
for (const row of res.data.rows || []) {
const date = row.dimensionValues[0].value;
const device = row.dimensionValues[1].value;
const sessions = parseInt(row.metricValues[0].value) || 0;
if (!dailyData.has(date)) {
dailyData.set(date, { desktop: 0, mobile: 0, tablet: 0, total: 0 });
}
const day = dailyData.get(date);
if (device === 'mobile') day.mobile += sessions;
else if (device === 'tablet') day.tablet += sessions;
else if (device === 'desktop') day.desktop += sessions;
day.total += sessions;
}
// ── Analyze mobile percentage trend ──
const sortedDates = [...dailyData.keys()].sort();
let consecutiveLowDays = 0;
let maxConsecutiveLow = 0;
const dailyReport = [];
for (const date of sortedDates) {
const day = dailyData.get(date);
if (day.total < minDailySessions) {
// Skip low-traffic days: sample size too small to trust the
// mobile %. Reset the consecutive-low streak so skipped days
// do not silently chain together low-volume days that are
// actually noise (see #8136).
dailyReport.push({ date, mobilePct: null, sessions: day.total, skipped: true });
consecutiveLowDays = 0;
continue;
}
const mobilePct = (day.mobile / day.total) * 100;
const isLow = mobilePct < minMobilePct;
if (isLow) {
consecutiveLowDays++;
maxConsecutiveLow = Math.max(maxConsecutiveLow, consecutiveLowDays);
} else {
consecutiveLowDays = 0;
}
dailyReport.push({
date,
mobilePct: mobilePct.toFixed(1),
mobile: day.mobile,
desktop: day.desktop,
sessions: day.total,
isLow,
});
}
// ── Log summary ──
console.log('\n📱 Mobile Traffic Report');
console.log('─'.repeat(60));
for (const d of dailyReport) {
if (d.skipped) {
console.log(` ${d.date}: skipped — ${d.sessions} sessions below ${minDailySessions} sample-size floor (mobile % unreliable)`);
} else {
const flag = d.isLow ? ' ⚠️' : ' ✓';
console.log(` ${d.date}: ${d.mobilePct}% mobile (${d.mobile}/${d.sessions})${flag}`);
}
}
console.log('─'.repeat(60));
console.log(`Max consecutive low-mobile days: ${maxConsecutiveLow} (threshold: ${consecutiveThreshold})`);
// ── Create or close issue ──
const { data: existingIssues } = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
labels: 'ga4-monitor,mobile-traffic',
per_page: 10,
});
const existing = existingIssues.find(i => i.title.includes(tag));
if (maxConsecutiveLow >= consecutiveThreshold) {
// Alert: mobile traffic is suspiciously low
const tableRows = dailyReport
.filter(d => !d.skipped)
.map(d => `| ${d.date} | ${d.mobilePct}% | ${d.mobile} | ${d.desktop} | ${d.sessions} | ${d.isLow ? '⚠️' : '✓'} |`)
.join('\n');
const body = [
`## ⚠️ Mobile traffic below ${minMobilePct}% for ${maxConsecutiveLow} consecutive days`,
'',
`This may indicate a mobile-specific crash (e.g., React render loop) that prevents`,
`analytics from loading on mobile devices.`,
'',
`### Daily Breakdown`,
'',
`| Date | Mobile % | Mobile | Desktop | Total | Status |`,
`|------|----------|--------|---------|-------|--------|`,
tableRows,
'',
`### Investigate`,
'',
`1. Open \`console.kubestellar.io\` on a mobile device or emulator`,
`2. Check for React error boundary ("This page encountered an error")`,
`3. Run mobile Playwright test: \`npx playwright test e2e/smoke.spec.ts --grep "mobile"\``,
`4. Check nightly react-render-errors report for mobile viewport failures`,
'',
`### Context`,
'',
`On March 20, 2026, a render loop in Sidebar.tsx crashed mobile browsers silently.`,
`Mobile traffic dropped from ~20% to <3% for 19 days before detection.`,
`This monitor exists to catch that pattern early.`,
'',
`> Auto-generated by GA4 Mobile Traffic Monitor. Auto-closes when mobile traffic recovers.`,
].join('\n');
if (existing) {
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: existing.number,
body,
});
console.log(`\n⚠️ Updated existing issue #${existing.number}`);
} else {
// Ensure labels exist
for (const label of ['ga4-monitor', 'mobile-traffic', 'bug']) {
try {
await github.rest.issues.getLabel({ owner: context.repo.owner, repo: context.repo.repo, name: label });
} catch {
await github.rest.issues.createLabel({
owner: context.repo.owner, repo: context.repo.repo, name: label,
color: label === 'ga4-monitor' ? '0969da' : label === 'mobile-traffic' ? 'a333c8' : 'd73a4a',
});
}
}
const { data: issue } = await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `${tag} Mobile traffic critically low — possible mobile crash`,
body,
labels: ['ga4-monitor', 'mobile-traffic', 'bug'],
});
console.log(`\n⚠️ Created issue #${issue.number}`);
}
} else if (existing) {
// Mobile traffic recovered — close the issue
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: existing.number,
state: 'closed',
state_reason: 'completed',
});
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: existing.number,
body: `✅ Mobile traffic recovered above ${minMobilePct}% threshold on ${fmt(endDate)}. Auto-closing.`,
});
console.log(`\n✅ Mobile traffic recovered — closed issue #${existing.number}`);
} else {
console.log(`\n✅ Mobile traffic healthy — no action needed`);
}