Skip to content
Draft
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
40 changes: 39 additions & 1 deletion ghost/core/core/server/services/automations/automations-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,17 @@ import { createDatabaseAutomationsRepository } from './database-automations-repo
import { parseFakeWaitHoursMultiplier } from './fake-wait-hours-multiplier';
import type { AutomationsRepository, EditAutomationData } from './automations-repository';
import { StartAutomationsPollEvent } from './events/start-automations-poll-event';
import { EMPTY_AUTOMATION_STATS, fetchAutomationStats } from './tinybird-automation-stats';

const { knex } = require('../../data/db');
const domainEvents = require('@tryghost/domain-events');
const labs = require('../../../shared/labs');
const config = require('../../../shared/config');
const settingsCache = require('../../../shared/settings-cache');
const lexicalLib = require('../../lib/lexical');
const requestExternal = require('../../lib/request-external');
const TinybirdServiceWrapper = require('../tinybird');
const { create: createTinybirdClient } = require('../stats/utils/tinybird');

const MAX_AUTOMATION_ACTIONS = 20;

Expand Down Expand Up @@ -72,8 +77,41 @@ const repository = createDatabaseAutomationsRepository({
),
});

function getTinybirdClient() {
if (!labs.isSet('automationRunAnalytics') || !config.get('tinybird')) {
return null;
}
if (!TinybirdServiceWrapper.instance) {
TinybirdServiceWrapper.init();
}
const tinybirdService = TinybirdServiceWrapper.instance;
if (!tinybirdService?.getToken()) {
return null;
}
return createTinybirdClient({ config, request: requestExternal, settingsCache, tinybirdService });
}

export async function browse() {
return await repository.browse();
const tinybirdClient = getTinybirdClient();
if (!tinybirdClient) {
return await repository.browse();
}

const [browseResult, stats] = await Promise.all([
repository.browse({ includeDatabaseStats: false }),
fetchAutomationStats(tinybirdClient),
]);
if (!stats) {
return browseResult;
}

return {
...browseResult,
data: browseResult.data.map((automation) => ({
...automation,
stats: stats.get(automation.id) ?? EMPTY_AUTOMATION_STATS,
})),
};
}

export async function read(automationId: string) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,12 @@ export type AutomationStepTerminalStatus =
| 'member changed status'
| 'member unsubscribed';

export interface BrowseOptions {
includeDatabaseStats?: boolean;
}

export interface AutomationsRepository {
browse(): Promise<Page<AutomationBrowseResult>>;
browse(options?: BrowseOptions): Promise<Page<AutomationBrowseResult>>;
getById(id: string): Promise<Automation | null>;
getAutomationActionLinks(
automationId: string,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type {
AutomationStepTerminalStatus,
AutomationStepToRun,
AutomationsRepository,
BrowseOptions,
EditAutomationData,
Page,
} from './automations-repository';
Expand Down Expand Up @@ -160,14 +161,18 @@ export function createDatabaseAutomationsRepository({
fakeWaitHoursMultiplier: number | null;
}): AutomationsRepository {
return {
async browse(): Promise<Page<AutomationBrowseResult>> {
async browse({ includeDatabaseStats = true }: BrowseOptions = {}): Promise<
Page<AutomationBrowseResult>
> {
return await knex.transaction(async (trx) => {
await ensureDefaultAutomations(trx);
const rows = await loadAutomations(trx);
const data = includeDatabaseStats
? (await loadAutomationsWithStats(trx)).map((row) => buildAutomationBrowseResult(row))
: (await loadAutomations(trx)).map((row) => buildAutomationSummary(row));
return {
data: rows.map((row) => buildAutomationBrowseResult(row)),
data,
meta: {
pagination: buildPagination(rows.length),
pagination: buildPagination(data.length),
},
};
});
Expand Down Expand Up @@ -1070,7 +1075,13 @@ async function loadAutomationBySlug(
return row ?? null;
}

async function loadAutomations(trx: Knex.Transaction): Promise<AutomationBrowseRow[]> {
async function loadAutomations(trx: Knex.Transaction): Promise<AutomationRow[]> {
return await trx('automations')
.select('id', 'slug', 'name', 'status', 'created_at', 'updated_at')
.orderBy('name');
}

async function loadAutomationsWithStats(trx: Knex.Transaction): Promise<AutomationBrowseRow[]> {
const inProgressRuns = trx('automation_run_steps')
.distinct('automation_run_id')
.where('status', 'pending')
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { z } from 'zod';
import { fromDatabaseDate } from '../../lib/db-types/date';

const logging = require('@tryghost/logging');

export interface TinybirdClient {
fetch(pipeName: string): Promise<unknown>;
}

export interface AutomationStats {
last_run_created_at: Date | null;
total_run_count: number;
in_progress_run_count: number;
}

export const EMPTY_AUTOMATION_STATS: AutomationStats = {
last_run_created_at: null,
total_run_count: 0,
in_progress_run_count: 0,
};

const statsRowSchema = z.object({
automation_id: z.string(),
last_run_created_at: z.string().nullable(),
total_run_count: z.coerce.number(),
in_progress_run_count: z.coerce.number(),
});

// Resolves to null when Tinybird is unreachable or returns something unexpected; the
// client logs transport errors itself. Callers then omit stats and Admin hides the columns.
export async function fetchAutomationStats(
client: TinybirdClient,
): Promise<Map<string, AutomationStats> | null> {
const rows = await client.fetch('api_automation_browse_stats');
if (rows === null) {
return null;
}

const parsed = z.array(statsRowSchema).safeParse(rows);
if (!parsed.success) {
logging.error(
{
system: { event: 'automations.stats.invalid_tinybird_response' },
issues: parsed.error.issues,
},
'Unexpected response from the Tinybird automation stats pipe',
);
return null;
}

return new Map(
parsed.data.map((row) => [
row.automation_id,
{
last_run_created_at: row.last_run_created_at
? fromDatabaseDate(row.last_run_created_at)
: null,
total_run_count: row.total_run_count,
in_progress_run_count: row.in_progress_run_count,
},
]),
);
}
78 changes: 78 additions & 0 deletions ghost/core/test/e2e-api/admin/automations.test.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
const assert = require('node:assert/strict');
const { createHash } = require('node:crypto');
const sinon = require('sinon');
const nock = require('nock');
const domainEvents = require('@tryghost/domain-events');
const ObjectId = require('bson-objectid').default;
const models = require('../../../core/server/models');
const mailService = require('../../../core/server/services/mail');
const TinybirdServiceWrapper = require('../../../core/server/services/tinybird');
const configUtils = require('../../utils/config-utils');
const { getSignedAdminToken } = require('../../../core/server/adapters/scheduling/utils');
const {
MEMBER_WELCOME_EMAIL_SLUGS,
Expand Down Expand Up @@ -320,6 +323,81 @@ describe('Automations API', function () {
assert.equal(automation.stats.in_progress_run_count, 1);
});

describe('with Tinybird configured', function () {
const TINYBIRD_ENDPOINT = 'https://api.tinybird.co';

beforeEach(function () {
configUtils.set('tinybird', {
workspaceId: 'test-workspace-id',
adminToken: 'test-admin-token',
stats: { endpoint: TINYBIRD_ENDPOINT },
});
TinybirdServiceWrapper.reset();
});

afterEach(async function () {
nock.cleanAll();
await configUtils.restore();
TinybirdServiceWrapper.reset();
});

it('populates stats from Tinybird instead of the database', async function () {
const { body: beforeBody } = await agent.get('automations').expectStatus(200);
const [automationId, otherAutomationId] = beforeBody.automations.map(
(automation) => automation.id,
);
await createAutomationRun(automationId, new Date('2026-01-01T00:00:00.000Z'));

const siteUuid = (await models.Settings.findOne({ key: 'site_uuid' })).get('value');
const tinybird = nock(TINYBIRD_ENDPOINT)
.get('/v0/pipes/api_automation_browse_stats.json')
.query({ site_uuid: siteUuid })
.reply(200, {
data: [
{
automation_id: automationId,
last_run_created_at: '2026-02-01 01:00:00',
total_run_count: 5,
in_progress_run_count: 2,
},
],
});

const { body } = await agent.get('automations').expectStatus(200);

assert.ok(tinybird.isDone());
const automation = body.automations.find((candidate) => candidate.id === automationId);
assert.deepEqual(automation.stats, {
last_run_created_at: '2026-02-01T01:00:00.000Z',
total_run_count: 5,
in_progress_run_count: 2,
});
const otherAutomation = body.automations.find(
(candidate) => candidate.id === otherAutomationId,
);
assert.deepEqual(otherAutomation.stats, {
last_run_created_at: null,
total_run_count: 0,
in_progress_run_count: 0,
});
});

it('omits stats when Tinybird is unavailable', async function () {
sinon.stub(console, 'error');
nock(TINYBIRD_ENDPOINT)
.get('/v0/pipes/api_automation_browse_stats.json')
.query(true)
.reply(500, 'nope');

const { body } = await agent.get('automations').expectStatus(200);

assert.equal(body.automations.length, 2);
for (const automation of body.automations) {
assert.equal(automation.stats, undefined);
}
});
});

it('upserts the default free and paid automations', async function () {
const existingAutomations = await models.Base.knex('automations')
.select('id')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,15 @@ describe('automations repository', function () {
});

describe('browse', function () {
it('omits stats when includeDatabaseStats is false', async function () {
const result = await repo.browse({ includeDatabaseStats: false });

assert.ok(result.data.length > 0);
for (const automation of result.data) {
assert.equal('stats' in automation, false);
}
});

const deleteActionsForAutomationIds = async (automationIds: string[]) => {
const actionIds = await knex('automation_actions')
.whereIn('automation_id', automationIds)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import assert from 'node:assert/strict';
import sinon from 'sinon';
import { afterEach, describe, it } from 'vitest';
import logging from '@tryghost/logging';
import { fetchAutomationStats } from '../../../../../core/server/services/automations/tinybird-automation-stats';

const clientReturning = (value: unknown) => ({ fetch: sinon.stub().resolves(value) });

describe('fetchAutomationStats', function () {
afterEach(function () {
sinon.restore();
});

it('reads the automation browse stats pipe', async function () {
const client = clientReturning([]);

await fetchAutomationStats(client);

assert.ok(client.fetch.calledOnceWithExactly('api_automation_browse_stats'));
});

it('maps rows by automation id, parsing UTC dates and numeric strings', async function () {
const client = clientReturning([
{
automation_id: 'automation-1',
last_run_created_at: '2026-02-01 01:00:00',
total_run_count: '4',
in_progress_run_count: 2,
},
{
automation_id: 'automation-2',
last_run_created_at: null,
total_run_count: 0,
in_progress_run_count: 0,
},
]);

const stats = await fetchAutomationStats(client);

assert.ok(stats);
assert.deepEqual(stats.get('automation-1'), {
last_run_created_at: new Date('2026-02-01T01:00:00.000Z'),
total_run_count: 4,
in_progress_run_count: 2,
});
assert.deepEqual(stats.get('automation-2'), {
last_run_created_at: null,
total_run_count: 0,
in_progress_run_count: 0,
});
});

it('returns null when the client could not fetch', async function () {
assert.equal(await fetchAutomationStats(clientReturning(null)), null);
});

it('returns null and logs when the response has an unexpected shape', async function () {
const error = sinon.stub(logging, 'error');

const stats = await fetchAutomationStats(clientReturning([{ automation_id: 42 }]));

assert.equal(stats, null);
assert.ok(error.calledOnce);
});
});
Loading