From 4896c74b613e082fd2e3a31f4a51b514ff9e60d3 Mon Sep 17 00:00:00 2001 From: Austin Burdine Date: Thu, 20 Aug 2026 12:39:10 -0400 Subject: [PATCH 1/9] =?UTF-8?q?=F0=9F=90=9B=20Fixed=20log=20flushing=20on?= =?UTF-8?q?=20exit=20(#30165)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref https://linear.app/ghost/issue/PLA-254/fatal-boot-errors-are-lost-from-elasticsearch-no-logging-flush-before - bump framework deps to pull in logging flush fix - wire up log flush to exit points --- .changeset/brown-rules-cut.md | 6 + ghost/core/core/boot.js | 10 +- ghost/core/core/server/ghost-server.js | 11 +- ghost/core/core/shared/flush-logs.ts | 16 ++ pnpm-lock.yaml | 294 +++++++++++-------------- pnpm-workspace.yaml | 16 +- 6 files changed, 171 insertions(+), 182 deletions(-) create mode 100644 .changeset/brown-rules-cut.md create mode 100644 ghost/core/core/shared/flush-logs.ts diff --git a/.changeset/brown-rules-cut.md b/.changeset/brown-rules-cut.md new file mode 100644 index 00000000000..3c6251b5d0e --- /dev/null +++ b/.changeset/brown-rules-cut.md @@ -0,0 +1,6 @@ +--- +"@tryghost/adapter-base-scheduling": patch +"@tryghost/adapter-base-sso": patch +--- + +Bumped framework dependencies diff --git a/ghost/core/core/boot.js b/ghost/core/core/boot.js index e5d9f32e369..0bcccdd19e5 100644 --- a/ghost/core/core/boot.js +++ b/ghost/core/core/boot.js @@ -531,6 +531,7 @@ async function bootGhost({backend = true, frontend = true, server = true} = {}) // We need access to these variables in both the try and catch block let bootLogger; let config; + let flushLogs; let ghostServer; let logging; let metrics; @@ -553,6 +554,7 @@ async function bootGhost({backend = true, frontend = true, server = true} = {}) debug('Begin: Load logging'); logging = require('@tryghost/logging'); metrics = require('@tryghost/metrics'); + flushLogs = require('./shared/flush-logs').flushLogs; bootLogger = new BootLogger(logging, metrics, startTime); debug('End: Load logging'); @@ -659,6 +661,7 @@ async function bootGhost({backend = true, frontend = true, server = true} = {}) // If we pass the env var, kill Ghost if (process.env.GHOST_CI_SHUTDOWN_AFTER_BOOT) { + await flushLogs(); process.exit(0); } @@ -688,10 +691,9 @@ async function bootGhost({backend = true, frontend = true, server = true} = {}) notifyServerReady(serverStartError); ghostServer.shutdown(2); } else { - // Ghost server failed to start, set a timeout to give logging a chance to flush - setTimeout(() => { - process.exit(2); - }, 100); + // Ghost server failed to start, drain the log transports before exiting + await flushLogs(); + process.exit(2); } } } diff --git a/ghost/core/core/server/ghost-server.js b/ghost/core/core/server/ghost-server.js index 0b97b5dc526..c411c9068e9 100644 --- a/ghost/core/core/server/ghost-server.js +++ b/ghost/core/core/server/ghost-server.js @@ -6,6 +6,7 @@ const tpl = require('@tryghost/tpl'); const logging = require('@tryghost/logging'); const metrics = require('@tryghost/metrics'); const notify = require('./notify'); +const {flushLogs} = require('../shared/flush-logs'); const moment = require('moment'); const stoppable = require('stoppable'); @@ -148,14 +149,12 @@ class GhostServer { this.isShuttingDown = true; logging.warn(tpl(messages.ghostIsShuttingDown)); await this.stop(); - setTimeout(() => { - process.exit(code); - }, 100); + await flushLogs(); + process.exit(code); } catch (error) { logging.error(error); - setTimeout(() => { - process.exit(1); - }, 100); + await flushLogs(); + process.exit(1); } } diff --git a/ghost/core/core/shared/flush-logs.ts b/ghost/core/core/shared/flush-logs.ts new file mode 100644 index 00000000000..fbf640792fb --- /dev/null +++ b/ghost/core/core/shared/flush-logs.ts @@ -0,0 +1,16 @@ +import {setTimeout as delay} from 'node:timers/promises'; +import logging from '@tryghost/logging'; + +const DEFAULT_TIMEOUT_MS = 2000; + +/** + * Drain buffered log transports before the process exits, bounded so a stuck + * transport can never block shutdown. + */ +export async function flushLogs(timeoutMs: number = DEFAULT_TIMEOUT_MS): Promise { + // Unreffed so the loser of the race can't hold the event loop open + await Promise.race([ + logging.flush(), + delay(timeoutMs, undefined, {ref: false}) + ]); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 962dd4c9b98..1bdd7f8019e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -151,8 +151,8 @@ catalogs: specifier: 14.3.1 version: 14.3.1 '@tryghost/api-framework': - specifier: 3.3.5 - version: 3.3.5 + specifier: 3.3.9 + version: 3.3.9 '@tryghost/brute-knex': specifier: 3.2.2 version: 3.2.2 @@ -163,11 +163,11 @@ catalogs: specifier: 1.0.11 version: 1.0.11 '@tryghost/debug': - specifier: 2.3.8 - version: 2.3.8 + specifier: 2.3.9 + version: 2.3.9 '@tryghost/domain-events': - specifier: 3.3.9 - version: 3.3.9 + specifier: 3.3.10 + version: 3.3.10 '@tryghost/helpers': specifier: 1.1.106 version: 1.1.106 @@ -175,14 +175,14 @@ catalogs: specifier: 1.5.6 version: 1.5.6 '@tryghost/metrics': - specifier: 3.4.2 - version: 3.4.2 + specifier: 3.5.0 + version: 3.5.0 '@tryghost/mongo-knex': specifier: 0.11.2 version: 0.11.2 '@tryghost/request': - specifier: 4.0.1 - version: 4.0.1 + specifier: 4.0.2 + version: 4.0.2 '@tryghost/string': specifier: 0.3.5 version: 0.3.5 @@ -190,8 +190,8 @@ catalogs: specifier: 1.0.0 version: 1.0.0 '@tryghost/tpl': - specifier: 2.3.8 - version: 2.3.8 + specifier: 2.3.9 + version: 2.3.9 '@types/express': specifier: 4.17.25 version: 4.17.25 @@ -516,8 +516,8 @@ catalogs: overrides: cron-validate: 1.4.5 knex-migrator>knex: 2.4.2 - '@tryghost/errors': 3.3.8 - '@tryghost/logging': 5.3.4 + '@tryghost/errors': 3.3.9 + '@tryghost/logging': 5.4.0 '@tryghost/nql': 0.13.4 '@tryghost/nql-lang': 0.7.0 jackspeak: 4.2.3 @@ -1159,7 +1159,7 @@ importers: version: 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)(react-dom@17.0.2(react@17.0.2))(react@17.0.2) '@tryghost/debug': specifier: 'catalog:' - version: 2.3.8(supports-color@10.2.2) + version: 2.3.9(supports-color@10.2.2) react: specifier: catalog:react17 version: 17.0.2 @@ -1650,7 +1650,7 @@ importers: dependencies: '@tryghost/debug': specifier: 'catalog:' - version: 2.3.8(supports-color@10.2.2) + version: 2.3.9(supports-color@10.2.2) devDependencies: '@doist/react-interpolate': specifier: 2.2.4 @@ -1931,7 +1931,7 @@ importers: dependencies: '@tryghost/debug': specifier: 'catalog:' - version: 2.3.8(supports-color@10.2.2) + version: 2.3.9(supports-color@10.2.2) react: specifier: 'catalog:' version: 18.3.1 @@ -2001,7 +2001,7 @@ importers: dependencies: '@tryghost/debug': specifier: 'catalog:' - version: 2.3.8(supports-color@10.2.2) + version: 2.3.9(supports-color@10.2.2) '@tryghost/i18n': specifier: workspace:* version: link:../../packages/i18n @@ -2142,10 +2142,10 @@ importers: version: 1.61.1 '@tryghost/debug': specifier: 'catalog:' - version: 2.3.8(supports-color@10.2.2) + version: 2.3.9(supports-color@10.2.2) '@tryghost/logging': - specifier: 5.3.4 - version: 5.3.4(supports-color@10.2.2) + specifier: 5.4.0 + version: 5.4.0(supports-color@10.2.2) '@tryghost/test-data': specifier: workspace:* version: link:../packages/testing/test-data @@ -2244,7 +2244,7 @@ importers: version: link:../../packages/admin-api-schema '@tryghost/api-framework': specifier: 'catalog:' - version: 3.3.5(supports-color@10.2.2) + version: 3.3.9(supports-color@10.2.2) '@tryghost/bookshelf-plugins': specifier: 2.3.8 version: 2.3.8(supports-color@10.2.2) @@ -2268,16 +2268,16 @@ importers: version: 2.3.2 '@tryghost/debug': specifier: 'catalog:' - version: 2.3.8(supports-color@10.2.2) + version: 2.3.9(supports-color@10.2.2) '@tryghost/domain-events': specifier: 'catalog:' - version: 3.3.9(supports-color@10.2.2) + version: 3.3.10(supports-color@10.2.2) '@tryghost/email-mock-receiver': specifier: 2.1.0 version: 2.1.0 '@tryghost/errors': - specifier: 3.3.8 - version: 3.3.8 + specifier: 3.3.9 + version: 3.3.9 '@tryghost/helpers': specifier: 'catalog:' version: 1.1.106 @@ -2315,11 +2315,11 @@ importers: specifier: 'catalog:' version: 1.5.6 '@tryghost/logging': - specifier: 5.3.4 - version: 5.3.4(supports-color@10.2.2) + specifier: 5.4.0 + version: 5.4.0(supports-color@10.2.2) '@tryghost/metrics': specifier: 'catalog:' - version: 3.4.2(supports-color@10.2.2) + version: 3.5.0(supports-color@10.2.2) '@tryghost/mongo-knex': specifier: 'catalog:' version: 0.11.2(supports-color@10.2.2) @@ -2361,7 +2361,7 @@ importers: version: 0.1.21 '@tryghost/request': specifier: 'catalog:' - version: 4.0.1 + version: 4.0.2 '@tryghost/root-utils': specifier: 2.3.2 version: 2.3.2 @@ -2376,7 +2376,7 @@ importers: version: 0.3.5 '@tryghost/tpl': specifier: 'catalog:' - version: 2.3.8 + version: 2.3.9 '@tryghost/url-utils': specifier: 5.2.6 version: 5.2.6 @@ -3867,8 +3867,8 @@ importers: packages/adapters/scheduling-base: dependencies: '@tryghost/logging': - specifier: 5.3.4 - version: 5.3.4(supports-color@10.2.2) + specifier: 5.4.0 + version: 5.4.0(supports-color@10.2.2) devDependencies: '@internal/cfg-eslint': specifier: 'workspace:' @@ -3907,8 +3907,8 @@ importers: packages/adapters/sso-base: dependencies: '@tryghost/errors': - specifier: 3.3.8 - version: 3.3.8 + specifier: 3.3.9 + version: 3.3.9 devDependencies: '@internal/cfg-eslint': specifier: workspace:* @@ -3981,8 +3981,8 @@ importers: packages/admin-api-schema: dependencies: '@tryghost/errors': - specifier: 3.3.8 - version: 3.3.8 + specifier: 3.3.9 + version: 3.3.9 ajv: specifier: 'catalog:' version: 8.20.0 @@ -4056,7 +4056,7 @@ importers: dependencies: '@tryghost/debug': specifier: 'catalog:' - version: 2.3.8(supports-color@10.2.2) + version: 2.3.9(supports-color@10.2.2) i18next: specifier: 23.16.8 version: 23.16.8 @@ -9256,8 +9256,8 @@ packages: resolution: {integrity: sha512-VyMVKRrpHTT8PnotUeV8L/mDaMwD5DaAKCFLP73zAqAtvF0FCqky+Ki7BYbFCYQmqFyTe9316Ed5zS70QUR9eg==} engines: {node: '>= 10'} - '@tryghost/api-framework@3.3.5': - resolution: {integrity: sha512-6bDBLtKAHPG82P3ZdY0gT0ZcfpfLbRiK5uwcJhLbr4CfhhH8IWvrecvjkKUskQldL18Tdr2Ct+rfbj2SOYGXog==} + '@tryghost/api-framework@3.3.9': + resolution: {integrity: sha512-G05CO9tbwQnLWGlpC7c0tANQlgdQbJUK9l/j0o4ykfqz6d0W/uZ7avvniU2TKFO24D1pA+POZskZhigqMTCzDA==} '@tryghost/bookshelf-collision@2.3.7': resolution: {integrity: sha512-CIrIW1BhzyBaybKTKF7Ly6jcnx/AOzCmH1rrLlLhxUjW/5tRZt7qHfXgH7r/SbYbzR8+CVuljq1jGA4vqjM9SA==} @@ -9296,8 +9296,8 @@ packages: resolution: {integrity: sha512-wdhqZV5klysUO+0ZthzxkaWiSxqKKoJm7rkqby3llt5FHZTU5yiaJ55yraio9knm5MHqfDd+IFspNTKEjAnsIw==} engines: {node: '>=20.20.0'} - '@tryghost/bunyan-rotating-filestream@0.0.14': - resolution: {integrity: sha512-ECV9pTYcuCQjzzGwEpsWYDHC6RCAJXafAjAjKg1FN8J52uP8ekQ4J7dAaXBdd76b3ktfsNo90Ul1eGd+5yeT2w==} + '@tryghost/bunyan-rotating-filestream@0.0.15': + resolution: {integrity: sha512-slSrnLFWWDoNsPsyXDyf5+mG2gULzQb//Ntp60NDEWu40yq1OVEJsbd8Jl+1F34cKQyoFBD1pm+J6srH+qOr8Q==} '@tryghost/color-utils@0.2.20': resolution: {integrity: sha512-0KLCQDX7TbJGKNihs+OB1+3hEvhdP4YXOKpdbqJlUkvEFdY+Gl1FlI4WL8wnn0v6oc1aOjvrKmva5BUT0pWtIg==} @@ -9323,20 +9323,17 @@ packages: '@tryghost/debug@2.3.1': resolution: {integrity: sha512-m35yRGwmmmvHWzs42qJnf0duCvi5Yd456bPa436Gcldv/rxK5YMPehtGDrwjHstJ4K2If0oguXsdHJf3tjgAjw==} - '@tryghost/debug@2.3.5': - resolution: {integrity: sha512-t08spG/+SXLb7x1C2zJ0ATw/sM3CC5ssaeCHm/Ew2zPaIF+4SaFzNMynGRxKjzlIb+bxUoWPMJlW0EQaveOPeg==} - '@tryghost/debug@2.3.7': resolution: {integrity: sha512-qS8QrBLNdDTu1DBgx/RwnNeF/7abDvT1iRZ+bDJ95TIj6gKcHbQN2gQs0rLhZLsnxQs2aC4arb0QjHRy8JyazA==} - '@tryghost/debug@2.3.8': - resolution: {integrity: sha512-7c9/8cCRjmt0AuFjEt+5ALHzoffa8QdqSYaTRXn0Y5PcflStLvv19jF7yUTdvo4JFn4gTvpNosg1QxEohfFgmQ==} + '@tryghost/debug@2.3.9': + resolution: {integrity: sha512-nWwKuyJIzCzBFbTMh2bcGAocqSeMbjPifb56AqqZybt0KErbuqrw+RfYox92UXTCQR/TLMhPmBq9ccHD0OG33w==} - '@tryghost/domain-events@3.3.9': - resolution: {integrity: sha512-Lw8//qojB7ljRdEXvAK8Jtv0RHKpzZLNdh6kulBtxDKNE67ei8ZoTT/yLR8aKmGzdQ6PJnsOalYPvVJeaXJrqg==} + '@tryghost/domain-events@3.3.10': + resolution: {integrity: sha512-Kh23N3SOCNpp0ozkgAmOt6qMKMwHjXPeXOvAhHAsA8ybaO0t+uEH1KJUn5xjCwptTu9JjgJ5YD3aiqovB8gdxg==} - '@tryghost/elasticsearch@5.4.5': - resolution: {integrity: sha512-8sjw9VQhfjUzIH2Vu1Gl/a4CP+gsohhc9Qw9tIkQFeMH6LMEKDzYik3gkOlaWj+MyT5BUkizU2tKPdlm1jTEKQ==} + '@tryghost/elasticsearch@5.4.6': + resolution: {integrity: sha512-7bE2Nm4XfXhlOiVSxVV4FrKyHMxbd/NxNHnowe1vXyKzQp6ExWVNdrgPFnUUYoUyT1THz20HMY7SB0bUje1ZLg==} '@tryghost/email-mock-receiver@2.1.0': resolution: {integrity: sha512-bgEVM5eRnN53rnqWJ8ZkjuGCzMwwt5Ch29TTXRp3qSGTJruUW44DtN/mjB36DDti0hq4unJV9+D1qIIKxdbong==} @@ -9345,8 +9342,8 @@ packages: resolution: {integrity: sha512-py/Fi3jbr+UiUped74m2VRhNEJQrFSgPEhQu/FSHkPjUPWruIokwJoO4TDedklGcMJ+0RKr3YiigcuMFxXjf7A==} engines: {node: 12.* || >= 14.*} - '@tryghost/errors@3.3.8': - resolution: {integrity: sha512-11AaQCyPC8w8Fh9zmAfXIUCDO8q6i0tHsGl65XnZCzqOd5G6HOXgf+rt+cjhLhslBxdcp9qX1nForZ0jrct31Q==} + '@tryghost/errors@3.3.9': + resolution: {integrity: sha512-hcrIXQoWZV0Th8DwndC8bA5BcCcgd7Zw0anAYv6ys/SsKlDMmpzZplECC3pEL8HKI/Cz05/TXkQJiBNrTK11/Q==} '@tryghost/express-test@2.1.0': resolution: {integrity: sha512-gY2AeFzBLvDtDCdc5rE54crDeEC9TzVboViUTmP1Vwbt35BA3O177Ox2j6vb/LQ9HCQ1tp3DOa0hbNYZFaaJAQ==} @@ -9362,8 +9359,8 @@ packages: '@tryghost/http-cache-utils@0.1.25': resolution: {integrity: sha512-OJAD1ESvV+F81w6IOTKCX0JLGIiJqNn87HVFMskpUC3IUGpqcDP1pCM79d72khxLWy5oRQDYu8xIqicHJ9ST1Q==} - '@tryghost/http-stream@2.3.9': - resolution: {integrity: sha512-ffxWIJ8bGBZS2d/NkFfOgqWEkd1LoqT9AIEqdBTvlDm3jDMqu585IbIluTddrnZIIGgKJNi5+lyFfjBj5z1CAg==} + '@tryghost/http-stream@2.3.10': + resolution: {integrity: sha512-qvod+MltdcQhcfii1B+VpNsJD9pi/1QYqdLckH0Z5DnFD3euDgB7cys+dr/2XS0TgxQc9Gj7cHZDl0OOtKe2RQ==} '@tryghost/image-transform@1.4.17': resolution: {integrity: sha512-+WipAbQ6gTOB9hbhmTDHW7tzaB1lPIRszoN+z1qV+vlI+cR/4qO70Ux+HkSYT9gt4WpxRZJZCBCk3+x4Nm0LhQ==} @@ -9377,11 +9374,11 @@ packages: '@tryghost/limit-service@1.5.6': resolution: {integrity: sha512-wR+Hm2v5k2FjOUhLhfPZ0p0acCfaTQoSEhuw96uw8c15CLZtm/HKsLnxA5d3AXwYmjRo5gkYskP8UYxrCKNcoQ==} - '@tryghost/logging@5.3.4': - resolution: {integrity: sha512-7ZRknXmKLFK+WW2xwQ1UJ392bh/yDaqyMCJCPvCI//UwVUkhsRfOAVYCYaMn5VWYDLYEflk1fOL8aYhatmeVEQ==} + '@tryghost/logging@5.4.0': + resolution: {integrity: sha512-H0jWmJBJ0EEwBkq+SErWt4N/LQwwdYCfQcUT3a5RZbF5bcZTRcWnGfSw5aPBIphra9BaOHCiCYeQbyg5wcJkBw==} - '@tryghost/metrics@3.4.2': - resolution: {integrity: sha512-20ldmzt6yHK7835yuU/PJDfZXaHlLl9Y3QfKM2tHUXJH0t6VdTEx1CSEnMjdSgxKG2z5+5uK84FoFD67toUVrQ==} + '@tryghost/metrics@3.5.0': + resolution: {integrity: sha512-fBIKCodafcG23Eq5rjhmixI+CSzVZ555jNqj7tdDeCXtfVA9ws3X3H3dNo7jgxgZs2SbC7ElYbhBSj4dHX8RKw==} '@tryghost/mongo-knex@0.11.2': resolution: {integrity: sha512-0l7L/J609p+12+rA15Muya1JoIeSfBxIUZRB2FfROtJ2U+FTEOzZ/c0HgxB8eAArJMW2VXECmLCAbNUwygRzfg==} @@ -9413,8 +9410,8 @@ packages: '@tryghost/pretty-cli@3.3.2': resolution: {integrity: sha512-JOWoEwrdF+flxZBWq+ikww5BRvU95jfUzUnx25pkrSNJEdTYWzJj3bsTGt7LravWCN41M9y4+XvYwEp6cfawpg==} - '@tryghost/pretty-stream@2.3.8': - resolution: {integrity: sha512-yd95fnXZ9ssS4nfMzzaPCnD+v2GR0c2cQonX1X9+njH3gBln+nyEUlOhQZWGArqq393YeITeC0B2ctPNBFuQRA==} + '@tryghost/pretty-stream@2.3.9': + resolution: {integrity: sha512-5bq3xDBmLm1auQzsuoIxxwimuQFSnTia+rV9kQ6HmId4GRCbZrqcQOx7y9MVe2kwAUkdHqQrkSZkxoNebCiaRQ==} '@tryghost/prometheus-metrics@1.0.8': resolution: {integrity: sha512-E1LRRwLgiWIN/P20uHgpTK+JVYYQ3+BarKCBszB6qmK5IBANJvQHI3LQV0wDWefVwFyl5FI6AqxLpOOpquvahA==} @@ -9425,15 +9422,15 @@ packages: '@tryghost/promise@2.3.2': resolution: {integrity: sha512-WAwvjo85D5DVHfQ+YhBAb8Vq8h3NS/tfn/gVzrIy/L6TztvSxxPG2t9hU/XVsPbFguEV8lqhInowHdJehW/f0w==} - '@tryghost/promise@2.3.5': - resolution: {integrity: sha512-PsWt9XndId4iduE3HGMRWXM2bqicUgCS/kNY7mko+nBl/QRVc4ejBkqW9w5hDtIpYvW61kp2wfyrbGAPCA5ksA==} + '@tryghost/promise@2.3.9': + resolution: {integrity: sha512-O/d4kTvvHrD/T9Czfhl057uPCJVQulfcQl/XsFHrWOhXewZUc6rsmqTFo6zz9Iv5NRye+SicsUya7BSJFSgKCQ==} '@tryghost/referrer-parser@0.1.21': resolution: {integrity: sha512-Uz7bj8IadLah1j9GgieGQ0VGytdOG6mIlYdbpNu1r8/o9pjR2IS4bt88tvwpGSPNb47P9NrGntGn+Le1W/90jg==} engines: {node: '>=16.0.0'} - '@tryghost/request@4.0.1': - resolution: {integrity: sha512-AZdig94vsNuG6PORGe5Ygm5+tuAOQikND7znHiwMASWxP34Kt1pd8QNuKvCyGDtlD7hwanX8/Pc4IP7RCtganA==} + '@tryghost/request@4.0.2': + resolution: {integrity: sha512-43mgzdOSd09u7Ek9nIjgk1MIkYteZ5FmiS4UYc+H82G8ci5RnVvkW2qplaNYK8Rpih36KxxjNIb7SglX6IL+kg==} '@tryghost/root-utils@0.3.38': resolution: {integrity: sha512-ARn8wC6qv867lCr7BZ+IS8S/88K5go0j6HgQFhP27rKje4b40PsxH/P3rO4Ez2NzF/Do8ywFrTWHoLCSsCazXQ==} @@ -9444,14 +9441,11 @@ packages: '@tryghost/root-utils@2.3.2': resolution: {integrity: sha512-tUyMS4xJUceE62JjZIjWAXCeoVlbNHWF2dIy6xJkaYgeHw4oZ5YoKMu9Mzqa7TPHIapU+BxwdWV0HvMj9dZGlA==} - '@tryghost/root-utils@2.3.5': - resolution: {integrity: sha512-7OnoEPEAAaT9LFgR0WNxHUeehC1VSits1k3KuW5FpqitE6NznieiSK9Cpo8v0489Xq7d0Xbkc9qsDytWCCRGCg==} - '@tryghost/root-utils@2.3.7': resolution: {integrity: sha512-8HR0W95it+s+ERkRI3syWHDLyYcinBFexpVlmLBhMMf8yEcL7r6tio6H3sUAj09L/YE8Kk5uGhvjAZAT3TU77Q==} - '@tryghost/root-utils@2.3.8': - resolution: {integrity: sha512-n12HsmW0mms6hlfYYSOcsJ9um6jifBvBFXJak2yPBySSmoTZpCgPezG9JlTXtPjG+4VmfvKaOE7NxlbEJyegiw==} + '@tryghost/root-utils@2.3.9': + resolution: {integrity: sha512-jhnWp6PVubyPoc3BDJSeQ7rX7kC73X7Asp8gv8mCosae9/ciBST95SND/Kblh8FI5yGwuENN8b6Z2w+YBl13FA==} '@tryghost/security@1.0.6': resolution: {integrity: sha512-h4FiUK4ndHezlXeuRzfwTZrX/a8ZdCS/FRqmZWCHcMUiBH6lewHv8eIjsPemN6e6Gn9jtsWZsKnuYM2mD0meSQ==} @@ -9478,14 +9472,11 @@ packages: '@tryghost/tpl@2.3.1': resolution: {integrity: sha512-qqa2SvhnBVKFYN+G4hfj2cYZuZXINBDyJ9cTzNBtev/FRNUKniyGarPKAkyb6ZBtvp3Nqlmyvrqe+SFoqHcfrQ==} - '@tryghost/tpl@2.3.5': - resolution: {integrity: sha512-wexPVuAfF+k/Ysy1uTLTHPznQVsKe5kImlvhKd3IQU1eA4ZYGwymtD7GaSvJxHvyaS6zRB8FXzbgXl2dpAUyVg==} - '@tryghost/tpl@2.3.7': resolution: {integrity: sha512-TIDQF9tj4MaQKrIxNB8CxpElShAwLfyByozggIBnuzIVkGudX07gy/o9oWGRYslRselrvdByf+NpYonQ9j3Y0Q==} - '@tryghost/tpl@2.3.8': - resolution: {integrity: sha512-WkStVunfpAzfZsGx9Hy4hi+CF5ZYNzwBkxY6+JAeQs8O4npWfVpMLQCEP+CaHpFkuJRhVVtH63Y4KbRw97cH6g==} + '@tryghost/tpl@2.3.9': + resolution: {integrity: sha512-55bUKq9vG9YvJNtrJKptv2zZoLYXxGUPnX7q6mV6cuZEwxZaWY0uukcs2l3oCG9hollXj0pKJW4KdulcuYi20w==} '@tryghost/url-utils@5.2.6': resolution: {integrity: sha512-3TQRcseZW/rq3UsSMP94n6WgKYZJqufreNdv9wJT5P4Fm5bNQ04rEHvL8i1AggXWa2kUPmwt2ed7KzNY6xx7zA==} @@ -9496,17 +9487,14 @@ packages: '@tryghost/validator@0.2.22': resolution: {integrity: sha512-dmobNVEKXMi3K4OdAXLBFwa78hVgy8cYvCPJgfV4h3NtYIyRHqwARr3upT3/ASpVBpKGVBu7XexfSv+tibzsuQ==} - '@tryghost/validator@3.2.5': - resolution: {integrity: sha512-uIpY/QFGjsVgTjT2Hzps4iTxyS3Shjh5g3MpB1QCG6yXm7KWinTfuh8ps2ZNgxKqDjCPEQH6gTWpREugTlLHaw==} - - '@tryghost/validator@3.2.8': - resolution: {integrity: sha512-aw5c77K9vl8/dh8AEqoG7bf3YE0gtqKJSasJeu5j56VbaVHhexAh92FXK5BP8pyOfQwqz1ZREuWsfwiDUl5YGQ==} + '@tryghost/validator@3.2.9': + resolution: {integrity: sha512-dhb8ix2Xd1TVCjpwAJ87MRO/39m1fTl72dfqLbjnmA8r/vgA59SLVUF+BimH3DHcN6vDldwJlDlfJ4nHl5NEaA==} '@tryghost/version@2.3.2': resolution: {integrity: sha512-tBW8gJKLJTUsrGdt/mM1k28Vy4DRTQF7tfZPUf9DQ0KkTgFc+5Z2WipGa50dOaicGysM15imOq7AK7IfXddCHw==} - '@tryghost/version@2.3.8': - resolution: {integrity: sha512-25Aa+J9AvnTLhcu3ph4Klw/nt45h2EUtmfGpSLN6TQykreV20bGvBLzUHlHhnL1kuNpuQaR9qSCvMNU9yYzXmA==} + '@tryghost/version@2.3.9': + resolution: {integrity: sha512-jRBIcJuU/ljnTmpGnWZfzUyj+KY/9NChAD+tlkqI9ZQoSn4/VZFnZn8zGb0cRI245wzIi2YTjZokO0RJutqvTg==} '@tryghost/webhook-mock-receiver@2.1.0': resolution: {integrity: sha512-Cka5SW4igfgbGv33fFyEjkgt1HCA3lNhKCL88ThsDCw3/9ROxKQLcHMB+S7SNcewIDfsS5kI2INHtD+1Tbp/BA==} @@ -28657,20 +28645,20 @@ snapshots: '@tootallnate/once@3.0.1': {} - '@tryghost/api-framework@3.3.5(supports-color@10.2.2)': + '@tryghost/api-framework@3.3.9(supports-color@10.2.2)': dependencies: - '@tryghost/debug': 2.3.5(supports-color@10.2.2) - '@tryghost/errors': 3.3.8 - '@tryghost/promise': 2.3.5 - '@tryghost/tpl': 2.3.5 - '@tryghost/validator': 3.2.5 + '@tryghost/debug': 2.3.9(supports-color@10.2.2) + '@tryghost/errors': 3.3.9 + '@tryghost/promise': 2.3.9 + '@tryghost/tpl': 2.3.9 + '@tryghost/validator': 3.2.9 lodash: 4.18.1 transitivePeerDependencies: - supports-color '@tryghost/bookshelf-collision@2.3.7': dependencies: - '@tryghost/errors': 3.3.8 + '@tryghost/errors': 3.3.9 lodash: 4.18.1 moment-timezone: 0.5.45 @@ -28686,7 +28674,7 @@ snapshots: '@tryghost/bookshelf-filter@2.3.7(supports-color@10.2.2)': dependencies: '@tryghost/debug': 2.3.7(supports-color@10.2.2) - '@tryghost/errors': 3.3.8 + '@tryghost/errors': 3.3.9 '@tryghost/nql': 0.13.4(supports-color@10.2.2) '@tryghost/tpl': 2.3.7 transitivePeerDependencies: @@ -28712,7 +28700,7 @@ snapshots: '@tryghost/bookshelf-pagination@2.4.1': dependencies: - '@tryghost/errors': 3.3.8 + '@tryghost/errors': 3.3.9 '@tryghost/tpl': 2.3.7 lodash: 4.18.1 @@ -28749,7 +28737,7 @@ snapshots: - supports-color - tedious - '@tryghost/bunyan-rotating-filestream@0.0.14': + '@tryghost/bunyan-rotating-filestream@0.0.15': dependencies: long-timeout: 0.1.1 @@ -28785,13 +28773,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@tryghost/debug@2.3.5(supports-color@10.2.2)': - dependencies: - '@tryghost/root-utils': 2.3.5 - debug: 4.4.3(supports-color@10.2.2) - transitivePeerDependencies: - - supports-color - '@tryghost/debug@2.3.7(supports-color@10.2.2)': dependencies: '@tryghost/root-utils': 2.3.7 @@ -28799,24 +28780,24 @@ snapshots: transitivePeerDependencies: - supports-color - '@tryghost/debug@2.3.8(supports-color@10.2.2)': + '@tryghost/debug@2.3.9(supports-color@10.2.2)': dependencies: - '@tryghost/root-utils': 2.3.8 + '@tryghost/root-utils': 2.3.9 debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@tryghost/domain-events@3.3.9(supports-color@10.2.2)': + '@tryghost/domain-events@3.3.10(supports-color@10.2.2)': dependencies: - '@tryghost/logging': 5.3.4(supports-color@10.2.2) + '@tryghost/logging': 5.4.0(supports-color@10.2.2) transitivePeerDependencies: - '@75lb/nature' - supports-color - '@tryghost/elasticsearch@5.4.5(supports-color@10.2.2)': + '@tryghost/elasticsearch@5.4.6(supports-color@10.2.2)': dependencies: '@elastic/elasticsearch': 8.19.2(supports-color@10.2.2) - '@tryghost/debug': 2.3.8(supports-color@10.2.2) + '@tryghost/debug': 2.3.9(supports-color@10.2.2) split2: 4.2.0 transitivePeerDependencies: - '@75lb/nature' @@ -28845,7 +28826,7 @@ snapshots: - webpack-cli - webpack-command - '@tryghost/errors@3.3.8': {} + '@tryghost/errors@3.3.9': {} '@tryghost/express-test@2.1.0(express@4.22.2(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: @@ -28868,14 +28849,14 @@ snapshots: '@tryghost/http-cache-utils@0.1.25': {} - '@tryghost/http-stream@2.3.9': + '@tryghost/http-stream@2.3.10': dependencies: - '@tryghost/errors': 3.3.8 - '@tryghost/request': 4.0.1 + '@tryghost/errors': 3.3.9 + '@tryghost/request': 4.0.2 '@tryghost/image-transform@1.4.17(@types/node@22.20.1)': dependencies: - '@tryghost/errors': 3.3.8 + '@tryghost/errors': 3.3.9 fs-extra: 11.3.6 optionalDependencies: sharp: 0.35.3(@types/node@22.20.1) @@ -28886,7 +28867,7 @@ snapshots: dependencies: '@jest/expect': 30.3.0(supports-color@10.2.2) '@jest/expect-utils': 30.3.0 - '@tryghost/errors': 3.3.8 + '@tryghost/errors': 3.3.9 jest-snapshot: 30.3.0(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -28894,8 +28875,8 @@ snapshots: '@tryghost/job-manager@1.0.9(supports-color@10.2.2)': dependencies: '@breejs/later': 4.2.0 - '@tryghost/errors': 3.3.8 - '@tryghost/logging': 5.3.4(supports-color@10.2.2) + '@tryghost/errors': 3.3.9 + '@tryghost/logging': 5.4.0(supports-color@10.2.2) bree: 6.5.0(supports-color@10.2.2) cron-validate: 1.4.5 fastq: 1.20.1 @@ -28907,17 +28888,17 @@ snapshots: '@tryghost/limit-service@1.5.6': dependencies: - '@tryghost/errors': 3.3.8 + '@tryghost/errors': 3.3.9 lodash: 4.18.1 luxon: 3.7.2 - '@tryghost/logging@5.3.4(supports-color@10.2.2)': + '@tryghost/logging@5.4.0(supports-color@10.2.2)': dependencies: - '@tryghost/bunyan-rotating-filestream': 0.0.14 - '@tryghost/elasticsearch': 5.4.5(supports-color@10.2.2) - '@tryghost/http-stream': 2.3.9 - '@tryghost/pretty-stream': 2.3.8 - '@tryghost/root-utils': 2.3.8 + '@tryghost/bunyan-rotating-filestream': 0.0.15 + '@tryghost/elasticsearch': 5.4.6(supports-color@10.2.2) + '@tryghost/http-stream': 2.3.10 + '@tryghost/pretty-stream': 2.3.9 + '@tryghost/root-utils': 2.3.9 bunyan: 1.8.15 fs-extra: 11.4.0 gelf-stream: 1.1.1 @@ -28927,11 +28908,11 @@ snapshots: - '@75lb/nature' - supports-color - '@tryghost/metrics@3.4.2(supports-color@10.2.2)': + '@tryghost/metrics@3.5.0(supports-color@10.2.2)': dependencies: - '@tryghost/elasticsearch': 5.4.5(supports-color@10.2.2) - '@tryghost/pretty-stream': 2.3.8 - '@tryghost/root-utils': 2.3.8 + '@tryghost/elasticsearch': 5.4.6(supports-color@10.2.2) + '@tryghost/pretty-stream': 2.3.9 + '@tryghost/root-utils': 2.3.9 json-stringify-safe: 5.0.1 transitivePeerDependencies: - '@75lb/nature' @@ -28955,7 +28936,7 @@ snapshots: '@tryghost/mw-error-handler@1.0.13(supports-color@10.2.2)': dependencies: '@tryghost/debug': 0.1.40(supports-color@10.2.2) - '@tryghost/errors': 3.3.8 + '@tryghost/errors': 3.3.9 '@tryghost/http-cache-utils': 0.1.25 '@tryghost/tpl': 0.1.40 lodash: 4.18.1 @@ -28968,7 +28949,7 @@ snapshots: '@tryghost/nodemailer@2.3.1(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: '@aws-sdk/client-sesv2': 3.1073.0 - '@tryghost/errors': 3.3.8 + '@tryghost/errors': 3.3.9 '@tryghost/tpl': 2.3.1 nodemailer: 9.0.1 nodemailer-mailgun-transport: 2.1.5(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2) @@ -29001,7 +28982,7 @@ snapshots: chalk: 5.6.2 sywac: 1.3.0 - '@tryghost/pretty-stream@2.3.8': + '@tryghost/pretty-stream@2.3.9': dependencies: date-format: 4.0.14 lodash: 4.18.1 @@ -29009,7 +28990,7 @@ snapshots: '@tryghost/prometheus-metrics@1.0.8(supports-color@10.2.2)': dependencies: - '@tryghost/logging': 5.3.4(supports-color@10.2.2) + '@tryghost/logging': 5.4.0(supports-color@10.2.2) express: 4.22.1(supports-color@10.2.2) prom-client: 15.1.3 stoppable: 1.1.0 @@ -29021,15 +29002,15 @@ snapshots: '@tryghost/promise@2.3.2': {} - '@tryghost/promise@2.3.5': {} + '@tryghost/promise@2.3.9': {} '@tryghost/referrer-parser@0.1.21': {} - '@tryghost/request@4.0.1': + '@tryghost/request@4.0.2': dependencies: - '@tryghost/errors': 3.3.8 - '@tryghost/validator': 3.2.8 - '@tryghost/version': 2.3.8 + '@tryghost/errors': 3.3.9 + '@tryghost/validator': 3.2.9 + '@tryghost/version': 2.3.9 cacheable-lookup: 7.0.0 got: 15.1.0 lodash: 4.18.1 @@ -29049,17 +29030,12 @@ snapshots: caller: 1.1.0 find-root: 1.1.0 - '@tryghost/root-utils@2.3.5': - dependencies: - caller: 1.1.0 - find-root: 1.1.0 - '@tryghost/root-utils@2.3.7': dependencies: caller: 1.1.0 find-root: 1.1.0 - '@tryghost/root-utils@2.3.8': + '@tryghost/root-utils@2.3.9': dependencies: caller: 1.1.0 find-root: 1.1.0 @@ -29072,7 +29048,7 @@ snapshots: '@tryghost/server@3.1.1(supports-color@10.2.2)': dependencies: '@tryghost/debug': 2.3.1(supports-color@10.2.2) - '@tryghost/logging': 5.3.4(supports-color@10.2.2) + '@tryghost/logging': 5.4.0(supports-color@10.2.2) transitivePeerDependencies: - '@75lb/nature' - supports-color @@ -29095,11 +29071,9 @@ snapshots: '@tryghost/tpl@2.3.1': {} - '@tryghost/tpl@2.3.5': {} - '@tryghost/tpl@2.3.7': {} - '@tryghost/tpl@2.3.8': {} + '@tryghost/tpl@2.3.9': {} '@tryghost/url-utils@5.2.6': dependencies: @@ -29123,24 +29097,16 @@ snapshots: '@tryghost/validator@0.2.22': dependencies: - '@tryghost/errors': 3.3.8 + '@tryghost/errors': 3.3.9 '@tryghost/tpl': 0.1.40 lodash: 4.18.1 moment-timezone: 0.5.45 validator: 7.2.0 - '@tryghost/validator@3.2.5': - dependencies: - '@tryghost/errors': 3.3.8 - '@tryghost/tpl': 2.3.5 - lodash: 4.18.1 - moment-timezone: 0.5.45 - validator: 13.15.35 - - '@tryghost/validator@3.2.8': + '@tryghost/validator@3.2.9': dependencies: - '@tryghost/errors': 3.3.8 - '@tryghost/tpl': 2.3.8 + '@tryghost/errors': 3.3.9 + '@tryghost/tpl': 2.3.9 lodash: 4.18.1 moment-timezone: 0.5.45 validator: 13.15.35 @@ -29150,9 +29116,9 @@ snapshots: '@tryghost/root-utils': 2.3.2 semver: 7.8.5 - '@tryghost/version@2.3.8': + '@tryghost/version@2.3.9': dependencies: - '@tryghost/root-utils': 2.3.8 + '@tryghost/root-utils': 2.3.9 semver: 7.8.5 '@tryghost/webhook-mock-receiver@2.1.0': @@ -29161,7 +29127,7 @@ snapshots: '@tryghost/zip@3.5.0(supports-color@10.2.2)': dependencies: - '@tryghost/errors': 3.3.8 + '@tryghost/errors': 3.3.9 archiver: 8.0.0 extract-zip: 2.0.1(supports-color@10.2.2) transitivePeerDependencies: @@ -29172,7 +29138,7 @@ snapshots: '@tryghost/zip@3.5.1(supports-color@10.2.2)': dependencies: - '@tryghost/errors': 3.3.8 + '@tryghost/errors': 3.3.9 archiver: 8.0.0 extract-zip: 2.0.1(supports-color@10.2.2) transitivePeerDependencies: @@ -31883,7 +31849,7 @@ snapshots: bookshelf-relations@2.8.0(bookshelf@1.2.0(knex@2.4.2(better-sqlite3@12.11.1)(mysql2@3.22.5(@types/node@22.20.1))(supports-color@10.2.2)))(supports-color@10.2.2): dependencies: '@tryghost/debug': 0.1.40(supports-color@10.2.2) - '@tryghost/errors': 3.3.8 + '@tryghost/errors': 3.3.9 bluebird: 3.7.2 bookshelf: 1.2.0(knex@2.4.2(better-sqlite3@12.11.1)(mysql2@3.22.5(@types/node@22.20.1))(supports-color@10.2.2)) lodash: 4.18.1 @@ -37774,8 +37740,8 @@ snapshots: '@sentry/node': 10.65.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(supports-color@10.2.2) '@tryghost/config': 2.3.1 '@tryghost/debug': 2.3.1(supports-color@10.2.2) - '@tryghost/errors': 3.3.8 - '@tryghost/logging': 5.3.4(supports-color@10.2.2) + '@tryghost/errors': 3.3.9 + '@tryghost/logging': 5.4.0(supports-color@10.2.2) '@tryghost/nql': 0.13.4(supports-color@10.2.2) '@tryghost/pretty-cli': 3.3.1 '@tryghost/server': 3.1.1(supports-color@10.2.2) @@ -39634,8 +39600,8 @@ snapshots: knex-migrator@5.4.1(@types/node@22.20.1)(supports-color@10.2.2): dependencies: '@tryghost/database-info': 0.3.35 - '@tryghost/errors': 3.3.8 - '@tryghost/logging': 5.3.4(supports-color@10.2.2) + '@tryghost/errors': 3.3.9 + '@tryghost/logging': 5.4.0(supports-color@10.2.2) '@tryghost/promise': 0.3.20 commander: 5.1.0 compare-ver: 2.0.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 08654163777..956902de3fb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -78,24 +78,24 @@ catalog: '@tanstack/react-virtual': 3.14.9 '@testing-library/jest-dom': 6.9.1 '@testing-library/react': 14.3.1 - '@tryghost/api-framework': 3.3.5 + '@tryghost/api-framework': 3.3.9 '@tryghost/brute-knex': 3.2.2 '@tryghost/color-utils': 0.2.20 '@tryghost/custom-fonts': 1.0.11 - '@tryghost/debug': 2.3.8 - '@tryghost/domain-events': 3.3.9 - '@tryghost/errors': 3.3.8 + '@tryghost/debug': 2.3.9 + '@tryghost/domain-events': 3.3.10 + '@tryghost/errors': 3.3.9 '@tryghost/helpers': 1.1.106 '@tryghost/limit-service': 1.5.6 - '@tryghost/logging': 5.3.4 - '@tryghost/metrics': 3.4.2 + '@tryghost/logging': 5.4.0 + '@tryghost/metrics': 3.5.0 '@tryghost/mongo-knex': 0.11.2 '@tryghost/nql': 0.13.4 '@tryghost/nql-lang': 0.7.0 - '@tryghost/request': 4.0.1 + '@tryghost/request': 4.0.2 '@tryghost/string': 0.3.5 '@tryghost/timezone-data': 1.0.0 - '@tryghost/tpl': 2.3.8 + '@tryghost/tpl': 2.3.9 '@types/express': 4.17.25 '@types/html-minifier': ^4.0.6 '@types/lodash': 4.17.25 From 64d2d469c1d456e151a3473a447493238af9174f Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 20 Aug 2026 11:55:23 -0500 Subject: [PATCH 2/9] Updated email analytics spec title for gift analytics (#30163) ref 8be837eacde0496bc60183d34e6e752030f7248e We're checking for gift analytics, but didn't update the spec title. --- .../test/unit/server/services/email-analytics/index.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ghost/core/test/unit/server/services/email-analytics/index.test.ts b/ghost/core/test/unit/server/services/email-analytics/index.test.ts index d2820ef7a31..6a6e0c75e0d 100644 --- a/ghost/core/test/unit/server/services/email-analytics/index.test.ts +++ b/ghost/core/test/unit/server/services/email-analytics/index.test.ts @@ -63,7 +63,7 @@ describe('email analytics service', function () { sinon.restore(); }); - it('initializes newsletter and automation analytics', function () { + it('initializes newsletter, automation, and gift analytics', function () { init(dependencies); sinon.assert.calledOnceWithExactly(newslettersInit, sinon.match({ From 58321677343641e1785ef5b208bcd870171a8d73 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 20 Aug 2026 11:56:02 -0500 Subject: [PATCH 3/9] TypeScriptified automations service entrypoint (#30166) no ref --- ghost/core/core/boot.js | 4 ++-- ghost/core/core/server/services/automations/index.js | 3 --- ghost/core/core/server/services/automations/index.ts | 3 +++ 3 files changed, 5 insertions(+), 5 deletions(-) delete mode 100644 ghost/core/core/server/services/automations/index.js create mode 100644 ghost/core/core/server/services/automations/index.ts diff --git a/ghost/core/core/boot.js b/ghost/core/core/boot.js index 0bcccdd19e5..04ae64ad0ae 100644 --- a/ghost/core/core/boot.js +++ b/ghost/core/core/boot.js @@ -361,7 +361,7 @@ async function initServices({ghostServer, config, prometheusClient}) { const statsService = require('./server/services/stats'); const explorePingService = require('./server/services/explore-ping'); const domainEvents = require('@tryghost/domain-events'); - const automations = require('./server/services/automations'); + const {automationsService} = require('./server/services/automations'); const automationsApi = require('./server/services/automations/automations-api'); const adapterManager = require('./server/services/adapter-manager').default; const {withErrorCapture} = require('./server/adapters/scheduling/error-capture'); @@ -424,7 +424,7 @@ async function initServices({ghostServer, config, prometheusClient}) { internalKeys }), machinePaymentsService.init(), - automations.init({ + automationsService.init({ domainEvents, apiUrl, schedulerAdapter, diff --git a/ghost/core/core/server/services/automations/index.js b/ghost/core/core/server/services/automations/index.js deleted file mode 100644 index efcca5a5c66..00000000000 --- a/ghost/core/core/server/services/automations/index.js +++ /dev/null @@ -1,3 +0,0 @@ -const {AutomationsService} = require('./service'); - -module.exports = new AutomationsService(); diff --git a/ghost/core/core/server/services/automations/index.ts b/ghost/core/core/server/services/automations/index.ts new file mode 100644 index 00000000000..7649b2ac94f --- /dev/null +++ b/ghost/core/core/server/services/automations/index.ts @@ -0,0 +1,3 @@ +import {AutomationsService} from './service'; + +export const automationsService = new AutomationsService(); From 5b18df723d85c470d6b7274b91662e8157ffc81e Mon Sep 17 00:00:00 2001 From: Austin Burdine Date: Thu, 20 Aug 2026 13:42:22 -0400 Subject: [PATCH 4/9] =?UTF-8?q?=F0=9F=90=9B=20Fixed=20cleanup=20behavior?= =?UTF-8?q?=20of=20batch=20email=20sending=20(#30169)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref https://linear.app/ghost/issue/ONC-1954 - add preStopTasks array for tasks that should run prior to server drain - wire up email batch shutdown to preStop - fix error handling in cleanup tasks - prevent batch email send retries after shutdown --- ghost/core/core/server/ghost-server.js | 60 ++++- .../email-service/batch-sending-service.js | 119 +++++++-- .../email-service/email-service-wrapper.js | 4 + .../test/unit/server/ghost-server.test.js | 73 ++++++ .../batch-sending-service.test.js | 239 ++++++++++++++++++ 5 files changed, 471 insertions(+), 24 deletions(-) create mode 100644 ghost/core/test/unit/server/ghost-server.test.js diff --git a/ghost/core/core/server/ghost-server.js b/ghost/core/core/server/ghost-server.js index c411c9068e9..b349e1ea7d7 100644 --- a/ghost/core/core/server/ghost-server.js +++ b/ghost/core/core/server/ghost-server.js @@ -59,6 +59,9 @@ class GhostServer { // Tasks that should be run before the server exits this.cleanupTasks = []; + + // Tasks that should be run at the very start of shutdown + this.preStopTasks = []; } /** @@ -167,6 +170,10 @@ class GhostServer { */ async stop() { try { + // Signal "stop taking new work" before the HTTP server drain, so background + // workers aren't still claiming tasks during it + this._preStop(); + // If we never fully started, there's nothing to stop if (this.httpServer && this.httpServer.listening) { // Time how long it takes to close all in-flight requests @@ -206,6 +213,17 @@ class GhostServer { this.cleanupTasks.push({task, label: label || `cleanup task #${this.cleanupTasks.length + 1}`}); } + /** + * Add a task that runs at the very start of shutdown, before the HTTP server drain. + * Synchronous on purpose: this is the shutdown critical path, so it's for cheap "stop + * claiming new work" signals only. Draining belongs in a cleanup task. + * @param {() => void} task + * @param {string} [label] - name used in shutdown timing logs + */ + registerPreStopTask(task, label) { + this.preStopTasks.push({task, label: label || `pre-stop task #${this.preStopTasks.length + 1}`}); + } + /** * ### Stop Server * Does the work of stopping the server using stoppable @@ -226,16 +244,52 @@ class GhostServer { } } + /** + * Best-effort: a throwing task must not skip the ones after it, nor the drain and + * cleanup that follow. + */ + _preStop() { + for (const {task, label} of this.preStopTasks) { + try { + task(); + } catch (error) { + logging.error(new errors.InternalServerError({ + err: error, + message: `Shutdown: ${label} failed` + })); + } + } + } + + /** + * Runs cleanup tasks concurrently, timing each so a slow one is identifiable. + * Every task runs to completion regardless of its siblings: a rejection escaping the + * map would exit the process mid-drain and orphan email batches in `submitting`. + */ async _cleanup() { - // Wait for all cleanup tasks to finish, timing each so a slow one is identifiable - return Promise.all(this.cleanupTasks.map(async ({task, label}) => { + const failed = []; + + await Promise.all(this.cleanupTasks.map(async ({task, label}) => { const startTime = Date.now(); try { - return await task(); + await task(); + } catch (error) { + failed.push(label); + logging.error(new errors.InternalServerError({ + err: error, + message: `Shutdown: ${label} failed` + })); } finally { logging.info(`Shutdown: ${label} finished in ${Date.now() - startTime}ms`); } })); + + // Surface a non-zero exit, but only once every task has settled + if (failed.length > 0) { + throw new errors.InternalServerError({ + message: `Shutdown: ${failed.length} cleanup task(s) failed: ${failed.join(', ')}` + }); + } } /** diff --git a/ghost/core/core/server/services/email-service/batch-sending-service.js b/ghost/core/core/server/services/email-service/batch-sending-service.js index cee0eda8827..cd4c97b855b 100644 --- a/ghost/core/core/server/services/email-service/batch-sending-service.js +++ b/ghost/core/core/server/services/email-service/batch-sending-service.js @@ -42,6 +42,13 @@ class BatchSendingService { #AFTER_RETRY_CONFIG = {maxRetries: 20, maxTime: 30 * 60 * 1000, sleep: 2000}; #MAILGUN_API_RETRY_CONFIG = {sleep: 10 * 1000, maxRetries: 6}; + // The normal budgets (up to 30 minutes) outlive the container's grace period, so + // retrying just holds a batch in `submitting` until the process is killed, turning a + // recoverable `failed` into an orphan. Give up fast, except on the terminal status + // write, which has to land. + #SHUTDOWN_RETRY_CONFIG = {maxRetries: 0}; + #SHUTDOWN_AFTER_RETRY_CONFIG = {maxRetries: 3, maxTime: 10 * 1000, sleep: 250}; + /** * @param {Object} dependencies * @param {EmailRenderer} dependencies.emailRenderer @@ -112,15 +119,60 @@ class BatchSendingService { } } + // Each config carries the policy to switch to on shutdown. retryDb applies it per + // attempt, so a shutdown starting mid-retry collapses the remaining budget too. #getBeforeRetryConfig(email) { - if (email._retryCutOffTime) { - return {...this.#BEFORE_RETRY_CONFIG, stopAfterDate: email._retryCutOffTime}; + if (email?._retryCutOffTime) { + return {...this.#BEFORE_RETRY_CONFIG, stopAfterDate: email._retryCutOffTime, shutdownConfig: this.#SHUTDOWN_RETRY_CONFIG}; } - return this.#BEFORE_RETRY_CONFIG; + return {...this.#BEFORE_RETRY_CONFIG, shutdownConfig: this.#SHUTDOWN_RETRY_CONFIG}; + } + + #getAfterRetryConfig() { + return {...this.#AFTER_RETRY_CONFIG, shutdownConfig: this.#SHUTDOWN_AFTER_RETRY_CONFIG}; + } + + #getMailgunRetryConfig() { + return {...this.#MAILGUN_API_RETRY_CONFIG, shutdownConfig: this.#SHUTDOWN_RETRY_CONFIG}; + } + + /** + * Normalises retry options for a single attempt: swaps in the shutdown policy once a + * shutdown has started, then pins the deadline implied by maxTime (shortest wins). + */ + #resolveRetryOptions(options) { + let resolved = options; + + if (this.#shuttingDown && resolved.shutdownConfig && !resolved.shutdownPolicyApplied) { + resolved = { + ...resolved, + ...resolved.shutdownConfig, + shutdownConfig: resolved.shutdownConfig, + shutdownPolicyApplied: true + }; + } + + if (resolved.maxTime !== undefined) { + const stopAfterDate = new Date(Date.now() + resolved.maxTime); + if (!resolved.stopAfterDate || stopAfterDate < resolved.stopAfterDate) { + resolved = {...resolved, stopAfterDate}; + } + } + + return resolved; } /** - * Stops the batch workers and waits for any in-flight sends to finish. + * Signals the batch workers to stop claiming new batches. Runs before the HTTP + * server drain, so no batch is claimed in a window we can't finish it in. + * Synchronous — draining is onShutdown's job. Idempotent. + */ + onPreStop() { + this.#shuttingDown = true; + } + + /** + * Waits for any in-flight sends to finish. * Called by the cleanup pipeline when the container is shutting down. Idempotent. */ async onShutdown() { @@ -161,7 +213,7 @@ class BatchSendingService { async () => { return await this.updateStatusLock(this.#models.Email, emailId, 'submitting', ['pending', 'failed']); }, - {...this.#BEFORE_RETRY_CONFIG, description: `updateStatusLock email ${emailId} -> submitting`} + {...this.#getBeforeRetryConfig(), description: `updateStatusLock email ${emailId} -> submitting`} ); if (!email) { logging.error(`Tried sending email that is not pending or failed ${emailId}`); @@ -185,9 +237,12 @@ class BatchSendingService { submitted_at: new Date(), error: null }, {patch: true, autoRefresh: false}); - }, {...this.#AFTER_RETRY_CONFIG, description: `email ${emailId} -> submitted`}); + }, {...this.#getAfterRetryConfig(), description: `email ${emailId} -> submitted`}); } catch (e) { - if (e && e.code === SHUTDOWN_CODE) { + // Any failure while shutting down counts as interrupted, not failed: + // collapsed budgets surface transient errors as hard failures, and `failed` + // drops the email out of the boot resume scan. + if ((e && e.code === SHUTDOWN_CODE) || this.#shuttingDown) { logging.info(`Email ${email.id} send stopped because the container is shutting down — leaving status=submitting so it can resume on next boot`); return; } @@ -209,7 +264,7 @@ class BatchSendingService { status: 'failed', error: e.message || 'Something went wrong while sending the email' }, {patch: true, autoRefresh: false}); - }, {...this.#AFTER_RETRY_CONFIG, description: `email ${emailId} -> failed`}); + }, {...this.#getAfterRetryConfig(), description: `email ${emailId} -> failed`}); } } @@ -509,8 +564,12 @@ class BatchSendingService { } }; - // Run maximum MAX_SENDING_CONCURRENCY at the same time - await Promise.all(new Array(MAX_SENDING_CONCURRENCY).fill(0).map(() => runWorker())); + // Run maximum MAX_SENDING_CONCURRENCY at the same time. + // allSettled so one worker throwing doesn't detach the others: the drain must not + // resolve while a sibling's terminal status write is still in flight. + const workerResults = await Promise.allSettled( + new Array(MAX_SENDING_CONCURRENCY).fill(0).map(() => runWorker()) + ); logging.info(`Email ${email.id} send done: ${succeededCount}/${batches.length} batches succeeded, ${queue.length} unstarted`); @@ -521,6 +580,11 @@ class BatchSendingService { }); } + const failedWorker = workerResults.find(result => result.status === 'rejected'); + if (failedWorker) { + throw failedWorker.reason; + } + if (succeededCount < batches.length) { if (succeededCount > 0) { throw new errors.EmailError({ @@ -604,7 +668,7 @@ class BatchSendingService { deliveryTime, emailBodyCache }); - }, {...this.#MAILGUN_API_RETRY_CONFIG, description: `Sending email batch ${originalBatch.id} ${deliveryTime ? `with delivery time ${deliveryTime}` : ''}`}); + }, {...this.#getMailgunRetryConfig(), description: `Sending email batch ${originalBatch.id} ${deliveryTime ? `with delivery time ${deliveryTime}` : ''}`}); succeeded = true; await this.retryDb( @@ -618,7 +682,7 @@ class BatchSendingService { error_data: null }, {patch: true, require: false, autoRefresh: false}); }, - {...this.#AFTER_RETRY_CONFIG, description: `save batch ${originalBatch.id} -> submitted`} + {...this.#getAfterRetryConfig(), description: `save batch ${originalBatch.id} -> submitted`} ); } catch (err) { if (err.code && err.code === 'BULK_EMAIL_SEND_FAILED') { @@ -653,8 +717,13 @@ class BatchSendingService { error_data: err.errorDetails ?? null }, {patch: true, require: false, autoRefresh: false}); }, - {...this.#AFTER_RETRY_CONFIG, description: `save batch ${originalBatch.id} -> failed`} + {...this.#getAfterRetryConfig(), description: `save batch ${originalBatch.id} -> failed`} ); + } else if (this.#shuttingDown) { + // Sent, but the `submitted` write didn't land in the collapsed budget. + // Returning success would mark the email submitted with this row left in + // `submitting`, which the boot resume scan never looks at. + throw err; } } @@ -665,7 +734,7 @@ class BatchSendingService { .where({batch_id: batch.id}) .save({processed_at: new Date()}, {patch: true, require: false, autoRefresh: false}); }, - {...this.#AFTER_RETRY_CONFIG, description: `save EmailRecipients ${originalBatch.id} processed_at`} + {...this.#getAfterRetryConfig(), description: `save EmailRecipients ${originalBatch.id} processed_at`} ); return succeeded; @@ -744,12 +813,7 @@ class BatchSendingService { * @returns {Promise} */ async retryDb(func, options) { - if (options.maxTime !== undefined) { - const stopAfterDate = new Date(Date.now() + options.maxTime); - if (!options.stopAfterDate || stopAfterDate < options.stopAfterDate) { - options = {...options, stopAfterDate}; - } - } + options = this.#resolveRetryOptions(options); const retryCount = (options.retryCount ?? 0); try { @@ -765,6 +829,10 @@ class BatchSendingService { return response; } catch (e) { + // Shutdown may have started while this attempt was pending — re-resolve so + // the collapsed budget decides whether we retry at all + options = this.#resolveRetryOptions(options); + const sleep = (options.sleep ?? 0); if (retryCount >= options.maxRetries || (options.stopAfterDate && (new Date(Date.now() + sleep)) > options.stopAfterDate)) { if (retryCount > 0) { @@ -794,7 +862,16 @@ class BatchSendingService { setTimeout(resolve, sleep); }); } - return await this.retryDb(func, {...options, retryCount: retryCount + 1, sleep: sleep * 2}); + + // Budget is only checked after a failure, so recursing always spends another + // attempt first. Re-check here, or a shutdown that began during the backoff + // gets one more go — a fresh Mailgun send well into a shutdown. + const nextOptions = this.#resolveRetryOptions({...options, retryCount: retryCount + 1, sleep: sleep * 2}); + if (nextOptions.retryCount > nextOptions.maxRetries || (nextOptions.stopAfterDate && new Date() > nextOptions.stopAfterDate)) { + throw e; + } + + return await this.retryDb(func, nextOptions); } } diff --git a/ghost/core/core/server/services/email-service/email-service-wrapper.js b/ghost/core/core/server/services/email-service/email-service-wrapper.js index e1fc5065028..d0ee2f80da6 100644 --- a/ghost/core/core/server/services/email-service/email-service-wrapper.js +++ b/ghost/core/core/server/services/email-service/email-service-wrapper.js @@ -136,6 +136,10 @@ class EmailServiceWrapper { }); if (ghostServer) { + // Two phases: stop claiming batches immediately, drain in-flight ones later. + // Draining alone would leave workers claiming new batches for the whole HTTP + // server drain, each a fresh orphan candidate. + ghostServer.registerPreStopTask(() => batchSendingService.onPreStop(), 'Email batch sending (stop claiming)'); ghostServer.registerCleanupTask(() => batchSendingService.onShutdown(), 'Email batch sending'); } diff --git a/ghost/core/test/unit/server/ghost-server.test.js b/ghost/core/test/unit/server/ghost-server.test.js new file mode 100644 index 00000000000..cca06dde975 --- /dev/null +++ b/ghost/core/test/unit/server/ghost-server.test.js @@ -0,0 +1,73 @@ +const assert = require('node:assert/strict'); +const sinon = require('sinon'); +const logging = require('@tryghost/logging'); + +const GhostServer = require('../../../core/server/ghost-server'); + +describe('GhostServer', function () { + let server; + + beforeEach(function () { + sinon.stub(logging, 'info'); + sinon.stub(logging, 'warn'); + sinon.stub(logging, 'error'); + server = new GhostServer({url: 'http://localhost:2368', env: 'testing', serverConfig: {}}); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe('_cleanup', function () { + it('runs every task even when one rejects', async function () { + const slow = sinon.stub().resolves(); + server.registerCleanupTask(() => Promise.reject(new Error('nope')), 'failing task'); + server.registerCleanupTask(slow, 'Email batch sending'); + + // Rejects so the exit code still reflects the failure... + await assert.rejects(server._cleanup(), /1 cleanup task\(s\) failed: failing task/); + + // ...but only after the sibling ran to completion. Rejecting early would + // exit the process mid-drain and orphan email batches in `submitting`. + sinon.assert.calledOnce(slow); + }); + + it('resolves when every task succeeds', async function () { + server.registerCleanupTask(() => Promise.resolve(), 'a'); + server.registerCleanupTask(() => Promise.resolve(), 'b'); + + await server._cleanup(); + }); + }); + + describe('_preStop', function () { + it('runs every task before the server drain, isolating failures', function () { + const second = sinon.stub(); + server.registerPreStopTask(() => { + throw new Error('nope'); + }, 'failing signal'); + server.registerPreStopTask(second, 'Email batch sending (stop claiming)'); + + // Must not throw — a bad signal cannot skip the drain and cleanup that follow. + server._preStop(); + + sinon.assert.calledOnce(second); + }); + + it('has run before the HTTP server is stopped', async function () { + const order = []; + server.registerPreStopTask(() => order.push('pre-stop'), 'stop claiming'); + server.registerCleanupTask(async () => { + order.push('cleanup'); + }, 'drain'); + sinon.stub(server, '_stopServer').callsFake(async () => { + order.push('stop-server'); + }); + server.httpServer = {listening: true}; + + await server.stop(); + + assert.deepEqual(order, ['pre-stop', 'stop-server', 'cleanup']); + }); + }); +}); diff --git a/ghost/core/test/unit/server/services/email-service/batch-sending-service.test.js b/ghost/core/test/unit/server/services/email-service/batch-sending-service.test.js index 1817c5678e8..b9ab10a0f65 100644 --- a/ghost/core/test/unit/server/services/email-service/batch-sending-service.test.js +++ b/ghost/core/test/unit/server/services/email-service/batch-sending-service.test.js @@ -1254,6 +1254,44 @@ describe('Batch Sending Service', function () { assert.equal(members.length, 2); }); + it('Propagates a failed submitted-status write during shutdown', async function () { + const sendingService = { + send: sinon.stub().resolves({id: 'providerid@example.com'}), + getMaximumRecipients: () => 5 + }; + const service = new BatchSendingService({ + models: {EmailRecipient}, + sendingService + }); + const batch = createModel({status: 'pending', member_segment: null}); + const save = sinon.stub(batch, 'save').rejects(new Error('deadlock')); + sinon.stub(service, 'updateStatusLock').resolves(batch); + + service.onPreStop(); + + const clock = sinon.useFakeTimers(); + const promise = service.sendBatch({ + email: createModel({}), + batch: createModel({}), + post: createModel({}), + newsletter: createModel({}) + }); + // Drain the collapsed shutdown retry budget without waiting in real time + const assertion = assert.rejects(promise); + await clock.tickAsync(30000); + await assertion; + clock.restore(); + + // Mailgun accepted the batch, so reporting success would let the parent email + // be saved as `submitted` with this row left behind in `submitting` + sinon.assert.calledOnce(sendingService.send); + assert.ok(save.callCount > 0); + assert.ok( + save.getCalls().every(call => call.args[0].status === 'submitted'), + 'batch must never be downgraded to failed once Mailgun has accepted it' + ); + }); + it('Does send with a deliverytime', async function () { const EmailBatch = createModelClass({ findOne: { @@ -2134,5 +2172,206 @@ describe('Batch Sending Service', function () { // logging.info was stubbed in the outer beforeEach — confirm the shutdown breadcrumb was emitted. sinon.assert.calledWithMatch(logging.info, /send stopped because the container is shutting down/); }); + + it('emailJob leaves email in submitting status for any error once shutting down', async function () { + const Email = createModelClass({ + findOne: { + status: 'pending' + } + }); + const service = new BatchSendingService({ + models: {Email} + }); + let afterEmailModel; + // A plain transient error, not SHUTDOWN_CODE — the kind the collapsed + // shutdown retry budgets stop absorbing. + sinon.stub(service, 'sendEmail').callsFake((email) => { + afterEmailModel = email; + return Promise.reject(new Error('connection lost')); + }); + service.onPreStop(); + + await service.emailJob({emailId: '123'}); + + assert.equal(afterEmailModel.get('status'), 'submitting', 'Email must stay submitting so the boot resume scan picks it up'); + assert.equal(afterEmailModel.get('error'), undefined); + }); + + it('onPreStop stops workers claiming new batches without waiting for in-flight ones', async function () { + const clock = sinon.useFakeTimers(new Date()); + const service = new BatchSendingService({ + sendingService: { + getTargetDeliveryWindow() { + return 0; + } + } + }); + const sendBatch = sinon.stub(service, 'sendBatch').callsFake(async () => { + await simulateSleep(5, clock); + return true; + }); + // 6 batches, 2 workers: the first 2 are claimed immediately, 4 stay queued. + const batches = new Array(6).fill(0).map(() => createModel({})); + const sendPromise = service.sendBatches({ + email: createModel({}), + batches, + post: createModel({}), + newsletter: createModel({}) + }); + + // Synchronous — must not await the in-flight batches. + service.onPreStop(); + + await assert.rejects( + sendPromise, + err => err.code === BatchSendingService.SHUTDOWN_CODE + ); + sinon.assert.callCount(sendBatch, 2); + clock.restore(); + }); + + it('collapses pre-send retries once shutting down', async function () { + const buildService = () => { + const service = new BatchSendingService({ + models: {EmailBatch: createModelClass({findOne: {status: 'pending'}})}, + BEFORE_RETRY_CONFIG: {maxRetries: 3, sleep: 0} + }); + const updateStatusLock = sinon.stub(service, 'updateStatusLock').rejects(new Error('deadlock')); + return {service, updateStatusLock}; + }; + const args = () => ({ + email: createModel({}), + batch: createModel({status: 'pending'}), + post: createModel({}), + newsletter: createModel({}) + }); + + // Baseline: the configured budget is spent trying to claim the batch. + const baseline = buildService(); + await assert.rejects(baseline.service.sendBatch(args())); + sinon.assert.callCount(baseline.updateStatusLock, 4); + + // Shutting down: give up on the first failure instead of holding the batch + // in `submitting` until the container is killed. + const shuttingDown = buildService(); + shuttingDown.service.onPreStop(); + await assert.rejects(shuttingDown.service.sendBatch(args())); + sinon.assert.callCount(shuttingDown.updateStatusLock, 1); + }); + + it('collapses retries for a shutdown that starts while an attempt is pending', async function () { + const service = new BatchSendingService({}); + const func = sinon.stub().callsFake(async () => { + // Shutdown begins mid-attempt, after the caller already picked its policy + service.onPreStop(); + throw new Error('deadlock'); + }); + + await assert.rejects(service.retryDb(func, { + maxRetries: 10, + maxTime: 10 * 60 * 1000, + sleep: 0, + shutdownConfig: {maxRetries: 0}, + description: 'mid-flight shutdown' + })); + + // The captured 10-retry budget must not survive the shutdown + sinon.assert.callCount(func, 1); + }); + + it('waits for every worker to settle when one throws', async function () { + const service = new BatchSendingService({ + sendingService: { + getTargetDeliveryWindow() { + return 0; + } + } + }); + + let slowBatchSettled = false; + let releaseSlowBatch; + const slowBatch = new Promise((resolve) => { + releaseSlowBatch = resolve; + }); + + sinon.stub(service, 'sendBatch').callsFake(async ({batch}) => { + if (batch.get('member_segment') === 'throws') { + throw new Error('lock timeout'); + } + await slowBatch; + slowBatchSettled = true; + return true; + }); + + let outcome = null; + const tracked = service.sendBatches({ + email: createModel({}), + batches: [createModel({member_segment: 'throws'}), createModel({member_segment: 'slow'})], + post: createModel({}), + newsletter: createModel({}) + }).then( + () => { + outcome = 'fulfilled'; + }, + (err) => { + outcome = err; + } + ); + + // Let every pending microtask and timer callback run. The throwing worker has + // rejected by now; with Promise.all that alone settled sendBatches, leaving + // the sibling's status write in flight while the drain reported itself done. + await new Promise((resolve) => { + setImmediate(resolve); + }); + assert.equal(outcome, null, 'sendBatches must not settle while a worker is still in flight'); + assert.equal(slowBatchSettled, false); + + releaseSlowBatch(); + await tracked; + + assert.equal(slowBatchSettled, true); + assert.match(outcome.message, /lock timeout/, 'the worker failure must still surface'); + }); + + it('does not retry when shutdown starts during the backoff sleep', async function () { + const service = new BatchSendingService({}); + const func = sinon.stub().rejects(new Error('mailgun 500')); + + const promise = service.retryDb(func, { + maxRetries: 6, + sleep: 20, + shutdownConfig: {maxRetries: 0}, + description: 'shutdown during backoff' + }); + + // First attempt has already failed and retryDb is now sleeping before the + // next one — the window where a Mailgun send would otherwise fire again. + await new Promise((resolve) => { + setTimeout(resolve, 10); + }); + service.onPreStop(); + + await assert.rejects(promise); + sinon.assert.callCount(func, 1); + }); + + it('keeps a short retry budget for terminal status writes during shutdown', async function () { + const service = new BatchSendingService({}); + const func = sinon.stub().rejects(new Error('deadlock')); + service.onPreStop(); + + await assert.rejects(service.retryDb(func, { + maxRetries: 20, + maxTime: 30 * 60 * 1000, + sleep: 0, + shutdownConfig: {maxRetries: 3, maxTime: 10 * 1000, sleep: 0}, + description: 'terminal write' + })); + + // Collapsed to the shutdown budget, but not to zero — this write is the one + // that has to land, or the batch orphans in `submitting` + sinon.assert.callCount(func, 4); + }); }); }); From 77a3a7b047d0a6e830f15b3e598f47cc6a696abb Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Thu, 20 Aug 2026 15:50:47 -0400 Subject: [PATCH 5/9] Removed unused post analytics view (#30135) no refs ## Evidence that the component is unused `PostAnalyticsView` is not exported by the private Admin package, and repository-wide searches for both its symbol and module path find no importer. Admin-only Knip reported `apps/admin/src/posts/analytics/components/post-analytics-view.tsx` as an unused file before this removal and stopped reporting it afterward. The Admin build uses explicit routes and entry points; no dynamic import or glob references this path. ## History and intentionality Documented facts: - https://github.com/TryGhost/Ghost/commit/ad522841bd987a65bd409511d60852c4595e622b introduced the component as the loading/empty/content wrapper in the first React post-analytics skeleton. https://github.com/TryGhost/Ghost/pull/22978 explains that the work began a better breakdown of post statistics, initially focused on web analytics for one post. - https://github.com/TryGhost/Ghost/commit/54d993bb46f0635b28f378d464b08e1367f4a7ec replaced that static wrapper in the main page with real post and traffic data. https://github.com/TryGhost/Ghost/pull/22986 says this made the previously static skeleton usable for beta users. - https://github.com/TryGhost/Ghost/commit/26de9edcc4c2511c0143e6f3b5fb1d61f7b62380 later reused the component only as the loading screen for a duplicated `withFeatureFlag` wrapper. https://github.com/TryGhost/Ghost/pull/23270 documents that the wrapper prevented the Newsletter tab from redirecting before the alpha setting had loaded, and explicitly called the duplication temporary. - https://github.com/TryGhost/Ghost/pull/23029 documents the rollout model: `trafficAnalytics` selected beta users, while `trafficAnalyticsAlpha` selected features still under development for that beta. - https://github.com/TryGhost/Ghost/commit/f0a33b86c8d4be98cdf2c8806a84e7ae8e9c761a intentionally moved Newsletter analytics out of alpha. Its diff replaced the protected route with the direct `Newsletter` route and made the sidebar links unconditional; https://github.com/TryGhost/Ghost/pull/23294 states that purpose directly. Production therefore stopped reaching the wrapper, and consequently `PostAnalyticsView`, on May 12, 2025. Given the documented flag semantics, this was an intentional promotion to the analytics beta rather than an accidental bypass. - https://github.com/TryGhost/Ghost/commit/fb0790bef3740e112ffe2c24aa9ee5706248f1c6 later restored the alpha flag for other design work but did not restore Newsletter's route guard, corroborating that Newsletter's promotion was deliberate. - https://github.com/TryGhost/Ghost/commit/566f8a94e7dfc3fd67bb32f0e66a6c7b1786f22f removed the now-unreachable wrapper and its tests. https://github.com/TryGhost/Ghost/pull/29686 identifies those files as duplicated dead code with no live importers. That deletion removed the component's last source or test importer on July 30, 2026. Inference: leaving `PostAnalyticsView` behind in that last cleanup was an incomplete leaf cleanup. No historical source explicitly describes the omission, but the dead-code PR's stated intent, the current zero-reference result, and Knip's independent unused-file report support that conclusion. ## Removal Deleted the 33-line orphaned component. It had no dedicated tests, export, dependency, configuration, or documentation to remove. Its `EmptyStatView` and `PostAnalyticsContent` dependencies remain used by live analytics screens. ## Verification - `pnpm exec knip --workspace @tryghost/admin --include files --reporter compact --no-progress` (the removed file is absent; four pre-existing acceptance setup/config entries remain) - `pnpm --dir apps/admin lint` - `pnpm --dir apps/admin typecheck` - `pnpm --dir apps/admin test:unit` (135 files, 1,604 tests) - `pnpm nx run @tryghost/admin:build` - `pnpm nx run-many -t test --exclude @tryghost/e2e --exclude ghost-admin --exclude @tryghost/koenig-lexical` (35 projects, including 8,000 Ghost tests) `pnpm check` completed repository-wide lint and documentation checks, but its aggregate test phase could not run Koenig Lexical's browser suite in this environment: Chromium was denied Mach port registration and the configured Firefox executable is absent. The broader test run above excludes only that environment-blocked package and passed. --- .../components/post-analytics-view.tsx | 33 ------------------- 1 file changed, 33 deletions(-) delete mode 100644 apps/admin/src/posts/analytics/components/post-analytics-view.tsx diff --git a/apps/admin/src/posts/analytics/components/post-analytics-view.tsx b/apps/admin/src/posts/analytics/components/post-analytics-view.tsx deleted file mode 100644 index b9728ca19c5..00000000000 --- a/apps/admin/src/posts/analytics/components/post-analytics-view.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import EmptyStatView from '@/shared/analytics/empty-stat-view'; -import PostAnalyticsContent from './post-analytics-content'; -import React from 'react'; - -interface PostAnalyticsViewProps { - isLoading?: boolean; - data?: T[] | null; - children?: React.ReactNode; - loadingComponent?: React.ReactNode; - emptyComponent?: React.ReactNode; -} - -const PostAnalyticsView = ({ - isLoading, - data, - children, - loadingComponent = <>Loading..., - emptyComponent = -}: PostAnalyticsViewProps) => { - return ( - - {isLoading ? ( - loadingComponent - ) : !data || data.length === 0 ? ( - emptyComponent - ) : ( - children - )} - - ); -}; - -export default PostAnalyticsView; From 745e3ff0b4d8385450ec1c47b5b29814cfea66b7 Mon Sep 17 00:00:00 2001 From: Troy Ciesco Date: Thu, 20 Aug 2026 16:36:51 -0400 Subject: [PATCH 6/9] Added targeted session replay recording for Automations (#30070) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref https://linear.app/ghost/issue/NY-1504 Automations investigations need session context even when no error occurs. Once a sampled app load enters an Automations route, record a full session replay for the rest of that load while preserving error-triggered replay buffering everywhere else. This follows Sentry's documented approach for recording replays on specific URLs: https://docs.sentry.io/platforms/javascript/session-replay/understanding-sessions/#record-session-replays-on-specific-urls Currently i have sampling set to 1.0 for automations, meaning that anyone who enters an automations route will start a session replay. i think this makes sense 1) to make sure it works and 2) while the feature's in beta, because we won't get too many hits to these routes. we can always lower the rate if it's recording too many. I also set it to mask _any_ text on these routes. That's certainly more than we need to mask but i wanted the setup to be as simple as possible. if we find we need to read non-sensitive data like certain labels and such, we can tweak later. It'll look like this in the sentry dashboard: Screenshot 2026-08-20 at 10 39
28 AM --- apps/admin/src/automations/automations.tsx | 2 +- .../email-modal/email-content-modal.tsx | 1 + .../components/email-modal/preview-frame.tsx | 1 + .../email-modal/test-email-dropdown.tsx | 2 +- .../email-modal/use-email-preview.ts | 2 + apps/admin/src/automations/editor.tsx | 2 +- apps/ember-admin/app/routes/application.js | 80 +++++++++++++++++++ apps/ember-admin/app/utils/sentry.js | 8 +- 8 files changed, 93 insertions(+), 5 deletions(-) diff --git a/apps/admin/src/automations/automations.tsx b/apps/admin/src/automations/automations.tsx index 008834b19dc..78f5326d0dd 100644 --- a/apps/admin/src/automations/automations.tsx +++ b/apps/admin/src/automations/automations.tsx @@ -17,7 +17,7 @@ const Automations: React.FC = () => { } return ( - + diff --git a/apps/admin/src/automations/components/email-modal/email-content-modal.tsx b/apps/admin/src/automations/components/email-modal/email-content-modal.tsx index d0832c02493..b1a305410d3 100644 --- a/apps/admin/src/automations/components/email-modal/email-content-modal.tsx +++ b/apps/admin/src/automations/components/email-modal/email-content-modal.tsx @@ -278,6 +278,7 @@ const EmailContentModal: React.FC = ({ ref={setDialogContentNode} aria-describedby={undefined} className='top-0 left-0 h-[100dvh] w-full max-w-full translate-0 grid-rows-[1fr] gap-0 rounded-none border-0 p-0 shadow-none outline-hidden sm:rounded-none dark:bg-[#151719]' + data-sentry-mask='true' onEscapeKeyDown={(event) => { if (isKoenigPortalFocused()) { // prevent Radix dismissing the dialog but let the diff --git a/apps/admin/src/automations/components/email-modal/preview-frame.tsx b/apps/admin/src/automations/components/email-modal/preview-frame.tsx index db87b5fdf0f..aefcf136074 100644 --- a/apps/admin/src/automations/components/email-modal/preview-frame.tsx +++ b/apps/admin/src/automations/components/email-modal/preview-frame.tsx @@ -135,6 +135,7 @@ const EmailPreviewFrame: React.FC = ({previewState}) =>