Skip to content

Commit 5bb46e4

Browse files
authored
Fixed stale Ghost(Pro) load errors (TryGhost#28108)
ref [INC-284](https://app.incident.io/ghost/incidents/284) Hidden Billing iframe preload timeouts could set the same failure state and Sentry signal used for visible Ghost(Pro) opens, causing users to see a stale error before Admin made a fresh visible attempt. Split preload diagnostics from visible load failures, restart the monitor with a new attempt id when a user opens Billing, and pass `bmaAttemptId` through the iframe URL so future Billing lifecycle messages can be correlated without treating post-shell loading as an Admin failure.
1 parent 0dc8360 commit 5bb46e4

3 files changed

Lines changed: 221 additions & 7 deletions

File tree

ghost/admin/app/services/billing.js

Lines changed: 101 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import {tracked} from '@glimmer/tracking';
66
const BILLING_APP_LOAD_TIMEOUT_MS = 10_000;
77
const BILLING_APP_LOAD_RETRY_DELAYS_MS = [1_000];
88
const BILLING_APP_LOAD_FAILURE_MESSAGE = 'Billing app failed to become ready';
9+
const BILLING_APP_ATTEMPT_SOURCE_PRELOAD = 'preload';
10+
const BILLING_APP_ATTEMPT_SOURCE_USER_OPEN = 'user_open';
11+
const BILLING_APP_ATTEMPT_SOURCE_RETRY = 'retry';
912

1013
export default class BillingService extends Service {
1114
@service ghostPaths;
@@ -29,12 +32,17 @@ export default class BillingService extends Service {
2932
@tracked billingAppLastPreReadyMessageType = null;
3033
@tracked billingAppReadyReceivedAt = null;
3134
@tracked billingAppReadyPayload = null;
35+
@tracked billingAppPreloadFailure = null;
3236

3337
billingAppLoadTimeout = null;
3438
billingAppRetryTimeout = null;
3539
billingAppLoadAttempts = 0;
3640
billingAppLoadTimeoutMs = BILLING_APP_LOAD_TIMEOUT_MS;
3741
billingAppLoadRetryDelaysMs = BILLING_APP_LOAD_RETRY_DELAYS_MS;
42+
billingAppLoadAttemptId = null;
43+
billingAppLoadAttemptSource = BILLING_APP_ATTEMPT_SOURCE_PRELOAD;
44+
billingAppIframeReloadReason = null;
45+
billingAppLoadAttemptSequence = 0;
3846
billingAppIframeSrcSetAt = null;
3947
billingAppIframeLoadFired = false;
4048

@@ -63,8 +71,11 @@ export default class BillingService extends Service {
6371
return this.getBillingIframe() !== null && this.getBillingIframe().contentWindow;
6472
}
6573

66-
startBillingAppLoadMonitor() {
74+
startBillingAppLoadMonitor(options = {}) {
75+
const {source = this.billingAppLoadAttemptSource} = options;
76+
6777
if (this.billingAppLoadTimeout || this.billingAppRetryTimeout) {
78+
this.billingAppLoadAttemptSource = source;
6879
return;
6980
}
7081

@@ -75,6 +86,7 @@ export default class BillingService extends Service {
7586
this.resetBillingAppLoadDiagnostics();
7687
}
7788

89+
this.billingAppLoadAttemptSource = source;
7890
this.billingAppLoadAttempts += 1;
7991
this.billingAppLoadTimeout = setTimeout(() => {
8092
this.billingAppLoadTimeout = null;
@@ -88,45 +100,58 @@ export default class BillingService extends Service {
88100
if (retryDelay !== undefined) {
89101
this.billingAppRetryTimeout = setTimeout(() => {
90102
this.billingAppRetryTimeout = null;
91-
this.reloadBillingIframe();
92-
this.startBillingAppLoadMonitor();
103+
const source = this.billingWindowOpen ? BILLING_APP_ATTEMPT_SOURCE_RETRY : this.billingAppLoadAttemptSource;
104+
105+
this.reloadBillingIframe({source, reloadReason: 'timeout_retry'});
106+
this.startBillingAppLoadMonitor({source});
93107
}, retryDelay);
94108
return;
95109
}
96110

97111
this.reportBillingAppLoadFailure();
98112
}
99113

100-
setBillingIframeSrc() {
114+
setBillingIframeSrc(options = {}) {
101115
const iframe = this.getBillingIframe();
102116
if (!iframe) {
103117
return;
104118
}
119+
120+
const {
121+
source = this.billingAppLoadAttemptSource || BILLING_APP_ATTEMPT_SOURCE_PRELOAD,
122+
reloadReason = 'set_src'
123+
} = options;
124+
105125
if (this._loadListenerAttachedTo !== iframe && typeof iframe.addEventListener === 'function') {
106126
iframe.addEventListener('load', () => {
107127
this.billingAppIframeLoadFired = true;
108128
});
109129
this._loadListenerAttachedTo = iframe;
110130
}
131+
this.billingAppLoadAttemptSequence += 1;
132+
this.billingAppLoadAttemptId = `${Date.now()}-${this.billingAppLoadAttemptSequence}`;
133+
this.billingAppLoadAttemptSource = source;
134+
this.billingAppIframeReloadReason = reloadReason;
111135
this.billingAppIframeLoadFired = false;
112136
this.billingAppIframeSrcSetAt = Date.now();
113137
this.resetBillingAppLoadDiagnostics();
114138
iframe.src = this.getIframeURL();
115139
}
116140

117-
reloadBillingIframe() {
141+
reloadBillingIframe(options = {}) {
118142
const iframe = this.getBillingIframe();
119143

120144
if (!iframe || this.billingAppLoaded) {
121145
return;
122146
}
123147

124-
this.setBillingIframeSrc();
148+
this.setBillingIframeSrc(options);
125149
}
126150

127151
markBillingAppLoaded(payload = null) {
128152
this.billingAppLoaded = true;
129153
this.billingAppLoadFailureReported = false;
154+
this.billingAppPreloadFailure = null;
130155
this.billingAppReadyReceivedAt = Date.now();
131156
this.billingAppReadyPayload = payload;
132157
this.clearBillingAppLoadMonitor();
@@ -232,11 +257,51 @@ export default class BillingService extends Service {
232257
}
233258
}
234259

260+
ensureBillingAppReadyForVisibleUse() {
261+
if (this.billingAppLoaded) {
262+
return;
263+
}
264+
265+
const hadPreloadFailure = !!this.billingAppPreloadFailure;
266+
const hadVisibleFailure = this.billingAppLoadFailureReported;
267+
268+
this.billingAppLoadFailureReported = false;
269+
this.clearBillingAppLoadMonitor();
270+
this.billingAppLoadAttempts = 0;
271+
272+
if (hadPreloadFailure || hadVisibleFailure) {
273+
this.reloadBillingIframe({
274+
source: BILLING_APP_ATTEMPT_SOURCE_USER_OPEN,
275+
reloadReason: hadPreloadFailure ? 'visible_open_after_preload_failure' : 'visible_open_after_load_failure'
276+
});
277+
} else {
278+
this.billingAppLoadAttemptSource = BILLING_APP_ATTEMPT_SOURCE_USER_OPEN;
279+
}
280+
281+
this.startBillingAppLoadMonitor({source: BILLING_APP_ATTEMPT_SOURCE_USER_OPEN});
282+
}
283+
284+
recordBillingAppPreloadFailure() {
285+
this.billingAppPreloadFailure = {
286+
attemptId: this.billingAppLoadAttemptId,
287+
attempts: this.billingAppLoadAttempts,
288+
elapsedMs: this.billingAppIframeSrcSetAt ? Date.now() - this.billingAppIframeSrcSetAt : null,
289+
nonReadyMessageCount: this.billingAppPreReadyMessageCount,
290+
nonReadyMessageTypes: [...this.billingAppPreReadyMessageTypes],
291+
lastNonReadyMessageType: this.billingAppLastPreReadyMessageType
292+
};
293+
}
294+
235295
reportBillingAppLoadFailure() {
236296
if (this.billingAppLoadFailureReported) {
237297
return;
238298
}
239299

300+
if (!this.billingWindowOpen) {
301+
this.recordBillingAppPreloadFailure();
302+
return;
303+
}
304+
240305
this.billingAppLoadFailureReported = true;
241306

242307
if (!this.config.sentry_dsn) {
@@ -287,6 +352,10 @@ export default class BillingService extends Service {
287352
ghost: {
288353
billing_monitor: {
289354
attempts: this.billingAppLoadAttempts,
355+
attempt_id: this.billingAppLoadAttemptId,
356+
attempt_source: this.billingAppLoadAttemptSource,
357+
attempt_phase: 'shell_ready',
358+
iframe_reload_reason: this.billingAppIframeReloadReason,
290359
has_billing_url: !!this.config.hostSettings?.billing?.url,
291360
is_force_upgrade: !!this.config.hostSettings?.forceUpgrade,
292361
location_hash: window.location.hash,
@@ -304,6 +373,11 @@ export default class BillingService extends Service {
304373
non_ready_message_count: this.billingAppPreReadyMessageCount,
305374
non_ready_message_types: this.billingAppPreReadyMessageTypes.join(','),
306375
last_non_ready_message_type: this.billingAppLastPreReadyMessageType,
376+
has_preload_failure: !!this.billingAppPreloadFailure,
377+
preload_failure_elapsed_ms: this.billingAppPreloadFailure?.elapsedMs ?? null,
378+
preload_non_ready_message_count: this.billingAppPreloadFailure?.nonReadyMessageCount ?? null,
379+
preload_non_ready_message_types: this.billingAppPreloadFailure?.nonReadyMessageTypes?.join(',') ?? null,
380+
preload_last_non_ready_message_type: this.billingAppPreloadFailure?.lastNonReadyMessageType ?? null,
307381
ready_received: false,
308382
navigator_online: navigator.onLine,
309383
connection_effective_type: navigator.connection?.effectiveType ?? null,
@@ -316,6 +390,8 @@ export default class BillingService extends Service {
316390
},
317391
tags: {
318392
source: 'billing-app-load-monitor',
393+
attempt_source: this.billingAppLoadAttemptSource,
394+
attempt_phase: 'shell_ready',
319395
route: this.router.currentRouteName,
320396
path: window.location.hash
321397
}
@@ -340,7 +416,21 @@ export default class BillingService extends Service {
340416
}
341417
}
342418

343-
return url;
419+
return this.addBillingAppAttemptIdToURL(url);
420+
}
421+
422+
addBillingAppAttemptIdToURL(url) {
423+
if (!url || !this.billingAppLoadAttemptId) {
424+
return url;
425+
}
426+
427+
try {
428+
const billingUrl = new URL(url);
429+
billingUrl.searchParams.set('bmaAttemptId', this.billingAppLoadAttemptId);
430+
return billingUrl.toString();
431+
} catch (e) {
432+
return url;
433+
}
344434
}
345435

346436
async getOwnerUser() {
@@ -398,6 +488,10 @@ export default class BillingService extends Service {
398488
this.sendRouteUpdate();
399489

400490
this.billingWindowOpen = value;
491+
492+
if (value) {
493+
this.ensureBillingAppReadyForVisibleUse();
494+
}
401495
}
402496

403497
// Controls navigation to billing window modal which is triggered from the application UI.

ghost/admin/tests/integration/components/gh-billing-modal-test.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,17 @@ describe('Integration: Component: gh-billing-modal', function () {
102102
expect(find('[data-test-billing-load-error-description] a')).to.have.attribute('href', 'mailto:support@ghost.org');
103103
});
104104

105+
it('keeps showing the loading state for hidden preload diagnostics', async function () {
106+
billing.billingAppPreloadFailure = {
107+
attemptId: 'preload-attempt'
108+
};
109+
110+
await render(hbs`<GhBillingModal @billingWindowOpen={{true}} />`);
111+
112+
expect(find('[data-test-billing-loading]')).to.exist;
113+
expect(find('[data-test-billing-load-error]')).to.not.exist;
114+
});
115+
105116
it('clears a reported error when the billing app sends a late message', async function () {
106117
billing.billingAppLoadFailureReported = true;
107118

ghost/admin/tests/unit/services/billing-test.js

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,14 +101,108 @@ describe('Unit: Service: billing', function () {
101101
expect(iframe.src).to.equal('https://billing.example.test/pro');
102102
});
103103

104+
it('adds the active attempt id to the billing iframe URL', function () {
105+
const service = this.owner.lookup('service:billing');
106+
billingService = service;
107+
service.billingAppLoadAttemptId = 'attempt-123';
108+
109+
expect(service.getIframeURL({fetchOwner: false})).to.equal('https://billing.example.test/?bmaAttemptId=attempt-123');
110+
});
111+
112+
it('records hidden preload timeout without reporting a visible failure', async function () {
113+
const service = this.owner.lookup('service:billing');
114+
billingService = service;
115+
service.billingAppLoadTimeoutMs = 1;
116+
service.billingAppLoadRetryDelaysMs = [];
117+
service.billingAppIframeSrcSetAt = Date.now() - 100;
118+
119+
service.startBillingAppLoadMonitor();
120+
121+
await waitUntil(() => service.billingAppPreloadFailure);
122+
123+
expect(service.billingAppLoadFailureReported).to.be.false;
124+
expect(service.billingAppPreloadFailure).to.deep.include({
125+
attempts: 1,
126+
nonReadyMessageCount: 0,
127+
lastNonReadyMessageType: null
128+
});
129+
expect(testkit.reports()).to.have.lengthOf(0);
130+
});
131+
132+
it('promotes an in-flight preload attempt when the user opens billing', function () {
133+
const service = this.owner.lookup('service:billing');
134+
billingService = service;
135+
service.billingAppLoadTimeoutMs = 1000;
136+
const reloadBillingIframe = sinon.spy(service, 'reloadBillingIframe');
137+
138+
service.startBillingAppLoadMonitor();
139+
service.toggleProWindow(true);
140+
141+
expect(service.billingWindowOpen).to.be.true;
142+
expect(service.billingAppLoadAttemptSource).to.equal('user_open');
143+
expect(service.billingAppLoadAttempts).to.equal(1);
144+
expect(service.billingAppLoadTimeout).to.not.be.null;
145+
expect(reloadBillingIframe.called).to.be.false;
146+
});
147+
148+
it('reloads the iframe for a fresh visible attempt after preload failed', function () {
149+
const service = this.owner.lookup('service:billing');
150+
billingService = service;
151+
const iframe = {src: '', addEventListener: sinon.stub()};
152+
sinon.stub(service, 'getBillingIframe').returns(iframe);
153+
service.billingAppPreloadFailure = {
154+
attemptId: 'preload-attempt',
155+
attempts: 2,
156+
elapsedMs: 12000,
157+
nonReadyMessageCount: 1,
158+
nonReadyMessageTypes: ['token']
159+
};
160+
161+
service.toggleProWindow(true);
162+
163+
expect(service.billingWindowOpen).to.be.true;
164+
expect(service.billingAppLoadFailureReported).to.be.false;
165+
expect(service.billingAppLoadAttemptSource).to.equal('user_open');
166+
expect(service.billingAppIframeReloadReason).to.equal('visible_open_after_preload_failure');
167+
expect(service.billingAppLoadTimeout).to.not.be.null;
168+
expect(iframe.src).to.match(/^https:\/\/billing\.example\.test\/\?bmaAttemptId=/);
169+
});
170+
171+
it('reloads the iframe for a fresh visible attempt after a previous visible failure', function () {
172+
const service = this.owner.lookup('service:billing');
173+
billingService = service;
174+
const iframe = {src: '', addEventListener: sinon.stub()};
175+
sinon.stub(service, 'getBillingIframe').returns(iframe);
176+
service.billingAppLoadFailureReported = true;
177+
178+
service.toggleProWindow(true);
179+
180+
expect(service.billingWindowOpen).to.be.true;
181+
expect(service.billingAppLoadFailureReported).to.be.false;
182+
expect(service.billingAppIframeReloadReason).to.equal('visible_open_after_load_failure');
183+
expect(service.billingAppLoadTimeout).to.not.be.null;
184+
expect(iframe.src).to.match(/^https:\/\/billing\.example\.test\/\?bmaAttemptId=/);
185+
});
186+
104187
it('reports to Sentry with diagnostics when the billing app does not become ready', async function () {
105188
const service = this.owner.lookup('service:billing');
106189
billingService = service;
107190
sinon.stub(service, 'getBillingIframe').returns(null);
108191
service.billingAppLoadAttempts = 2;
192+
service.billingAppLoadAttemptId = 'attempt-123';
193+
service.billingAppLoadAttemptSource = 'user_open';
194+
service.billingAppIframeReloadReason = 'visible_open_after_preload_failure';
109195
service.billingAppIframeSrcSetAt = Date.now() - 1234;
110196
service.billingAppIframeLoadFired = true;
111197
service.billingWindowOpen = true;
198+
service.billingAppPreloadFailure = {
199+
attemptId: 'preload-attempt',
200+
attempts: 2,
201+
elapsedMs: 12000,
202+
nonReadyMessageCount: 1,
203+
nonReadyMessageTypes: ['token'],
204+
lastNonReadyMessageType: 'token'
205+
};
112206

113207
service.reportBillingAppLoadFailure();
114208

@@ -127,12 +221,21 @@ describe('Unit: Service: billing', function () {
127221
const billingMonitor = report.originalReport.contexts.ghost.billing_monitor;
128222
expect(billingMonitor).to.deep.include({
129223
attempts: 2,
224+
attempt_id: 'attempt-123',
225+
attempt_source: 'user_open',
226+
attempt_phase: 'shell_ready',
227+
iframe_reload_reason: 'visible_open_after_preload_failure',
130228
has_billing_url: true,
131229
is_force_upgrade: false,
132230
iframe_src: null,
133231
configured_billing_origin: 'https://billing.example.test',
134232
iframe_load_fired: true,
135233
billing_window_open: true,
234+
has_preload_failure: true,
235+
preload_failure_elapsed_ms: 12000,
236+
preload_non_ready_message_count: 1,
237+
preload_non_ready_message_types: 'token',
238+
preload_last_non_ready_message_type: 'token',
136239
non_ready_message_count: 0,
137240
non_ready_message_types: '',
138241
last_non_ready_message_type: null,
@@ -144,12 +247,18 @@ describe('Unit: Service: billing', function () {
144247
bma_boot_threw: false
145248
});
146249
expect(billingMonitor.ms_since_src_set).to.be.a('number').and.to.be.at.least(1234);
250+
expect(report.tags).to.deep.include({
251+
attempt_source: 'user_open',
252+
attempt_phase: 'shell_ready'
253+
});
147254
});
148255

149256
it('reports pre-ready message diagnostics to Sentry', async function () {
150257
const service = this.owner.lookup('service:billing');
151258
billingService = service;
152259
sinon.stub(service, 'getBillingIframe').returns(null);
260+
service.billingWindowOpen = true;
261+
service.billingAppLoadAttemptSource = 'user_open';
153262
service.billingAppLoadAttempts = 2;
154263
service.billingAppLoadTimeout = setTimeout(() => {}, 10000);
155264
service.billingAppIframeSrcSetAt = Date.now() - 100;

0 commit comments

Comments
 (0)