UNPKG

ghost

Version:

The professional publishing platform

553 lines (477 loc) 23.6 kB
const EventProcessingResult = require('./EventProcessingResult'); const logging = require('@tryghost/logging'); const errors = require('@tryghost/errors'); /** * @typedef {import('../email-service/EmailEventProcessor')} EmailEventProcessor */ /** * @typedef {object} FetchData * @property {boolean} running * @property {('email-analytics-latest-others'|'email-analytics-missing'|'email-analytics-latest-opened'|'email-analytics-scheduled')} jobName Name of the job that is running * @property {Date} [lastStarted] Date the last fetch started on * @property {Date} [lastBegin] The begin time used during the last fetch * @property {Date} [lastEventTimestamp] * @property {boolean} [canceled] Set to quit the job early */ /** * @typedef {FetchData & {schedule?: {begin: Date, end: Date}}} FetchDataScheduled */ /** * @typedef {'delivered' | 'opened' | 'failed' | 'unsubscribed' | 'complained'} EmailAnalyticsEvent */ const TRUST_THRESHOLD_MS = 30 * 60 * 1000; // 30 minutes 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. module.exports = class EmailAnalyticsService { config; settings; queries; eventProcessor; providers; /** * @type {FetchData} */ #fetchLatestNonOpenedData = { running: false, jobName: 'email-analytics-latest-others' }; /** * @type {FetchData} */ #fetchMissingData = { running: false, jobName: 'email-analytics-missing' }; /** * @type {FetchData} */ #fetchLatestOpenedData = { running: false, jobName: 'email-analytics-latest-opened' }; /** * @type {FetchDataScheduled} */ #fetchScheduledData = { running: false, jobName: 'email-analytics-scheduled' }; /** * @param {object} dependencies * @param {object} dependencies.config * @param {object} dependencies.settings * @param {object} dependencies.queries * @param {EmailEventProcessor} dependencies.eventProcessor * @param {object} dependencies.providers * @param {import('@tryghost/domain-events')} dependencies.domainEvents * @param {import('@tryghost/prometheus-metrics')} dependencies.prometheusClient */ constructor({config, settings, queries, eventProcessor, providers, domainEvents, prometheusClient}) { this.config = config; this.settings = settings; this.queries = queries; this.eventProcessor = eventProcessor; this.providers = providers; this.domainEvents = domainEvents; this.prometheusClient = prometheusClient; if (prometheusClient) { // @ts-expect-error prometheusClient.registerCounter({name: 'email_analytics_aggregate_member_stats_count', help: 'Count of member stats aggregations'}); } } getStatus() { return { latest: this.#fetchLatestNonOpenedData, missing: this.#fetchMissingData, scheduled: this.#fetchScheduledData, latestOpened: this.#fetchLatestOpenedData }; } /** * Returns the timestamp of the last non-opened event we processed. Defaults to now minus 30 minutes if we have no data yet. */ async getLastNonOpenedEventTimestamp() { return this.#fetchLatestNonOpenedData?.lastEventTimestamp ?? (await this.queries.getLastEventTimestamp(this.#fetchLatestNonOpenedData.jobName,['delivered','failed'])) ?? new Date(Date.now() - TRUST_THRESHOLD_MS); } /** * Returns the timestamp of the last opened event we processed. Defaults to now minus 30 minutes if we have no data yet. */ async getLastOpenedEventTimestamp() { return this.#fetchLatestOpenedData?.lastEventTimestamp ?? (await this.queries.getLastEventTimestamp(this.#fetchLatestOpenedData.jobName,['opened'])) ?? new Date(Date.now() - TRUST_THRESHOLD_MS); } /** * Returns the timestamp of the last missing event we processed. Defaults to now minus 2h if we have no data yet. */ async getLastMissingEventTimestamp() { return this.#fetchMissingData?.lastEventTimestamp ?? (await this.queries.getLastJobRunTimestamp(this.#fetchMissingData.jobName)) ?? new Date(Date.now() - TRUST_THRESHOLD_MS * 4); } /** * Fetches the latest opened events. * @param {Object} options - The options for fetching events. * @param {number} [options.maxEvents=Infinity] - The maximum number of events to fetch. * @returns {Promise<number>} The total number of events fetched. */ async fetchLatestOpenedEvents({maxEvents = Infinity} = {}) { const begin = await this.getLastOpenedEventTimestamp(); const end = new Date(Date.now() - FETCH_LATEST_END_MARGIN_MS); // Always stop at x minutes ago to give Mailgun a bit more time to stabilize storage if (end <= begin) { // Skip for now logging.info('[EmailAnalytics] Skipping fetchLatestOpenedEvents because end (' + end + ') is before begin (' + begin + ')'); return 0; } return await this.#fetchEvents(this.#fetchLatestOpenedData, {begin, end, maxEvents, eventTypes: ['opened']}); } /** * Fetches the latest non-opened events. * @param {Object} options - The options for fetching events. * @param {number} [options.maxEvents=Infinity] - The maximum number of events to fetch. * @returns {Promise<number>} The total number of events fetched. */ async fetchLatestNonOpenedEvents({maxEvents = Infinity} = {}) { const begin = await this.getLastNonOpenedEventTimestamp(); const end = new Date(Date.now() - FETCH_LATEST_END_MARGIN_MS); // Always stop at x minutes ago to give Mailgun a bit more time to stabilize storage if (end <= begin) { // Skip for now logging.info('[EmailAnalytics] Skipping fetchLatestNonOpenedEvents because end (' + end + ') is before begin (' + begin + ')'); return 0; } return await this.#fetchEvents(this.#fetchLatestNonOpenedData, {begin, end, maxEvents, eventTypes: ['delivered', 'failed', 'unsubscribed', 'complained']}); } /** * 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. * @param {object} options * @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. */ async fetchMissing({maxEvents = Infinity} = {}) { const begin = await this.getLastMissingEventTimestamp(); // Always stop at the earlier of the time the fetchLatest started fetching on or 30 minutes ago const end = new Date( Math.min( Date.now() - TRUST_THRESHOLD_MS, this.#fetchLatestNonOpenedData?.lastBegin?.getTime() || Date.now() // Fallback to now if the previous job didn't run, for whatever reason, prevents catastrophic error ) ); if (end <= begin) { // Skip for now logging.info('[EmailAnalytics] Skipping fetchMissing because end (' + end + ') is before begin (' + begin + ')'); return 0; } return await this.#fetchEvents(this.#fetchMissingData, {begin, end, maxEvents}); } /** * Schedule a new fetch for email analytics events. * @param {Object} options - The options for scheduling the fetch. * @param {Date} options.begin - The start date for the scheduled fetch. * @param {Date} options.end - The end date for the scheduled fetch. * @throws {errors.ValidationError} Throws an error if a fetch is already in progress. */ schedule({begin, end}) { if (this.#fetchScheduledData && this.#fetchScheduledData.running) { throw new errors.ValidationError({ message: 'Already fetching scheduled events. Wait for it to finish before scheduling a new one.' }); } logging.info('[EmailAnalytics] Scheduling fetch from ' + begin.toISOString() + ' until ' + end.toISOString()); this.#fetchScheduledData = { running: false, jobName: 'email-analytics-scheduled', schedule: { begin, end } }; } /** * Cancels the scheduled fetch of email analytics events. * If a fetch is currently running, it marks it for cancellation. * If no fetch is running, it clears the scheduled fetch data. * @method cancelScheduled */ cancelScheduled() { if (this.#fetchScheduledData) { if (this.#fetchScheduledData.running) { // Cancel the running fetch this.#fetchScheduledData.canceled = true; } else { this.#fetchScheduledData = { running: false, jobName: 'email-analytics-scheduled' }; } } } /** * Continues fetching the scheduled events (does not start one). Resets the scheduled event when received 0 events. * @method fetchScheduled * @param {Object} [options] - The options for fetching scheduled events. * @param {number} [options.maxEvents=Infinity] - The maximum number of events to fetch. * @returns {Promise<number>} The number of events fetched. */ async fetchScheduled({maxEvents = Infinity} = {}) { if (!this.#fetchScheduledData || !this.#fetchScheduledData.schedule) { // Nothing scheduled return 0; } if (this.#fetchScheduledData.canceled) { // Skip for now this.#fetchScheduledData = null; return 0; } let begin = this.#fetchScheduledData.schedule.begin; const end = this.#fetchScheduledData.schedule.end; if (this.#fetchScheduledData.lastEventTimestamp && this.#fetchScheduledData.lastEventTimestamp > begin) { // Continue where we left of begin = this.#fetchScheduledData.lastEventTimestamp; } if (end <= begin) { // Skip for now logging.info('[EmailAnalytics] Ending fetchScheduled because end is before begin'); this.#fetchScheduledData = { running: false, jobName: 'email-analytics-scheduled' }; return 0; } const count = await this.#fetchEvents(this.#fetchScheduledData, {begin, end, maxEvents}); if (count === 0 || this.#fetchScheduledData.canceled) { // Reset the scheduled fetch this.#fetchScheduledData = { running: false, jobName: 'email-analytics-scheduled' }; } this.queries.setJobTimestamp(this.#fetchScheduledData.jobName, 'finished', this.#fetchScheduledData.lastEventTimestamp); return count; } /** * Start fetching analytics and store the data of the progress inside fetchData * @param {FetchData} fetchData - Object to store the progress of the fetch operation * @param {object} options - Options for fetching events * @param {Date} options.begin - Start date for fetching events * @param {Date} options.end - End date for fetching events * @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. * @param {EmailAnalyticsEvent[]} [options.eventTypes] - Array of event types to fetch. If not provided, Mailgun will return all event types. * @returns {Promise<number>} The number of events fetched */ async #fetchEvents(fetchData, {begin, end, maxEvents = Infinity, eventTypes = null}) { // Start where we left of, or the last stored event in the database, or start 30 minutes ago if we have nothing available logging.info('[EmailAnalytics] Fetching from ' + begin.toISOString() + ' until ' + end.toISOString() + ' (maxEvents: ' + maxEvents + ')'); // Store that we started fetching fetchData.running = true; fetchData.lastStarted = new Date(); fetchData.lastBegin = begin; this.queries.setJobTimestamp(fetchData.jobName, 'started', begin); let lastAggregation = Date.now(); let eventCount = 0; const includeOpenedEvents = eventTypes?.includes('opened') ?? false; // We keep the processing result here, so we also have a result in case of failures let processingResult = new EventProcessingResult(); let error = null; /** * Process a batch of events * @param {Array<Object>} events - Array of event objects to process * @param {EventProcessingResult} processingResult - Object to store the processing results * @param {FetchData} fetchData - Object containing fetch operation data * @returns {Promise<void>} */ const processBatch = async (events) => { // Even if the fetching is interrupted because of an error, we still store the last event timestamp await this.processEventBatch(events, processingResult, fetchData); eventCount += events.length; // Every 5 minutes or 5000 members we do an aggregation and clear the processingResult // Otherwise we need to loop a lot of members afterwards, and this takes too long without updating the stat counts in between if ((Date.now() - lastAggregation > 5 * 60 * 1000 || processingResult.memberIds.length > 5000) && eventCount > 0) { // Aggregate and clear the processingResult // We do this here because otherwise it could take a long time before the new events are visible in the stats try { await this.aggregateStats(processingResult, includeOpenedEvents); lastAggregation = Date.now(); processingResult = new EventProcessingResult(); } catch (err) { logging.error('[EmailAnalytics] Error while aggregating stats'); logging.error(err); } } if (fetchData.canceled) { throw new errors.InternalServerError({ message: 'Fetching canceled' }); } }; try { for (const provider of this.providers) { await provider.fetchLatest(processBatch, {begin, end, maxEvents, events: eventTypes}); } logging.info('[EmailAnalytics] Fetching finished'); } catch (err) { if (err.message !== 'Fetching canceled') { logging.error('[EmailAnalytics] Error while fetching'); logging.error(err); error = err; } else { logging.error('[EmailAnalytics] Canceled fetching'); } } if (processingResult.memberIds.length > 0 || processingResult.emailIds.length > 0) { try { await this.aggregateStats(processingResult, includeOpenedEvents); } catch (err) { logging.error('[EmailAnalytics] Error while aggregating stats'); logging.error(err); if (!error) { error = err; } } } // Small trick: if reached the end of new events, we are going to keep // fetching the same events because 'begin' won't change // So if we didn't have errors while fetching, and total events < maxEvents, increase lastEventTimestamp with one second if (!error && eventCount > 0 && eventCount < maxEvents && fetchData.lastEventTimestamp && fetchData.lastEventTimestamp.getTime() < Date.now() - 2000) { logging.info('[EmailAnalytics] Reached end of new events, increasing lastEventTimestamp with one second'); // set the data on the db so we can store it for fetching after reboot await this.queries.setJobTimestamp(fetchData.jobName, 'finished', new Date(fetchData.lastEventTimestamp.getTime())); // increment and store in local memory fetchData.lastEventTimestamp = new Date(fetchData.lastEventTimestamp.getTime() + 1000); } else { logging.info('[EmailAnalytics] No new events found'); // set job status to finished await this.queries.setJobStatus(fetchData.jobName, 'finished'); } fetchData.running = false; if (error) { throw error; } return eventCount; } /** * Process a batch of email analytics events. * @param {any[]} events - An array of email analytics events to process. * @param {Object} result - The result object to merge batch processing results into. * @param {FetchData} fetchData - Data related to the current fetch operation. * @returns {Promise<void>} */ async processEventBatch(events, result, fetchData) { for (const event of events) { const batchResult = await this.processEvent(event); // Save last event timestamp if (!fetchData.lastEventTimestamp || (event.timestamp && event.timestamp > fetchData.lastEventTimestamp)) { fetchData.lastEventTimestamp = event.timestamp; // don't need to keep db in sync; it'll fall back to last completed timestamp anyways } result.merge(batchResult); } } /** * * @param {{id: string, type: any; severity: any; recipientEmail: any; emailId?: string; providerId: string; timestamp: Date; error: {code: number; message: string; enhandedCode: string|number} | null}} event * @returns {Promise<EventProcessingResult>} */ async processEvent(event) { if (event.type === 'delivered') { const recipient = await this.eventProcessor.handleDelivered({emailId: event.emailId, providerId: event.providerId, email: event.recipientEmail}, event.timestamp); if (recipient) { return new EventProcessingResult({ delivered: 1, emailIds: [recipient.emailId], memberIds: [recipient.memberId] }); } return new EventProcessingResult({unprocessable: 1}); } if (event.type === 'opened') { const recipient = await this.eventProcessor.handleOpened({emailId: event.emailId, providerId: event.providerId, email: event.recipientEmail}, event.timestamp); if (recipient) { return new EventProcessingResult({ opened: 1, emailIds: [recipient.emailId], memberIds: [recipient.memberId] }); } return new EventProcessingResult({unprocessable: 1}); } if (event.type === 'failed') { if (event.severity === 'permanent') { const recipient = await this.eventProcessor.handlePermanentFailed({emailId: event.emailId, providerId: event.providerId, email: event.recipientEmail}, {id: event.id, timestamp: event.timestamp, error: event.error}); if (recipient) { return new EventProcessingResult({ permanentFailed: 1, emailIds: [recipient.emailId], memberIds: [recipient.memberId] }); } return new EventProcessingResult({unprocessable: 1}); } else { const recipient = await this.eventProcessor.handleTemporaryFailed({emailId: event.emailId, providerId: event.providerId, email: event.recipientEmail}, {id: event.id, timestamp: event.timestamp, error: event.error}); if (recipient) { return new EventProcessingResult({ temporaryFailed: 1, emailIds: [recipient.emailId], memberIds: [recipient.memberId] }); } return new EventProcessingResult({unprocessable: 1}); } } if (event.type === 'unsubscribed') { const recipient = await this.eventProcessor.handleUnsubscribed({emailId: event.emailId, providerId: event.providerId, email: event.recipientEmail}, event.timestamp); if (recipient) { return new EventProcessingResult({ unsubscribed: 1, emailIds: [recipient.emailId], memberIds: [recipient.memberId] }); } return new EventProcessingResult({unprocessable: 1}); } if (event.type === 'complained') { const recipient = await this.eventProcessor.handleComplained({emailId: event.emailId, providerId: event.providerId, email: event.recipientEmail}, event.timestamp); if (recipient) { return new EventProcessingResult({ complained: 1, emailIds: [recipient.emailId], memberIds: [recipient.memberId] }); } return new EventProcessingResult({unprocessable: 1}); } return new EventProcessingResult({unhandled: 1}); } /** * @param {{emailIds?: string[], memberIds?: string[]}} stats * @param {boolean} includeOpenedEvents */ async aggregateStats({emailIds = [], memberIds = []}, includeOpenedEvents = true) { let startTime = Date.now(); logging.info(`[EmailAnalytics] Aggregating for ${emailIds.length} emails`); for (const emailId of emailIds) { await this.aggregateEmailStats(emailId, includeOpenedEvents); } let endTime = Date.now() - startTime; logging.info(`[EmailAnalytics] Aggregating for ${emailIds.length} emails took ${endTime}ms`); startTime = Date.now(); logging.info(`[EmailAnalytics] Aggregating for ${memberIds.length} members`); // @ts-expect-error const memberMetric = this.prometheusClient?.getMetric('email_analytics_aggregate_member_stats_count'); for (const memberId of memberIds) { await this.aggregateMemberStats(memberId); memberMetric?.inc(); } endTime = Date.now() - startTime; logging.info(`[EmailAnalytics] Aggregating for ${memberIds.length} members took ${endTime}ms`); } /** * Aggregate email stats for a given email ID. * @param {string} emailId - The ID of the email to aggregate stats for. * @param {boolean} includeOpenedEvents - Whether to include opened events in the stats. * @returns {Promise<void>} */ async aggregateEmailStats(emailId, includeOpenedEvents) { return this.queries.aggregateEmailStats(emailId, includeOpenedEvents); } /** * Aggregate member stats for a given member ID. * @param {string} memberId - The ID of the member to aggregate stats for. * @returns {Promise<void>} */ async aggregateMemberStats(memberId) { return this.queries.aggregateMemberStats(memberId); } };