ghost 6.5.2 → 6.6.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/components/tryghost-i18n-6.6.0.tgz +0 -0
- package/core/built/admin/assets/activitypub/activitypub.js +2 -2
- package/core/built/admin/assets/activitypub/{index-BhKEFypa.mjs → index-C11K46Pd.mjs} +2 -2
- package/core/built/admin/assets/activitypub/{index-BpGYxosT.mjs → index-iut24bY_.mjs} +29133 -29074
- package/core/built/admin/assets/admin-x-settings/{CodeEditorView-opzyn619.mjs → CodeEditorView-CpNQAafo.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +1 -1
- package/core/built/admin/assets/admin-x-settings/{index-Bl8-oPss.mjs → index-BosjN912.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/{index-DEKggYGh.mjs → index-k0BbkhP0.mjs} +8 -4
- package/core/built/admin/assets/admin-x-settings/{modals-Dfgf0rk6.mjs → modals-BMRPEgv3.mjs} +8031 -8023
- package/core/built/admin/assets/{chunk.524.776d5830221d3536ea22.js → chunk.524.f3c32903b0e3c946eb06.js} +7 -7
- package/core/built/admin/assets/{chunk.582.e24970c552c8079ff69c.js → chunk.582.7c5559f2d2f813a7ac09.js} +9 -9
- package/core/built/admin/assets/{ghost-6caf3f9221e33783e229a3e10b593fae.js → ghost-dd34586f2d693640a66edb96be8ea6c2.js} +807 -1048
- package/core/built/admin/assets/posts/posts.js +3 -3
- package/core/built/admin/assets/stats/stats.js +46 -42
- package/core/built/admin/index.html +3 -3
- package/core/server/services/VerificationTrigger.js +4 -1
- package/core/server/services/email-analytics/EmailAnalyticsService.js +58 -30
- package/core/server/services/email-analytics/EmailAnalyticsServiceWrapper.js +78 -23
- package/core/server/services/email-analytics/jobs/index.js +1 -1
- package/core/server/services/email-service/email-templates/template.hbs +2 -2
- package/core/server/services/lib/MailgunClient.js +4 -1
- package/core/server/services/mail/GhostMailer.js +1 -1
- package/core/server/services/public-config/config.js +1 -1
- package/core/shared/config/defaults.json +10 -1
- package/core/shared/labs.js +2 -1
- package/package.json +4 -4
- package/yarn.lock +17 -4
- package/components/tryghost-i18n-6.5.2.tgz +0 -0
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
<title>Ghost</title>
|
|
7
7
|
|
|
8
8
|
|
|
9
|
-
<meta name="ghost-admin/config/environment" content="%7B%22modulePrefix%22%3A%22ghost-admin%22%2C%22environment%22%3A%22production%22%2C%22cdnUrl%22%3A%22%22%2C%22editorUrl%22%3A%22%22%2C%22rootURL%22%3A%22%22%2C%22locationType%22%3A%22trailing-hash%22%2C%22EmberENV%22%3A%7B%22FEATURES%22%3A%7B%7D%2C%22EXTEND_PROTOTYPES%22%3A%7B%22Date%22%3Afalse%2C%22Array%22%3Atrue%2C%22String%22%3Atrue%2C%22Function%22%3Afalse%7D%2C%22_APPLICATION_TEMPLATE_WRAPPER%22%3Afalse%2C%22_JQUERY_INTEGRATION%22%3Atrue%2C%22_TEMPLATE_ONLY_GLIMMER_COMPONENTS%22%3Atrue%7D%2C%22APP%22%3A%7B%22version%22%3A%226.
|
|
9
|
+
<meta name="ghost-admin/config/environment" content="%7B%22modulePrefix%22%3A%22ghost-admin%22%2C%22environment%22%3A%22production%22%2C%22cdnUrl%22%3A%22%22%2C%22editorUrl%22%3A%22%22%2C%22rootURL%22%3A%22%22%2C%22locationType%22%3A%22trailing-hash%22%2C%22EmberENV%22%3A%7B%22FEATURES%22%3A%7B%7D%2C%22EXTEND_PROTOTYPES%22%3A%7B%22Date%22%3Afalse%2C%22Array%22%3Atrue%2C%22String%22%3Atrue%2C%22Function%22%3Afalse%7D%2C%22_APPLICATION_TEMPLATE_WRAPPER%22%3Afalse%2C%22_JQUERY_INTEGRATION%22%3Atrue%2C%22_TEMPLATE_ONLY_GLIMMER_COMPONENTS%22%3Atrue%7D%2C%22APP%22%3A%7B%22version%22%3A%226.6%22%2C%22name%22%3A%22ghost-admin%22%7D%2C%22ember-simple-auth%22%3A%7B%7D%2C%22%40sentry%2Fember%22%3A%7B%22disablePerformance%22%3Atrue%2C%22sentry%22%3A%7B%7D%7D%2C%22ember-cli-mirage%22%3A%7B%22usingProxy%22%3Afalse%2C%22useDefaultPassthroughs%22%3Atrue%7D%2C%22exportApplicationGlobal%22%3Afalse%2C%22ember-load%22%3A%7B%22loadingIndicatorClass%22%3A%22ember-load-indicator%22%7D%2C%22editorFilename%22%3A%22koenig-lexical.umd.js%22%2C%22editorHash%22%3A%2237bd1e3e4d%22%2C%22adminXSettingsFilename%22%3A%22admin-x-settings.js%22%2C%22adminXSettingsHash%22%3A%22f54bcf170b%22%2C%22activitypubFilename%22%3A%22activitypub.js%22%2C%22activitypubHash%22%3A%2240c618ae11%22%2C%22postsFilename%22%3A%22posts.js%22%2C%22postsHash%22%3A%229a34075d6a%22%2C%22statsFilename%22%3A%22stats.js%22%2C%22statsHash%22%3A%224b9d666e6c%22%2C%22activitypubRemoteConfigUrl%22%3A%22%2F.ghost%2Factivitypub%2Fstable%2Fclient-config%22%7D" />
|
|
10
10
|
|
|
11
11
|
<meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1, minimal-ui, viewport-fit=cover" />
|
|
12
12
|
<meta name="pinterest" content="nopin" />
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
|
|
50
50
|
<script src="assets/vendor-aed0068cf9b67d042dd23a6343545b7b.js"></script>
|
|
51
51
|
<script src="assets/chunk.397.a720333cfffc99c47e71.js"></script>
|
|
52
|
-
<script src="assets/chunk.524.
|
|
53
|
-
<script src="assets/ghost-
|
|
52
|
+
<script src="assets/chunk.524.f3c32903b0e3c946eb06.js"></script>
|
|
53
|
+
<script src="assets/ghost-dd34586f2d693640a66edb96be8ea6c2.js"></script>
|
|
54
54
|
</body>
|
|
55
55
|
</html>
|
|
@@ -43,7 +43,10 @@ class VerificationTrigger {
|
|
|
43
43
|
this._eventRepository = eventRepository;
|
|
44
44
|
|
|
45
45
|
this._handleMemberCreatedEvent = this._handleMemberCreatedEvent.bind(this);
|
|
46
|
-
|
|
46
|
+
|
|
47
|
+
if (!this._isVerified()) {
|
|
48
|
+
DomainEvents.subscribe(MemberCreatedEvent, this._handleMemberCreatedEvent);
|
|
49
|
+
}
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
get _apiTriggerThreshold() {
|
|
@@ -24,9 +24,32 @@ const errors = require('@tryghost/errors');
|
|
|
24
24
|
* @typedef {'delivered' | 'opened' | 'failed' | 'unsubscribed' | 'complained'} EmailAnalyticsEvent
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* @typedef {object} EmailAnalyticsFetchResult
|
|
29
|
+
* @property {number} eventCount - The number of events fetched
|
|
30
|
+
* @property {number} apiPollingTimeMs - Time spent polling the API in milliseconds
|
|
31
|
+
* @property {number} processingTimeMs - Time spent processing events in milliseconds
|
|
32
|
+
* @property {number} aggregationTimeMs - Time spent aggregating stats in milliseconds
|
|
33
|
+
* @property {EventProcessingResult} result - The processing result with event breakdown
|
|
34
|
+
*/
|
|
35
|
+
|
|
27
36
|
const TRUST_THRESHOLD_MS = 30 * 60 * 1000; // 30 minutes
|
|
28
37
|
const FETCH_LATEST_END_MARGIN_MS = 1 * 60 * 1000; // Do not fetch events newer than 1 minute (yet). Reduces the chance of having missed events in fetchLatest.
|
|
29
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Helper function to create an empty fetch result
|
|
41
|
+
* @returns {EmailAnalyticsFetchResult}
|
|
42
|
+
*/
|
|
43
|
+
function createEmptyResult() {
|
|
44
|
+
return {
|
|
45
|
+
eventCount: 0,
|
|
46
|
+
apiPollingTimeMs: 0,
|
|
47
|
+
processingTimeMs: 0,
|
|
48
|
+
aggregationTimeMs: 0,
|
|
49
|
+
result: new EventProcessingResult()
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
30
53
|
module.exports = class EmailAnalyticsService {
|
|
31
54
|
config;
|
|
32
55
|
settings;
|
|
@@ -125,7 +148,7 @@ module.exports = class EmailAnalyticsService {
|
|
|
125
148
|
* Fetches the latest opened events.
|
|
126
149
|
* @param {Object} options - The options for fetching events.
|
|
127
150
|
* @param {number} [options.maxEvents=Infinity] - The maximum number of events to fetch.
|
|
128
|
-
* @returns {Promise<
|
|
151
|
+
* @returns {Promise<EmailAnalyticsFetchResult>} Fetch results with timing metrics
|
|
129
152
|
*/
|
|
130
153
|
async fetchLatestOpenedEvents({maxEvents = Infinity} = {}) {
|
|
131
154
|
const begin = await this.getLastOpenedEventTimestamp();
|
|
@@ -134,7 +157,7 @@ module.exports = class EmailAnalyticsService {
|
|
|
134
157
|
if (end <= begin) {
|
|
135
158
|
// Skip for now
|
|
136
159
|
logging.info('[EmailAnalytics] Skipping fetchLatestOpenedEvents because end (' + end + ') is before begin (' + begin + ')');
|
|
137
|
-
return
|
|
160
|
+
return createEmptyResult();
|
|
138
161
|
}
|
|
139
162
|
|
|
140
163
|
return await this.#fetchEvents(this.#fetchLatestOpenedData, {begin, end, maxEvents, eventTypes: ['opened']});
|
|
@@ -144,7 +167,7 @@ module.exports = class EmailAnalyticsService {
|
|
|
144
167
|
* Fetches the latest non-opened events.
|
|
145
168
|
* @param {Object} options - The options for fetching events.
|
|
146
169
|
* @param {number} [options.maxEvents=Infinity] - The maximum number of events to fetch.
|
|
147
|
-
* @returns {Promise<
|
|
170
|
+
* @returns {Promise<EmailAnalyticsFetchResult>} Fetch results with timing metrics
|
|
148
171
|
*/
|
|
149
172
|
async fetchLatestNonOpenedEvents({maxEvents = Infinity} = {}) {
|
|
150
173
|
const begin = await this.getLastNonOpenedEventTimestamp();
|
|
@@ -153,7 +176,7 @@ module.exports = class EmailAnalyticsService {
|
|
|
153
176
|
if (end <= begin) {
|
|
154
177
|
// Skip for now
|
|
155
178
|
logging.info('[EmailAnalytics] Skipping fetchLatestNonOpenedEvents because end (' + end + ') is before begin (' + begin + ')');
|
|
156
|
-
return
|
|
179
|
+
return createEmptyResult();
|
|
157
180
|
}
|
|
158
181
|
|
|
159
182
|
return await this.#fetchEvents(this.#fetchLatestNonOpenedData, {begin, end, maxEvents, eventTypes: ['delivered', 'failed', 'unsubscribed', 'complained']});
|
|
@@ -163,6 +186,7 @@ module.exports = class EmailAnalyticsService {
|
|
|
163
186
|
* Fetches events that are older than 30 minutes, because then the 'storage' of the Mailgun API is stable. And we are sure we don't miss any events.
|
|
164
187
|
* @param {object} options
|
|
165
188
|
* @param {number} [options.maxEvents] Not a strict maximum. We stop fetching after we reached the maximum AND received at least one event after begin (not equal) to prevent deadlocks.
|
|
189
|
+
* @returns {Promise<EmailAnalyticsFetchResult>} Fetch results with timing metrics
|
|
166
190
|
*/
|
|
167
191
|
async fetchMissing({maxEvents = Infinity} = {}) {
|
|
168
192
|
const begin = await this.getLastMissingEventTimestamp();
|
|
@@ -178,7 +202,7 @@ module.exports = class EmailAnalyticsService {
|
|
|
178
202
|
if (end <= begin) {
|
|
179
203
|
// Skip for now
|
|
180
204
|
logging.info('[EmailAnalytics] Skipping fetchMissing because end (' + end + ') is before begin (' + begin + ')');
|
|
181
|
-
return
|
|
205
|
+
return createEmptyResult();
|
|
182
206
|
}
|
|
183
207
|
|
|
184
208
|
return await this.#fetchEvents(this.#fetchMissingData, {begin, end, maxEvents});
|
|
@@ -233,18 +257,18 @@ module.exports = class EmailAnalyticsService {
|
|
|
233
257
|
* @method fetchScheduled
|
|
234
258
|
* @param {Object} [options] - The options for fetching scheduled events.
|
|
235
259
|
* @param {number} [options.maxEvents=Infinity] - The maximum number of events to fetch.
|
|
236
|
-
* @returns {Promise<
|
|
260
|
+
* @returns {Promise<EmailAnalyticsFetchResult>} Fetch results with timing metrics
|
|
237
261
|
*/
|
|
238
262
|
async fetchScheduled({maxEvents = Infinity} = {}) {
|
|
239
263
|
if (!this.#fetchScheduledData || !this.#fetchScheduledData.schedule) {
|
|
240
264
|
// Nothing scheduled
|
|
241
|
-
return
|
|
265
|
+
return createEmptyResult();
|
|
242
266
|
}
|
|
243
267
|
|
|
244
268
|
if (this.#fetchScheduledData.canceled) {
|
|
245
269
|
// Skip for now
|
|
246
270
|
this.#fetchScheduledData = null;
|
|
247
|
-
return
|
|
271
|
+
return createEmptyResult();
|
|
248
272
|
}
|
|
249
273
|
|
|
250
274
|
let begin = this.#fetchScheduledData.schedule.begin;
|
|
@@ -262,11 +286,11 @@ module.exports = class EmailAnalyticsService {
|
|
|
262
286
|
running: false,
|
|
263
287
|
jobName: 'email-analytics-scheduled'
|
|
264
288
|
};
|
|
265
|
-
return
|
|
289
|
+
return createEmptyResult();
|
|
266
290
|
}
|
|
267
291
|
|
|
268
|
-
const
|
|
269
|
-
if (
|
|
292
|
+
const fetchResult = await this.#fetchEvents(this.#fetchScheduledData, {begin, end, maxEvents});
|
|
293
|
+
if (fetchResult.eventCount === 0 || this.#fetchScheduledData.canceled) {
|
|
270
294
|
// Reset the scheduled fetch
|
|
271
295
|
this.#fetchScheduledData = {
|
|
272
296
|
running: false,
|
|
@@ -275,7 +299,7 @@ module.exports = class EmailAnalyticsService {
|
|
|
275
299
|
}
|
|
276
300
|
|
|
277
301
|
this.queries.setJobTimestamp(this.#fetchScheduledData.jobName, 'finished', this.#fetchScheduledData.lastEventTimestamp);
|
|
278
|
-
return
|
|
302
|
+
return fetchResult;
|
|
279
303
|
}
|
|
280
304
|
/**
|
|
281
305
|
* Start fetching analytics and store the data of the progress inside fetchData
|
|
@@ -285,18 +309,21 @@ module.exports = class EmailAnalyticsService {
|
|
|
285
309
|
* @param {Date} options.end - End date for fetching events
|
|
286
310
|
* @param {number} [options.maxEvents=Infinity] - Maximum number of events to fetch. Not a strict maximum. We stop fetching after we reached the maximum AND received at least one event after begin (not equal) to prevent deadlocks.
|
|
287
311
|
* @param {EmailAnalyticsEvent[]} [options.eventTypes] - Array of event types to fetch. If not provided, Mailgun will return all event types.
|
|
288
|
-
* @returns {Promise<
|
|
312
|
+
* @returns {Promise<EmailAnalyticsFetchResult>} Fetch results with timing metrics
|
|
289
313
|
*/
|
|
290
314
|
async #fetchEvents(fetchData, {begin, end, maxEvents = Infinity, eventTypes = null}) {
|
|
291
315
|
// Start where we left of, or the last stored event in the database, or start 30 minutes ago if we have nothing available
|
|
292
|
-
logging.info('[EmailAnalytics] Fetching from ' + begin.toISOString() + ' until ' + end.toISOString() + ' (maxEvents: ' + maxEvents + ')');
|
|
293
|
-
|
|
294
316
|
// Store that we started fetching
|
|
295
317
|
fetchData.running = true;
|
|
296
318
|
fetchData.lastStarted = new Date();
|
|
297
319
|
fetchData.lastBegin = begin;
|
|
298
320
|
this.queries.setJobTimestamp(fetchData.jobName, 'started', begin);
|
|
299
321
|
|
|
322
|
+
// Timing metrics
|
|
323
|
+
let apiPollingTimeMs = 0;
|
|
324
|
+
let processingTimeMs = 0;
|
|
325
|
+
let aggregationTimeMs = 0;
|
|
326
|
+
|
|
300
327
|
let lastAggregation = Date.now();
|
|
301
328
|
let eventCount = 0;
|
|
302
329
|
const includeOpenedEvents = eventTypes?.includes('opened') ?? false;
|
|
@@ -314,7 +341,9 @@ module.exports = class EmailAnalyticsService {
|
|
|
314
341
|
*/
|
|
315
342
|
const processBatch = async (events) => {
|
|
316
343
|
// Even if the fetching is interrupted because of an error, we still store the last event timestamp
|
|
344
|
+
const processingStart = Date.now();
|
|
317
345
|
await this.processEventBatch(events, processingResult, fetchData);
|
|
346
|
+
processingTimeMs += (Date.now() - processingStart);
|
|
318
347
|
eventCount += events.length;
|
|
319
348
|
|
|
320
349
|
// Every 5 minutes or 5000 members we do an aggregation and clear the processingResult
|
|
@@ -323,7 +352,9 @@ module.exports = class EmailAnalyticsService {
|
|
|
323
352
|
// Aggregate and clear the processingResult
|
|
324
353
|
// We do this here because otherwise it could take a long time before the new events are visible in the stats
|
|
325
354
|
try {
|
|
355
|
+
const aggregationStart = Date.now();
|
|
326
356
|
await this.aggregateStats(processingResult, includeOpenedEvents);
|
|
357
|
+
aggregationTimeMs += (Date.now() - aggregationStart);
|
|
327
358
|
lastAggregation = Date.now();
|
|
328
359
|
processingResult = new EventProcessingResult();
|
|
329
360
|
} catch (err) {
|
|
@@ -341,10 +372,10 @@ module.exports = class EmailAnalyticsService {
|
|
|
341
372
|
|
|
342
373
|
try {
|
|
343
374
|
for (const provider of this.providers) {
|
|
375
|
+
const apiStart = Date.now();
|
|
344
376
|
await provider.fetchLatest(processBatch, {begin, end, maxEvents, events: eventTypes});
|
|
377
|
+
apiPollingTimeMs += (Date.now() - apiStart);
|
|
345
378
|
}
|
|
346
|
-
|
|
347
|
-
logging.info('[EmailAnalytics] Fetching finished');
|
|
348
379
|
} catch (err) {
|
|
349
380
|
if (err.message !== 'Fetching canceled') {
|
|
350
381
|
logging.error('[EmailAnalytics] Error while fetching');
|
|
@@ -357,7 +388,9 @@ module.exports = class EmailAnalyticsService {
|
|
|
357
388
|
|
|
358
389
|
if (processingResult.memberIds.length > 0 || processingResult.emailIds.length > 0) {
|
|
359
390
|
try {
|
|
391
|
+
const aggregationStart = Date.now();
|
|
360
392
|
await this.aggregateStats(processingResult, includeOpenedEvents);
|
|
393
|
+
aggregationTimeMs += (Date.now() - aggregationStart);
|
|
361
394
|
} catch (err) {
|
|
362
395
|
logging.error('[EmailAnalytics] Error while aggregating stats');
|
|
363
396
|
logging.error(err);
|
|
@@ -372,13 +405,11 @@ module.exports = class EmailAnalyticsService {
|
|
|
372
405
|
// fetching the same events because 'begin' won't change
|
|
373
406
|
// So if we didn't have errors while fetching, and total events < maxEvents, increase lastEventTimestamp with one second
|
|
374
407
|
if (!error && eventCount > 0 && eventCount < maxEvents && fetchData.lastEventTimestamp && fetchData.lastEventTimestamp.getTime() < Date.now() - 2000) {
|
|
375
|
-
logging.info('[EmailAnalytics] Reached end of new events, increasing lastEventTimestamp with one second');
|
|
376
408
|
// set the data on the db so we can store it for fetching after reboot
|
|
377
409
|
await this.queries.setJobTimestamp(fetchData.jobName, 'finished', new Date(fetchData.lastEventTimestamp.getTime()));
|
|
378
410
|
// increment and store in local memory
|
|
379
411
|
fetchData.lastEventTimestamp = new Date(fetchData.lastEventTimestamp.getTime() + 1000);
|
|
380
412
|
} else {
|
|
381
|
-
logging.info('[EmailAnalytics] No new events found');
|
|
382
413
|
// set job status to finished
|
|
383
414
|
await this.queries.setJobStatus(fetchData.jobName, 'finished');
|
|
384
415
|
}
|
|
@@ -388,7 +419,14 @@ module.exports = class EmailAnalyticsService {
|
|
|
388
419
|
if (error) {
|
|
389
420
|
throw error;
|
|
390
421
|
}
|
|
391
|
-
|
|
422
|
+
|
|
423
|
+
return {
|
|
424
|
+
eventCount,
|
|
425
|
+
apiPollingTimeMs,
|
|
426
|
+
processingTimeMs,
|
|
427
|
+
aggregationTimeMs,
|
|
428
|
+
result: processingResult
|
|
429
|
+
};
|
|
392
430
|
}
|
|
393
431
|
|
|
394
432
|
/**
|
|
@@ -509,17 +547,9 @@ module.exports = class EmailAnalyticsService {
|
|
|
509
547
|
* @param {boolean} includeOpenedEvents
|
|
510
548
|
*/
|
|
511
549
|
async aggregateStats({emailIds = [], memberIds = []}, includeOpenedEvents = true) {
|
|
512
|
-
let startTime = Date.now();
|
|
513
|
-
logging.info(`[EmailAnalytics] Aggregating for ${emailIds.length} emails`);
|
|
514
|
-
|
|
515
550
|
for (const emailId of emailIds) {
|
|
516
551
|
await this.aggregateEmailStats(emailId, includeOpenedEvents);
|
|
517
552
|
}
|
|
518
|
-
let endTime = Date.now() - startTime;
|
|
519
|
-
logging.info(`[EmailAnalytics] Aggregating for ${emailIds.length} emails took ${endTime}ms`);
|
|
520
|
-
|
|
521
|
-
startTime = Date.now();
|
|
522
|
-
logging.info(`[EmailAnalytics] Aggregating for ${memberIds.length} members`);
|
|
523
553
|
|
|
524
554
|
// @ts-expect-error
|
|
525
555
|
const memberMetric = this.prometheusClient?.getMetric('email_analytics_aggregate_member_stats_count');
|
|
@@ -527,8 +557,6 @@ module.exports = class EmailAnalyticsService {
|
|
|
527
557
|
await this.aggregateMemberStats(memberId);
|
|
528
558
|
memberMetric?.inc();
|
|
529
559
|
}
|
|
530
|
-
endTime = Date.now() - startTime;
|
|
531
|
-
logging.info(`[EmailAnalytics] Aggregating for ${memberIds.length} members took ${endTime}ms`);
|
|
532
560
|
}
|
|
533
561
|
|
|
534
562
|
/**
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
const logging = require('@tryghost/logging');
|
|
2
|
+
const metrics = require('@tryghost/metrics');
|
|
3
|
+
const config = require('../../../shared/config');
|
|
2
4
|
|
|
3
5
|
class EmailAnalyticsServiceWrapper {
|
|
4
6
|
init() {
|
|
@@ -13,7 +15,6 @@ class EmailAnalyticsServiceWrapper {
|
|
|
13
15
|
const {EmailRecipientFailure, EmailSpamComplaintEvent, Email} = require('../../models');
|
|
14
16
|
const StartEmailAnalyticsJobEvent = require('./events/StartEmailAnalyticsJobEvent');
|
|
15
17
|
const domainEvents = require('@tryghost/domain-events');
|
|
16
|
-
const config = require('../../../shared/config');
|
|
17
18
|
const settings = require('../../../shared/settings-cache');
|
|
18
19
|
const db = require('../../data/db');
|
|
19
20
|
const queries = require('./lib/queries');
|
|
@@ -62,51 +63,100 @@ class EmailAnalyticsServiceWrapper {
|
|
|
62
63
|
});
|
|
63
64
|
}
|
|
64
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Log comprehensive job completion with timing metrics
|
|
68
|
+
* @param {string} jobType - Type of job (e.g., 'latest-opened', 'latest', 'missing', 'scheduled')
|
|
69
|
+
* @param {object} fetchResult - The fetch result from EmailAnalyticsService
|
|
70
|
+
* @param {number} totalDurationMs - Total duration in milliseconds
|
|
71
|
+
*/
|
|
72
|
+
_logJobCompletion(jobType, fetchResult, totalDurationMs) {
|
|
73
|
+
const {eventCount, apiPollingTimeMs, processingTimeMs, aggregationTimeMs, result} = fetchResult;
|
|
74
|
+
|
|
75
|
+
if (eventCount === 0) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const throughput = totalDurationMs > 0 ? eventCount / (totalDurationMs / 1000) : 0;
|
|
80
|
+
const apiPercent = totalDurationMs > 0 ? Math.round((apiPollingTimeMs / totalDurationMs) * 100) : 0;
|
|
81
|
+
const processingPercent = totalDurationMs > 0 ? Math.round((processingTimeMs / totalDurationMs) * 100) : 0;
|
|
82
|
+
const aggregationPercent = totalDurationMs > 0 ? Math.round((aggregationTimeMs / totalDurationMs) * 100) : 0;
|
|
83
|
+
|
|
84
|
+
const logMessage = [
|
|
85
|
+
`[EmailAnalytics] Job complete: ${jobType}`,
|
|
86
|
+
`${eventCount} events in ${(totalDurationMs / 1000).toFixed(1)}s (${throughput.toFixed(2)} events/s)`,
|
|
87
|
+
`Timings: API ${(apiPollingTimeMs / 1000).toFixed(1)}s (${apiPercent}%) / Processing ${(processingTimeMs / 1000).toFixed(1)}s (${processingPercent}%) / Aggregation ${(aggregationTimeMs / 1000).toFixed(1)}s (${aggregationPercent}%)`,
|
|
88
|
+
`Events: opened=${result.opened} delivered=${result.delivered} failed=${result.permanentFailed + result.temporaryFailed} unprocessable=${result.unprocessable}`
|
|
89
|
+
].join(' | ');
|
|
90
|
+
|
|
91
|
+
logging.info(logMessage);
|
|
92
|
+
|
|
93
|
+
// We're only concerned with open throughput as this is displayed to users and is most sensitive to being up to date
|
|
94
|
+
if (jobType === 'latest-opened') {
|
|
95
|
+
const openThroughputEnabled = config.get('emailAnalytics:metrics:openThroughput:enabled');
|
|
96
|
+
const openThroughputThreshold = config.get('emailAnalytics:metrics:openThroughput:threshold') || 0;
|
|
97
|
+
if (openThroughputEnabled && eventCount >= openThroughputThreshold) {
|
|
98
|
+
metrics.metric('email-analytics-open-throughput', {
|
|
99
|
+
value: throughput,
|
|
100
|
+
events: eventCount,
|
|
101
|
+
duration: totalDurationMs
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
65
107
|
async fetchLatestOpenedEvents({maxEvents} = {maxEvents: Infinity}) {
|
|
66
|
-
|
|
108
|
+
const beginTimestamp = await this.service.getLastOpenedEventTimestamp();
|
|
109
|
+
const lagMinutes = (Date.now() - beginTimestamp.getTime()) / 60000;
|
|
110
|
+
const lagThreshold = config.get('emailAnalytics:openedJobLagWarningMinutes');
|
|
111
|
+
|
|
112
|
+
// NOTE: We only update the begin timestamp when we process events, so there's cases where we can have a false positive
|
|
113
|
+
// - Ghost or Mailgun outages
|
|
114
|
+
// - Lack of actual email activity
|
|
115
|
+
if (lagThreshold && lagMinutes > lagThreshold) {
|
|
116
|
+
logging.warn(`[EmailAnalytics] Opened events processing is ${lagMinutes.toFixed(1)} minutes behind (threshold: ${lagThreshold})`);
|
|
117
|
+
}
|
|
67
118
|
|
|
68
119
|
const fetchStartDate = new Date();
|
|
69
|
-
const
|
|
70
|
-
const
|
|
120
|
+
const fetchResult = await this.service.fetchLatestOpenedEvents({maxEvents});
|
|
121
|
+
const totalDuration = Date.now() - fetchStartDate.getTime();
|
|
71
122
|
|
|
72
|
-
|
|
73
|
-
|
|
123
|
+
this._logJobCompletion('latest-opened', fetchResult, totalDuration);
|
|
124
|
+
|
|
125
|
+
return fetchResult.eventCount;
|
|
74
126
|
}
|
|
75
127
|
|
|
76
128
|
async fetchLatestNonOpenedEvents({maxEvents} = {maxEvents: Infinity}) {
|
|
77
|
-
logging.info('[EmailAnalytics] Fetch latest non-opened events started');
|
|
78
|
-
|
|
79
129
|
const fetchStartDate = new Date();
|
|
80
|
-
const
|
|
81
|
-
const
|
|
130
|
+
const fetchResult = await this.service.fetchLatestNonOpenedEvents({maxEvents});
|
|
131
|
+
const totalDuration = Date.now() - fetchStartDate.getTime();
|
|
82
132
|
|
|
83
|
-
|
|
84
|
-
|
|
133
|
+
this._logJobCompletion('latest', fetchResult, totalDuration);
|
|
134
|
+
|
|
135
|
+
return fetchResult.eventCount;
|
|
85
136
|
}
|
|
86
137
|
|
|
87
138
|
async fetchMissing({maxEvents} = {maxEvents: Infinity}) {
|
|
88
|
-
logging.info('[EmailAnalytics] Fetch missing events started');
|
|
89
|
-
|
|
90
139
|
const fetchStartDate = new Date();
|
|
91
|
-
const
|
|
92
|
-
const
|
|
140
|
+
const fetchResult = await this.service.fetchMissing({maxEvents});
|
|
141
|
+
const totalDuration = Date.now() - fetchStartDate.getTime();
|
|
93
142
|
|
|
94
|
-
|
|
95
|
-
|
|
143
|
+
this._logJobCompletion('missing', fetchResult, totalDuration);
|
|
144
|
+
|
|
145
|
+
return fetchResult.eventCount;
|
|
96
146
|
}
|
|
97
147
|
|
|
98
148
|
async fetchScheduled({maxEvents}) {
|
|
99
149
|
if (maxEvents < 300) {
|
|
100
150
|
return 0;
|
|
101
151
|
}
|
|
102
|
-
logging.info('[EmailAnalytics] Fetch scheduled events started');
|
|
103
152
|
|
|
104
153
|
const fetchStartDate = new Date();
|
|
105
|
-
const
|
|
106
|
-
const
|
|
154
|
+
const fetchResult = await this.service.fetchScheduled({maxEvents});
|
|
155
|
+
const totalDuration = Date.now() - fetchStartDate.getTime();
|
|
156
|
+
|
|
157
|
+
this._logJobCompletion('scheduled', fetchResult, totalDuration);
|
|
107
158
|
|
|
108
|
-
|
|
109
|
-
return totalEvents;
|
|
159
|
+
return fetchResult.eventCount;
|
|
110
160
|
}
|
|
111
161
|
|
|
112
162
|
async startFetch() {
|
|
@@ -144,6 +194,11 @@ class EmailAnalyticsServiceWrapper {
|
|
|
144
194
|
return;
|
|
145
195
|
}
|
|
146
196
|
|
|
197
|
+
// Log summary if no events were found across all jobs
|
|
198
|
+
if (c1 + c2 + c3 + c4 === 0) {
|
|
199
|
+
logging.info('[EmailAnalytics] Job complete - No events');
|
|
200
|
+
}
|
|
201
|
+
|
|
147
202
|
this.fetching = false;
|
|
148
203
|
} catch (e) {
|
|
149
204
|
logging.error(e, 'Error while fetching email analytics');
|
|
@@ -10,7 +10,7 @@ module.exports = {
|
|
|
10
10
|
async scheduleRecurringJobs(skipEmailCheck = false) {
|
|
11
11
|
if (
|
|
12
12
|
!hasScheduled &&
|
|
13
|
-
config.get('emailAnalytics') &&
|
|
13
|
+
config.get('emailAnalytics:enabled') &&
|
|
14
14
|
config.get('backgroundJobs:emailAnalytics') &&
|
|
15
15
|
!process.env.NODE_ENV.startsWith('test')
|
|
16
16
|
) {
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
<title>{{post.title}}</title>
|
|
8
8
|
{{>styles}}
|
|
9
9
|
</head>
|
|
10
|
-
<body>
|
|
10
|
+
<body data-testid="email-preview-body">
|
|
11
11
|
<span class="preheader">{{preheader}}</span>
|
|
12
12
|
<!-- SPACING TO AVOID BODY TEXT BEING DUPLICATED IN PREVIEW TEXT -->
|
|
13
13
|
<div style="display:none; max-height:0; overflow:hidden; mso-hide: all;" aria-hidden="true" role="presentation">
|
|
@@ -178,7 +178,7 @@
|
|
|
178
178
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="main" width="100%">
|
|
179
179
|
<tr>
|
|
180
180
|
<td class="wrapper">
|
|
181
|
-
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
|
|
181
|
+
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" data-testid="email-preview-content">
|
|
182
182
|
<tr class="post-content-row">
|
|
183
183
|
<td class="{{classes.body}}">
|
|
184
184
|
<!-- POST CONTENT START -->
|
|
@@ -213,7 +213,10 @@ module.exports = class MailgunClient {
|
|
|
213
213
|
const totalDuration = overallEndTime - overallStartTime;
|
|
214
214
|
const averageBatchTime = batchCount > 0 ? totalBatchTime / batchCount : 0;
|
|
215
215
|
|
|
216
|
-
|
|
216
|
+
// Only log if we actually processed batches
|
|
217
|
+
if (batchCount > 0) {
|
|
218
|
+
logging.info(`[MailgunClient fetchEvents]: Processed ${batchCount} batches in ${(totalDuration / 1000).toFixed(2)}s. Average batch time: ${(averageBatchTime / 1000).toFixed(2)}s`);
|
|
219
|
+
}
|
|
217
220
|
} catch (error) {
|
|
218
221
|
logging.error(error);
|
|
219
222
|
throw error;
|
|
@@ -135,7 +135,7 @@ module.exports = class GhostMailer {
|
|
|
135
135
|
if (tags.length > 0) {
|
|
136
136
|
messageToSend['o:tag'] = tags;
|
|
137
137
|
}
|
|
138
|
-
if (settingsCache.get('
|
|
138
|
+
if (settingsCache.get('email_track_opens')) {
|
|
139
139
|
messageToSend['o:tracking-opens'] = true;
|
|
140
140
|
}
|
|
141
141
|
}
|
|
@@ -17,7 +17,7 @@ module.exports = function getConfigProperties() {
|
|
|
17
17
|
enableDeveloperExperiments: config.get('enableDeveloperExperiments') || false,
|
|
18
18
|
stripeDirect: config.get('stripeDirect'),
|
|
19
19
|
mailgunIsConfigured: !!(config.get('bulkEmail') && config.get('bulkEmail').mailgun),
|
|
20
|
-
emailAnalytics: config.get('emailAnalytics'),
|
|
20
|
+
emailAnalytics: config.get('emailAnalytics:enabled'),
|
|
21
21
|
hostSettings: config.get('hostSettings'),
|
|
22
22
|
tenor: config.get('tenor'),
|
|
23
23
|
pintura: config.get('pintura'),
|
|
@@ -216,7 +216,16 @@
|
|
|
216
216
|
"stripeDirect": false,
|
|
217
217
|
"enableStripePromoCodes": false,
|
|
218
218
|
"enableTipsAndDonations": true,
|
|
219
|
-
"emailAnalytics":
|
|
219
|
+
"emailAnalytics": {
|
|
220
|
+
"enabled": true,
|
|
221
|
+
"metrics": {
|
|
222
|
+
"openThroughput": {
|
|
223
|
+
"enabled": false,
|
|
224
|
+
"threshold": 500
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
"openedJobLagWarningMinutes": 30
|
|
228
|
+
},
|
|
220
229
|
"linkClickTrackingCacheMemberUuid": false,
|
|
221
230
|
"backgroundJobs": {
|
|
222
231
|
"emailAnalytics": true,
|
package/core/shared/labs.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ghost",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.6.0",
|
|
4
4
|
"description": "The professional publishing platform",
|
|
5
5
|
"author": "Ghost Foundation",
|
|
6
6
|
"homepage": "https://ghost.org",
|
|
@@ -86,7 +86,7 @@
|
|
|
86
86
|
"@tryghost/helpers": "1.1.97",
|
|
87
87
|
"@tryghost/html-to-plaintext": "1.0.4",
|
|
88
88
|
"@tryghost/http-cache-utils": "0.1.20",
|
|
89
|
-
"@tryghost/i18n": "file:components/tryghost-i18n-6.
|
|
89
|
+
"@tryghost/i18n": "file:components/tryghost-i18n-6.6.0.tgz",
|
|
90
90
|
"@tryghost/image-transform": "1.4.6",
|
|
91
91
|
"@tryghost/job-manager": "1.0.3",
|
|
92
92
|
"@tryghost/kg-card-factory": "5.1.2",
|
|
@@ -161,7 +161,7 @@
|
|
|
161
161
|
"ghost-storage-base": "1.0.0",
|
|
162
162
|
"glob": "8.1.0",
|
|
163
163
|
"got": "11.8.6",
|
|
164
|
-
"gscan": "5.
|
|
164
|
+
"gscan": "5.2.0",
|
|
165
165
|
"handlebars": "4.7.8",
|
|
166
166
|
"heic-convert": "2.1.0",
|
|
167
167
|
"html-to-text": "5.1.1",
|
|
@@ -273,7 +273,7 @@
|
|
|
273
273
|
"jackspeak": "2.3.6",
|
|
274
274
|
"moment": "2.24.0",
|
|
275
275
|
"moment-timezone": "0.5.45",
|
|
276
|
-
"@tryghost/i18n": "file:components/tryghost-i18n-6.
|
|
276
|
+
"@tryghost/i18n": "file:components/tryghost-i18n-6.6.0.tgz"
|
|
277
277
|
},
|
|
278
278
|
"nx": {
|
|
279
279
|
"targets": {
|
package/yarn.lock
CHANGED
|
@@ -5238,6 +5238,19 @@
|
|
|
5238
5238
|
"@radix-ui/react-use-previous" "1.1.1"
|
|
5239
5239
|
"@radix-ui/react-use-size" "1.1.1"
|
|
5240
5240
|
|
|
5241
|
+
"@radix-ui/react-switch@^1.2.6":
|
|
5242
|
+
version "1.2.6"
|
|
5243
|
+
resolved "https://registry.yarnpkg.com/@radix-ui/react-switch/-/react-switch-1.2.6.tgz#ff79acb831f0d5ea9216cfcc5b939912571358e3"
|
|
5244
|
+
integrity sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==
|
|
5245
|
+
dependencies:
|
|
5246
|
+
"@radix-ui/primitive" "1.1.3"
|
|
5247
|
+
"@radix-ui/react-compose-refs" "1.1.2"
|
|
5248
|
+
"@radix-ui/react-context" "1.1.2"
|
|
5249
|
+
"@radix-ui/react-primitive" "2.1.3"
|
|
5250
|
+
"@radix-ui/react-use-controllable-state" "1.2.2"
|
|
5251
|
+
"@radix-ui/react-use-previous" "1.1.1"
|
|
5252
|
+
"@radix-ui/react-use-size" "1.1.1"
|
|
5253
|
+
|
|
5241
5254
|
"@radix-ui/react-tabs@1.1.12":
|
|
5242
5255
|
version "1.1.12"
|
|
5243
5256
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-tabs/-/react-tabs-1.1.12.tgz#99b3522c73db9263f429a6d0f5a9acb88df3b129"
|
|
@@ -20441,10 +20454,10 @@ growly@^1.3.0:
|
|
|
20441
20454
|
resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081"
|
|
20442
20455
|
integrity sha512-+xGQY0YyAWCnqy7Cd++hc2JqMYzlm0dG30Jd0beaA64sROr8C4nt8Yc9V5Ro3avlSUDTN0ulqP/VBKi1/lLygw==
|
|
20443
20456
|
|
|
20444
|
-
gscan@5.
|
|
20445
|
-
version "5.
|
|
20446
|
-
resolved "https://registry.yarnpkg.com/gscan/-/gscan-5.
|
|
20447
|
-
integrity sha512-
|
|
20457
|
+
gscan@5.2.0:
|
|
20458
|
+
version "5.2.0"
|
|
20459
|
+
resolved "https://registry.yarnpkg.com/gscan/-/gscan-5.2.0.tgz#62947df15b3467b2a12a7e96b0aaf41b96b0c702"
|
|
20460
|
+
integrity sha512-C+xr32TKsaOEetqO0k3BX+DB+nYuyeWJx7UdA/oRtKmX7YQ+z8SjH5AtDAqWr2zVaG/o79migjRHrXV0LOfRIQ==
|
|
20448
20461
|
dependencies:
|
|
20449
20462
|
"@sentry/node" "^9.0.0"
|
|
20450
20463
|
"@tryghost/config" "^0.2.18"
|
|
Binary file
|