Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 26 additions & 8 deletions ghost/admin/app/components/posts/debug.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -375,10 +375,10 @@
<td>Last event time:</td>
<td>{{ this.analyticsStatus.missing.lastEventTimestamp }}</td>
</tr>
<tr>
<td colspan="2"><hr></td>
</tr>
{{#if (and this.analyticsStatus this.analyticsStatus.scheduled this.analyticsStatus.scheduled.schedule) }}
<tr>
<td colspan="2"><hr></td>
</tr>
<tr>
<td>Analytics Scheduled running:</td>
<td class="gh-email-debug-settings-icon">
Expand Down Expand Up @@ -409,11 +409,29 @@
</tr>
{{/unless}}
{{else}}
<tr>
<td colspan="2">
<button type="button" class="gh-email-debug-schedule-analytics" {{on "click" this.scheduleAnalytics }}>{{svg-jar "reload"}}Refetch Analytics</button>
</td>
</tr>
{{#if this.showCustomSchedule}}
<tr>
<td><label for="custom-begin-date">Begin:</label></td>
<td><input id="custom-begin-date" type="datetime-local" value={{this.customBeginDate}} {{on "input" this.updateCustomBeginDate}} style="border: 1px solid #ccc; padding: 6px 10px; border-radius: 4px; background: #f9f9f9; cursor: pointer; font-size: 1.3rem;" /></td>
</tr>
<tr>
<td><label for="custom-end-date">End:</label></td>
<td><input id="custom-end-date" type="datetime-local" value={{this.customEndDate}} {{on "input" this.updateCustomEndDate}} style="border: 1px solid #ccc; padding: 6px 10px; border-radius: 4px; background: #f9f9f9; cursor: pointer; font-size: 1.3rem;" /></td>
</tr>
<tr>
<td colspan="2">
<button type="button" class="gh-email-debug-schedule-analytics" {{on "click" this.scheduleAnalytics }}>{{svg-jar "reload"}}Schedule Custom Refetch</button>
<button type="button" class="gh-email-debug-schedule-analytics" {{on "click" this.toggleCustomSchedule }}>Cancel</button>
</td>
</tr>
{{else}}
<tr>
<td colspan="2">
<button type="button" class="gh-email-debug-schedule-analytics" {{on "click" this.scheduleAnalytics }}>{{svg-jar "reload"}}Refetch Analytics</button>
<button type="button" class="gh-email-debug-schedule-analytics" {{on "click" this.toggleCustomSchedule }}>Custom Date Range</button>
</td>
</tr>
{{/if}}
{{/if}}
</tbody>
</table>
Expand Down
38 changes: 36 additions & 2 deletions ghost/admin/app/components/posts/debug.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ export default class Debug extends Component {
@tracked loading = true;
@tracked analyticsStatus = null;
@tracked latestEmail = null;
@tracked showCustomSchedule = false;
@tracked customBeginDate = null;
@tracked customEndDate = null;

get post() {
return this.args.post;
Expand Down Expand Up @@ -293,6 +296,30 @@ export default class Debug extends Component {
}
}

@action
toggleCustomSchedule() {
this.showCustomSchedule = !this.showCustomSchedule;
if (this.showCustomSchedule) {
this.customBeginDate = moment(this.email?.createdAtUTC).format('YYYY-MM-DDTHH:mm');
const createdAt = moment(this.email?.createdAtUTC);
const maxEnd = moment.min(moment().subtract(1, 'hour'), createdAt.clone().add(7, 'days'));
this.customEndDate = maxEnd.format('YYYY-MM-DDTHH:mm');
} else {
this.customBeginDate = null;
this.customEndDate = null;
}
}

@action
updateCustomBeginDate(event) {
this.customBeginDate = event.target.value;
}

@action
updateCustomEndDate(event) {
this.customEndDate = event.target.value;
}

@action
scheduleAnalytics() {
try {
Expand All @@ -310,9 +337,16 @@ export default class Debug extends Component {

@task
*_scheduleAnalytics() {
let statsUrl = this.ghostPaths.url.api(`/emails/${this.post.email.id}/analytics`);
yield this.ajax.put(statsUrl, {});
const url = new URL(this.ghostPaths.url.api(`/emails/${this.post.email.id}/analytics`), window.location.origin);
if (this.customBeginDate) {
url.searchParams.set('begin', new Date(this.customBeginDate).toISOString());
}
if (this.customEndDate) {
url.searchParams.set('end', new Date(this.customEndDate).toISOString());
}
yield this.ajax.put(url.pathname + url.search, {});
yield this.fetchAnalyticsStatus();
this.showCustomSchedule = false;
}

@action
Expand Down
20 changes: 15 additions & 5 deletions ghost/core/core/server/api/endpoints/emails.js
Original file line number Diff line number Diff line change
Expand Up @@ -158,12 +158,22 @@ const controller = {
data: [
'id'
],
options: [
'begin',
'end'
],
async query(frame) {
const model = await models.Email.findOne(frame.data, frame.options);
return emailAnalytics.service.schedule({
begin: model.get('created_at'),
end: new Date(Math.min(Date.now() - 60 * 60 * 1000, model.get('created_at').getTime() + 24 * 60 * 60 * 1000 * 7))
});
const {begin: beginParam, end: endParam, ...findOptions} = frame.options;
const model = await models.Email.findOne(frame.data, findOptions);

const begin = beginParam
? new Date(beginParam)
: model.get('created_at');
const end = endParam
? new Date(endParam)
: new Date(Math.min(Date.now() - 60 * 60 * 1000, model.get('created_at').getTime() + 24 * 60 * 60 * 1000 * 7));

return emailAnalytics.service.schedule({begin, end});
}
},

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ const metrics = require('@tryghost/metrics');
const config = require('../../../shared/config');

class EmailAnalyticsServiceWrapper {
#restoredSchedule = false;

init() {
if (this.service) {
return;
Expand Down Expand Up @@ -167,6 +169,11 @@ class EmailAnalyticsServiceWrapper {
}

async startFetch() {
if (!this.#restoredSchedule) {
this.#restoredSchedule = true;
await this.service.restoreScheduled();
}

if (this.fetching) {
logging.info('Email analytics fetch already running, skipping');
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,14 @@ module.exports = class EmailAnalyticsService {
}
}

#clearScheduledData() {
this.#fetchScheduledData = {
running: false,
jobName: 'email-analytics-scheduled'
};
this.queries.setJobMetadata('email-analytics-scheduled', null);
}

getStatus() {
return {
latest: this.#fetchLatestNonOpenedData,
Expand Down Expand Up @@ -219,7 +227,7 @@ module.exports = class EmailAnalyticsService {
* @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}) {
async 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.'
Expand All @@ -234,6 +242,10 @@ module.exports = class EmailAnalyticsService {
end
}
};
await this.queries.setJobMetadata('email-analytics-scheduled', {
begin: begin.toISOString(),
end: end.toISOString()
});
}

/**
Expand All @@ -245,14 +257,46 @@ module.exports = class EmailAnalyticsService {
cancelScheduled() {
if (this.#fetchScheduledData) {
if (this.#fetchScheduledData.running) {
// Cancel the running fetch
this.#fetchScheduledData.canceled = true;
// Clear metadata eagerly; fetchScheduled() will clear in-memory state next cycle
this.queries.setJobMetadata('email-analytics-scheduled', null);
} else {
this.#clearScheduledData();
}
}
}

/**
* Restores a previously persisted scheduled fetch from the database.
* Must only be called once on startup (caller guards against repeated calls).
*/
async restoreScheduled() {
try {
const jobData = await this.queries.getJobData('email-analytics-scheduled');
if (!jobData || !jobData.metadata) {
return;
}

const metadata = JSON.parse(jobData.metadata);
if (metadata.begin && metadata.end) {
const begin = new Date(metadata.begin);
const end = new Date(metadata.end);

this.#fetchScheduledData = {
running: false,
jobName: 'email-analytics-scheduled'
jobName: 'email-analytics-scheduled',
schedule: {begin, end}
};

// Use finished_at as the resume cursor if available
if (jobData.finished_at) {
this.#fetchScheduledData.lastEventTimestamp = new Date(jobData.finished_at);
}

logging.info('[EmailAnalytics] Restored scheduled fetch: ' + begin.toISOString() + ' to ' + end.toISOString());
}
} catch (e) {
logging.error('[EmailAnalytics] Failed to restore scheduled fetch', e);
}
}

Expand All @@ -270,8 +314,7 @@ module.exports = class EmailAnalyticsService {
}

if (this.#fetchScheduledData.canceled) {
// Skip for now
this.#fetchScheduledData = null;
this.#clearScheduledData();
return createEmptyResult();
}

Expand All @@ -284,22 +327,14 @@ module.exports = class EmailAnalyticsService {
}

if (end <= begin) {
// Skip for now
logging.info('[EmailAnalytics] Ending fetchScheduled because end is before begin');
this.#fetchScheduledData = {
running: false,
jobName: 'email-analytics-scheduled'
};
this.#clearScheduledData();
return createEmptyResult();
}

const fetchResult = await this.#fetchEvents(this.#fetchScheduledData, {begin, end, maxEvents});
if (fetchResult.eventCount === 0 || this.#fetchScheduledData.canceled) {
// Reset the scheduled fetch
this.#fetchScheduledData = {
running: false,
jobName: 'email-analytics-scheduled'
};
this.#clearScheduledData();
}

this.queries.setJobTimestamp(this.#fetchScheduledData.jobName, 'finished', this.#fetchScheduledData.lastEventTimestamp);
Expand Down
45 changes: 44 additions & 1 deletion ghost/core/core/server/services/email-analytics/lib/queries.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ module.exports = {
* @returns {Promise<Object|null>} The job data, or null if no job data is found.
*/
async getJobData(jobName) {
return await db.knex('jobs').select('finished_at', 'started_at').where('name', jobName).first();
return await db.knex('jobs').select('finished_at', 'started_at', 'metadata').where('name', jobName).first();
},

/**
Expand Down Expand Up @@ -127,6 +127,49 @@ module.exports = {
}
},

/**
* Retrieves and parses the metadata JSON for the specified job.
* @param {EmailAnalyticsJobName} jobName - The name of the job.
* @returns {Promise<Object|null>} The parsed metadata object, or null.
*/
async getJobMetadata(jobName) {
try {
const row = await db.knex('jobs').select('metadata').where('name', jobName).first();
if (row && row.metadata) {
return JSON.parse(row.metadata);
}
} catch (err) {
logging.error(`Error reading metadata for job ${jobName}: ${err.message}`);
}
return null;
},

/**
* Writes metadata JSON for the specified job.
* @param {EmailAnalyticsJobName} jobName - The name of the job.
* @param {Object|null} metadata - The metadata to store, or null to clear.
* @returns {Promise<void>}
*/
async setJobMetadata(jobName, metadata) {
try {
const value = metadata ? JSON.stringify(metadata) : null;
await db.knex.transaction(async (trx) => {
const result = await trx('jobs').update({metadata: value, updated_at: new Date()}).where('name', jobName);
if (result === 0 && metadata) {
await trx('jobs').insert({
id: new ObjectID().toHexString(),
name: jobName,
metadata: value,
created_at: new Date(),
status: 'queued'
});
}
});
} catch (err) {
logging.error(`Error setting metadata for job ${jobName}: ${err.message}`);
}
},

/**
* Sets the status of the specified email analytics job.
* @param {EmailAnalyticsJobName} jobName - The name of the job to update.
Expand Down
Loading