@analyticscli/growth-engineer 0.1.0-preview.15 → 0.1.0-preview.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config.d.ts +775 -22
- package/dist/config.js +39 -5
- package/dist/config.js.map +1 -1
- package/dist/index.js +131 -3
- package/dist/index.js.map +1 -1
- package/dist/runtime/export-asc-summary.mjs +1 -1
- package/dist/runtime/export-asc-summary.mjs.map +1 -1
- package/dist/runtime/export-coolify-summary.d.mts +2 -0
- package/dist/runtime/export-coolify-summary.mjs +230 -0
- package/dist/runtime/export-coolify-summary.mjs.map +1 -0
- package/dist/runtime/export-paddle-summary.d.mts +2 -0
- package/dist/runtime/export-paddle-summary.mjs +170 -0
- package/dist/runtime/export-paddle-summary.mjs.map +1 -0
- package/dist/runtime/export-sentry-summary.mjs +265 -38
- package/dist/runtime/export-sentry-summary.mjs.map +1 -1
- package/dist/runtime/export-seo-summary.d.mts +2 -0
- package/dist/runtime/export-seo-summary.mjs +503 -0
- package/dist/runtime/export-seo-summary.mjs.map +1 -0
- package/dist/runtime/openclaw-exporters-lib.d.mts +50 -0
- package/dist/runtime/openclaw-exporters-lib.mjs +761 -57
- package/dist/runtime/openclaw-exporters-lib.mjs.map +1 -1
- package/dist/runtime/openclaw-growth-engineer.mjs +163 -4
- package/dist/runtime/openclaw-growth-engineer.mjs.map +1 -1
- package/dist/runtime/openclaw-growth-env.mjs +5 -0
- package/dist/runtime/openclaw-growth-env.mjs.map +1 -1
- package/dist/runtime/openclaw-growth-preflight.mjs +399 -26
- package/dist/runtime/openclaw-growth-preflight.mjs.map +1 -1
- package/dist/runtime/openclaw-growth-runner.mjs +564 -69
- package/dist/runtime/openclaw-growth-runner.mjs.map +1 -1
- package/dist/runtime/openclaw-growth-shared.d.mts +150 -2
- package/dist/runtime/openclaw-growth-shared.mjs +489 -7
- package/dist/runtime/openclaw-growth-shared.mjs.map +1 -1
- package/dist/runtime/openclaw-growth-start.mjs +584 -48
- package/dist/runtime/openclaw-growth-start.mjs.map +1 -1
- package/dist/runtime/openclaw-growth-status.mjs +82 -6
- package/dist/runtime/openclaw-growth-status.mjs.map +1 -1
- package/dist/runtime/openclaw-growth-wizard.mjs +1501 -105
- package/dist/runtime/openclaw-growth-wizard.mjs.map +1 -1
- package/package.json +1 -1
- package/templates/config.example.json +120 -71
|
@@ -100,6 +100,21 @@ function buildRetentionQualityEvidence(retention) {
|
|
|
100
100
|
}
|
|
101
101
|
return evidence;
|
|
102
102
|
}
|
|
103
|
+
function hasRetentionIdentityPersistenceGap(retention) {
|
|
104
|
+
const reliability = normalizeRetentionReliability(retention);
|
|
105
|
+
if (reliability !== 'low' && reliability !== 'unknown') {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
const stableShare = coerceNumber(retention?.quality?.stableIdentityShare);
|
|
109
|
+
const multiSessionShare = coerceNumber(retention?.quality?.multiSessionShare);
|
|
110
|
+
if (stableShare !== null && stableShare >= 0.5) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
if (multiSessionShare !== null && multiSessionShare >= 0.2) {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
103
118
|
function maybePushSignal(signals, signal) {
|
|
104
119
|
if (!signal)
|
|
105
120
|
return;
|
|
@@ -230,42 +245,68 @@ export function buildAnalyticsSummary(input) {
|
|
|
230
245
|
const retentionReliability = normalizeRetentionReliability(retention);
|
|
231
246
|
const retentionHasLowConfidence = retentionReliability === 'low' || retentionReliability === 'unknown';
|
|
232
247
|
const retentionQualityEvidence = buildRetentionQualityEvidence(retention);
|
|
248
|
+
const retentionIdentityPersistenceGap = hasRetentionIdentityPersistenceGap(retention);
|
|
233
249
|
if (hasMinimumSample(retention?.cohortSize)) {
|
|
234
|
-
|
|
235
|
-
const actual = retentionByDay.get(target.day);
|
|
236
|
-
if (actual === undefined || actual >= target.baseline) {
|
|
237
|
-
continue;
|
|
238
|
-
}
|
|
250
|
+
if (retentionIdentityPersistenceGap) {
|
|
239
251
|
maybePushSignal(signals, {
|
|
240
|
-
id:
|
|
241
|
-
title:
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
baseline_value: target.baseline,
|
|
249
|
-
delta_percent: computeDeltaPercent(actual, target.baseline),
|
|
252
|
+
id: 'analytics_identity_persistence_missing',
|
|
253
|
+
title: 'Analytics identity persistence is missing; D7 retention is not reliable',
|
|
254
|
+
area: 'analytics_anomaly',
|
|
255
|
+
priority: 'high',
|
|
256
|
+
metric: 'retention_identity_quality',
|
|
257
|
+
current_value: coerceNumber(retention?.quality?.stableIdentityShare) || 0,
|
|
258
|
+
baseline_value: 0.5,
|
|
259
|
+
delta_percent: computeDeltaPercent(coerceNumber(retention?.quality?.stableIdentityShare) || 0, 0.5),
|
|
250
260
|
evidence: [
|
|
251
261
|
`Retention cohort size: ${retention.cohortSize}`,
|
|
252
|
-
`Observed D${target.day} retention: ${(actual * 100).toFixed(2)}%`,
|
|
253
262
|
...retentionQualityEvidence,
|
|
254
|
-
retention
|
|
255
|
-
? `Average active days in the cohort: ${retention.avgActiveDays}`
|
|
256
|
-
: null,
|
|
263
|
+
'D1/D7 retention is suppressed from product findings until the host app persists a stable SDK identity.',
|
|
257
264
|
].filter(Boolean),
|
|
258
265
|
suggested_actions: [
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
'Instrument the major early-session drop-off points to isolate which step drives the retention loss',
|
|
265
|
-
].filter(Boolean),
|
|
266
|
-
keywords: ['retention', 'engagement', 'activation', `d${target.day}`],
|
|
266
|
+
'Enable persistent AnalyticsCLI SDK identity in the host app before evaluating D1/D7 retention',
|
|
267
|
+
'Verify new release events carry identityQuality=persistent or identified instead of ephemeral or unknown',
|
|
268
|
+
'Rerun retention after at least one cohort has stable identity coverage',
|
|
269
|
+
],
|
|
270
|
+
keywords: ['analyticscli', 'identity', 'persistence', 'retention', 'd7'],
|
|
267
271
|
});
|
|
268
|
-
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
for (const target of retentionTargets) {
|
|
275
|
+
const actual = retentionByDay.get(target.day);
|
|
276
|
+
if (actual === undefined || actual >= target.baseline) {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
maybePushSignal(signals, {
|
|
280
|
+
id: `retention_d${target.day}_below_target`,
|
|
281
|
+
title: retentionHasLowConfidence
|
|
282
|
+
? `Day-${target.day} retention appears below target, but identity quality is low`
|
|
283
|
+
: `Day-${target.day} retention is below target`,
|
|
284
|
+
area: 'retention',
|
|
285
|
+
priority: retentionHasLowConfidence ? 'medium' : target.day >= 3 ? 'high' : 'medium',
|
|
286
|
+
metric: `d${target.day}_retention`,
|
|
287
|
+
current_value: round(actual),
|
|
288
|
+
baseline_value: target.baseline,
|
|
289
|
+
delta_percent: computeDeltaPercent(actual, target.baseline),
|
|
290
|
+
evidence: [
|
|
291
|
+
`Retention cohort size: ${retention.cohortSize}`,
|
|
292
|
+
`Observed D${target.day} retention: ${(actual * 100).toFixed(2)}%`,
|
|
293
|
+
...retentionQualityEvidence,
|
|
294
|
+
retention?.avgActiveDays !== undefined
|
|
295
|
+
? `Average active days in the cohort: ${retention.avgActiveDays}`
|
|
296
|
+
: null,
|
|
297
|
+
].filter(Boolean),
|
|
298
|
+
suggested_actions: [
|
|
299
|
+
retentionHasLowConfidence
|
|
300
|
+
? 'Verify SDK identity persistence and rerun retention with stable identity filtering before treating D1/D7 as a product fact'
|
|
301
|
+
: null,
|
|
302
|
+
'Revisit the first-session value loop and ensure the core action completes quickly',
|
|
303
|
+
'Add targeted re-entry prompts or reminders after the first session',
|
|
304
|
+
'Instrument the major early-session drop-off points to isolate which step drives the retention loss',
|
|
305
|
+
].filter(Boolean),
|
|
306
|
+
keywords: ['retention', 'engagement', 'activation', `d${target.day}`],
|
|
307
|
+
});
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
269
310
|
}
|
|
270
311
|
}
|
|
271
312
|
return {
|
|
@@ -319,6 +360,42 @@ function collectStatusEntries(payload) {
|
|
|
319
360
|
});
|
|
320
361
|
return entries;
|
|
321
362
|
}
|
|
363
|
+
function normalizeVersionToken(value) {
|
|
364
|
+
const normalized = String(value || '').trim();
|
|
365
|
+
if (!normalized)
|
|
366
|
+
return '';
|
|
367
|
+
const match = normalized.match(/\b\d+(?:\.\d+){1,3}(?:\+\d+)?\b/);
|
|
368
|
+
return match ? match[0] : '';
|
|
369
|
+
}
|
|
370
|
+
function collectAscProductionVersions(payload) {
|
|
371
|
+
const candidates = [];
|
|
372
|
+
walk(payload, (value, pathParts, key) => {
|
|
373
|
+
if (typeof value !== 'string' && typeof value !== 'number')
|
|
374
|
+
return;
|
|
375
|
+
const normalizedKey = String(key || '').toLowerCase();
|
|
376
|
+
if (!/(version|string|build|release)/.test(normalizedKey))
|
|
377
|
+
return;
|
|
378
|
+
const version = normalizeVersionToken(value);
|
|
379
|
+
if (!version)
|
|
380
|
+
return;
|
|
381
|
+
const pathText = pathParts.join('.').toLowerCase();
|
|
382
|
+
const parentPath = pathParts.slice(0, -1).join('.').toLowerCase();
|
|
383
|
+
const context = String(JSON.stringify(resolvePathPayload(payload, pathParts.slice(0, -1))) || '').toLowerCase();
|
|
384
|
+
if (/(ready_for_sale|readyforsale|approved|active|available|current|live|production)/.test(`${pathText} ${parentPath} ${context}`)) {
|
|
385
|
+
candidates.push(version);
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
return [...new Set(candidates)].sort();
|
|
389
|
+
}
|
|
390
|
+
function resolvePathPayload(payload, pathParts) {
|
|
391
|
+
let current = payload;
|
|
392
|
+
for (const part of pathParts) {
|
|
393
|
+
if (!current || typeof current !== 'object')
|
|
394
|
+
return null;
|
|
395
|
+
current = current[part];
|
|
396
|
+
}
|
|
397
|
+
return current;
|
|
398
|
+
}
|
|
322
399
|
function classifyAscStatus(value) {
|
|
323
400
|
const normalized = String(value || '')
|
|
324
401
|
.trim()
|
|
@@ -590,6 +667,7 @@ function formatPercent(value) {
|
|
|
590
667
|
export function buildAscSummary(input) {
|
|
591
668
|
const appId = String(input?.appId || 'ASC_APP_ID').trim() || 'ASC_APP_ID';
|
|
592
669
|
const statusEntries = collectStatusEntries(input?.statusPayload);
|
|
670
|
+
const productionVersions = collectAscProductionVersions(input?.statusPayload);
|
|
593
671
|
const blockingStatuses = statusEntries.filter((entry) => classifyAscStatus(entry.value) === 'blocking');
|
|
594
672
|
const watchStatuses = statusEntries.filter((entry) => classifyAscStatus(entry.value) === 'watch');
|
|
595
673
|
const averageRatingCandidates = findNumbersByCandidateKeys(input?.ratingsPayload, [
|
|
@@ -891,6 +969,7 @@ export function buildAscSummary(input) {
|
|
|
891
969
|
analyticsAvailability,
|
|
892
970
|
analyticsWarnings,
|
|
893
971
|
batchReports,
|
|
972
|
+
productionVersions,
|
|
894
973
|
analytics: {
|
|
895
974
|
units: unitsMetric
|
|
896
975
|
? {
|
|
@@ -1094,6 +1173,399 @@ export function buildRevenueCatSummary(input) {
|
|
|
1094
1173
|
},
|
|
1095
1174
|
};
|
|
1096
1175
|
}
|
|
1176
|
+
function metricSeries(payload) {
|
|
1177
|
+
if (Array.isArray(payload?.data?.timeseries))
|
|
1178
|
+
return payload.data.timeseries;
|
|
1179
|
+
if (Array.isArray(payload?.timeseries))
|
|
1180
|
+
return payload.timeseries;
|
|
1181
|
+
return [];
|
|
1182
|
+
}
|
|
1183
|
+
function metricCurrency(payload) {
|
|
1184
|
+
return String(payload?.data?.currency_code || payload?.currency_code || '').trim();
|
|
1185
|
+
}
|
|
1186
|
+
function metricUpdatedAt(payload) {
|
|
1187
|
+
return String(payload?.data?.updated_at || payload?.updated_at || '').trim();
|
|
1188
|
+
}
|
|
1189
|
+
function amountValue(value) {
|
|
1190
|
+
const numeric = coerceNumber(value);
|
|
1191
|
+
return numeric === null ? 0 : numeric;
|
|
1192
|
+
}
|
|
1193
|
+
function sumAmounts(series) {
|
|
1194
|
+
return series.reduce((total, point) => total + amountValue(point?.amount), 0);
|
|
1195
|
+
}
|
|
1196
|
+
function sumCounts(series, key = 'count') {
|
|
1197
|
+
return series.reduce((total, point) => total + (coerceNumber(point?.[key]) || 0), 0);
|
|
1198
|
+
}
|
|
1199
|
+
function firstLastNumeric(series, key) {
|
|
1200
|
+
const values = series
|
|
1201
|
+
.map((point) => coerceNumber(point?.[key]))
|
|
1202
|
+
.filter((value) => value !== null);
|
|
1203
|
+
if (values.length === 0)
|
|
1204
|
+
return { first: null, last: null, deltaPercent: null };
|
|
1205
|
+
const first = values[0];
|
|
1206
|
+
const last = values[values.length - 1];
|
|
1207
|
+
return {
|
|
1208
|
+
first,
|
|
1209
|
+
last,
|
|
1210
|
+
deltaPercent: computeDeltaPercent(last, first),
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
function formatMinorCurrency(amount, currencyCode) {
|
|
1214
|
+
const currency = String(currencyCode || '').trim().toUpperCase();
|
|
1215
|
+
const value = amountValue(amount) / 100;
|
|
1216
|
+
if (!currency)
|
|
1217
|
+
return String(value);
|
|
1218
|
+
try {
|
|
1219
|
+
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(value);
|
|
1220
|
+
}
|
|
1221
|
+
catch {
|
|
1222
|
+
return `${value.toFixed(2)} ${currency}`;
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
export function buildPaddleSummary(input) {
|
|
1226
|
+
const revenue = input?.metrics?.revenue || null;
|
|
1227
|
+
const mrr = input?.metrics?.monthlyRecurringRevenue || null;
|
|
1228
|
+
const activeSubscribers = input?.metrics?.activeSubscribers || null;
|
|
1229
|
+
const refunds = input?.metrics?.refunds || null;
|
|
1230
|
+
const chargebacks = input?.metrics?.chargebacks || null;
|
|
1231
|
+
const checkoutConversion = input?.metrics?.checkoutConversion || null;
|
|
1232
|
+
const warnings = Array.isArray(input?.warnings) ? input.warnings.filter(Boolean).map(String) : [];
|
|
1233
|
+
const window = String(input?.window || 'last_30d');
|
|
1234
|
+
const currency = metricCurrency(revenue) || metricCurrency(mrr) || metricCurrency(refunds) || '';
|
|
1235
|
+
const revenueSeries = metricSeries(revenue);
|
|
1236
|
+
const mrrSeries = metricSeries(mrr);
|
|
1237
|
+
const subscriberSeries = metricSeries(activeSubscribers);
|
|
1238
|
+
const refundSeries = metricSeries(refunds);
|
|
1239
|
+
const chargebackSeries = metricSeries(chargebacks);
|
|
1240
|
+
const checkoutSeries = metricSeries(checkoutConversion);
|
|
1241
|
+
const signals = [];
|
|
1242
|
+
const totalRevenue = sumAmounts(revenueSeries);
|
|
1243
|
+
const transactionCount = sumCounts(revenueSeries);
|
|
1244
|
+
if (revenueSeries.length > 0) {
|
|
1245
|
+
const midpoint = Math.floor(revenueSeries.length / 2);
|
|
1246
|
+
const firstHalf = sumAmounts(revenueSeries.slice(0, midpoint || revenueSeries.length));
|
|
1247
|
+
const secondHalf = midpoint > 0 ? sumAmounts(revenueSeries.slice(midpoint)) : totalRevenue;
|
|
1248
|
+
const delta = midpoint > 0 ? computeDeltaPercent(secondHalf, firstHalf) : null;
|
|
1249
|
+
maybePushSignal(signals, {
|
|
1250
|
+
id: 'paddle_revenue_trend',
|
|
1251
|
+
title: delta !== null && delta < -20 ? 'Paddle revenue dropped versus the prior part of the window' : 'Paddle revenue metrics are connected',
|
|
1252
|
+
area: 'revenue',
|
|
1253
|
+
priority: delta !== null && delta < -20 ? 'high' : totalRevenue > 0 ? 'medium' : 'low',
|
|
1254
|
+
metric: 'paddle_revenue',
|
|
1255
|
+
current_value: totalRevenue,
|
|
1256
|
+
baseline_value: firstHalf || null,
|
|
1257
|
+
delta_percent: delta,
|
|
1258
|
+
evidence: [
|
|
1259
|
+
`Revenue in window: ${formatMinorCurrency(totalRevenue, currency)}`,
|
|
1260
|
+
`Completed payment count: ${transactionCount}`,
|
|
1261
|
+
delta !== null ? `Recent-half revenue delta: ${delta}%` : null,
|
|
1262
|
+
].filter(Boolean),
|
|
1263
|
+
suggested_actions: [
|
|
1264
|
+
'Compare Paddle revenue movement with AnalyticsCLI checkout and activation funnels',
|
|
1265
|
+
'Segment revenue movement by recent releases, traffic sources, and pricing page changes before changing pricing',
|
|
1266
|
+
],
|
|
1267
|
+
keywords: ['paddle', 'revenue', 'checkout', 'billing'],
|
|
1268
|
+
confidence: transactionCount > 0 ? 'high' : 'medium',
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1271
|
+
const mrrTrend = firstLastNumeric(mrrSeries, 'amount');
|
|
1272
|
+
if (mrrTrend.last !== null) {
|
|
1273
|
+
maybePushSignal(signals, {
|
|
1274
|
+
id: 'paddle_mrr_trend',
|
|
1275
|
+
title: mrrTrend.deltaPercent !== null && mrrTrend.deltaPercent < -10 ? 'Paddle MRR is contracting' : 'Paddle MRR is available for subscription analysis',
|
|
1276
|
+
area: mrrTrend.deltaPercent !== null && mrrTrend.deltaPercent < -10 ? 'retention' : 'revenue',
|
|
1277
|
+
priority: mrrTrend.deltaPercent !== null && mrrTrend.deltaPercent < -10 ? 'high' : 'medium',
|
|
1278
|
+
metric: 'paddle_mrr',
|
|
1279
|
+
current_value: mrrTrend.last,
|
|
1280
|
+
baseline_value: mrrTrend.first,
|
|
1281
|
+
delta_percent: mrrTrend.deltaPercent,
|
|
1282
|
+
evidence: [
|
|
1283
|
+
`MRR start: ${formatMinorCurrency(mrrTrend.first, currency)}`,
|
|
1284
|
+
`MRR end: ${formatMinorCurrency(mrrTrend.last, currency)}`,
|
|
1285
|
+
mrrTrend.deltaPercent !== null ? `MRR delta: ${mrrTrend.deltaPercent}%` : null,
|
|
1286
|
+
].filter(Boolean),
|
|
1287
|
+
suggested_actions: [
|
|
1288
|
+
'Investigate churn, failed renewals, and downgrade timing before adding acquisition spend',
|
|
1289
|
+
'Pair MRR changes with product usage cohorts to find whether contraction follows activation or value-delivery gaps',
|
|
1290
|
+
],
|
|
1291
|
+
keywords: ['paddle', 'mrr', 'subscription', 'retention'],
|
|
1292
|
+
confidence: 'high',
|
|
1293
|
+
});
|
|
1294
|
+
}
|
|
1295
|
+
const subscriberTrend = firstLastNumeric(subscriberSeries, 'count');
|
|
1296
|
+
if (subscriberTrend.last !== null) {
|
|
1297
|
+
maybePushSignal(signals, {
|
|
1298
|
+
id: 'paddle_active_subscribers_trend',
|
|
1299
|
+
title: subscriberTrend.deltaPercent !== null && subscriberTrend.deltaPercent < -10 ? 'Paddle active subscribers declined' : 'Paddle active subscriber count is connected',
|
|
1300
|
+
area: subscriberTrend.deltaPercent !== null && subscriberTrend.deltaPercent < -10 ? 'retention' : 'revenue',
|
|
1301
|
+
priority: subscriberTrend.deltaPercent !== null && subscriberTrend.deltaPercent < -10 ? 'high' : 'medium',
|
|
1302
|
+
metric: 'paddle_active_subscribers',
|
|
1303
|
+
current_value: subscriberTrend.last,
|
|
1304
|
+
baseline_value: subscriberTrend.first,
|
|
1305
|
+
delta_percent: subscriberTrend.deltaPercent,
|
|
1306
|
+
evidence: [
|
|
1307
|
+
`Active subscribers start: ${subscriberTrend.first}`,
|
|
1308
|
+
`Active subscribers end: ${subscriberTrend.last}`,
|
|
1309
|
+
subscriberTrend.deltaPercent !== null ? `Subscriber delta: ${subscriberTrend.deltaPercent}%` : null,
|
|
1310
|
+
].filter(Boolean),
|
|
1311
|
+
suggested_actions: [
|
|
1312
|
+
'Compare subscriber movement with onboarding completion, activation, and cancellation feedback',
|
|
1313
|
+
'Check whether acquisition quality or pricing page changes shifted subscriber mix',
|
|
1314
|
+
],
|
|
1315
|
+
keywords: ['paddle', 'subscribers', 'subscription', 'retention'],
|
|
1316
|
+
confidence: 'high',
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
const totalRefunds = sumAmounts(refundSeries);
|
|
1320
|
+
const totalChargebacks = sumCounts(chargebackSeries) || sumAmounts(chargebackSeries);
|
|
1321
|
+
if (totalRefunds > 0 || totalChargebacks > 0) {
|
|
1322
|
+
maybePushSignal(signals, {
|
|
1323
|
+
id: 'paddle_refunds_or_chargebacks_visible',
|
|
1324
|
+
title: 'Paddle reports refunds or chargebacks',
|
|
1325
|
+
area: 'revenue',
|
|
1326
|
+
priority: totalChargebacks > 0 || totalRefunds >= totalRevenue * 0.1 ? 'high' : 'medium',
|
|
1327
|
+
metric: 'paddle_refunds_chargebacks',
|
|
1328
|
+
current_value: totalRefunds + totalChargebacks,
|
|
1329
|
+
baseline_value: 0,
|
|
1330
|
+
delta_percent: 100,
|
|
1331
|
+
evidence: [
|
|
1332
|
+
`Refund amount: ${formatMinorCurrency(totalRefunds, currency)}`,
|
|
1333
|
+
`Chargebacks/count signal: ${totalChargebacks}`,
|
|
1334
|
+
],
|
|
1335
|
+
suggested_actions: [
|
|
1336
|
+
'Review refund reasons and payment disputes before scaling acquisition',
|
|
1337
|
+
'Compare refund timing with product promise, onboarding quality, and checkout copy',
|
|
1338
|
+
],
|
|
1339
|
+
keywords: ['paddle', 'refunds', 'chargebacks', 'revenue'],
|
|
1340
|
+
confidence: 'medium',
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
if (checkoutSeries.length > 0) {
|
|
1344
|
+
const latest = checkoutSeries[checkoutSeries.length - 1] || {};
|
|
1345
|
+
const rate = coerceNumber(latest.rate);
|
|
1346
|
+
const started = coerceNumber(latest.count);
|
|
1347
|
+
const completed = coerceNumber(latest.completed_count ?? latest.completedCount);
|
|
1348
|
+
if (rate !== null || started !== null || completed !== null) {
|
|
1349
|
+
maybePushSignal(signals, {
|
|
1350
|
+
id: 'paddle_checkout_conversion',
|
|
1351
|
+
title: rate !== null && rate < 0.05 ? 'Paddle checkout conversion is low' : 'Paddle checkout conversion is measurable',
|
|
1352
|
+
area: 'conversion',
|
|
1353
|
+
priority: rate !== null && rate < 0.05 ? 'high' : 'medium',
|
|
1354
|
+
metric: 'paddle_checkout_conversion',
|
|
1355
|
+
current_value: rate,
|
|
1356
|
+
baseline_value: null,
|
|
1357
|
+
delta_percent: null,
|
|
1358
|
+
evidence: [
|
|
1359
|
+
rate !== null ? `Latest checkout conversion rate: ${(rate * 100).toFixed(2)}%` : null,
|
|
1360
|
+
started !== null ? `Latest checkout sessions: ${started}` : null,
|
|
1361
|
+
completed !== null ? `Latest completed checkouts: ${completed}` : null,
|
|
1362
|
+
].filter(Boolean),
|
|
1363
|
+
suggested_actions: [
|
|
1364
|
+
'Inspect checkout abandonment by plan, geography, and device before changing the paywall',
|
|
1365
|
+
'Cross-check pricing page CTA clicks against Paddle checkout starts and completions',
|
|
1366
|
+
],
|
|
1367
|
+
keywords: ['paddle', 'checkout', 'conversion', 'pricing'],
|
|
1368
|
+
confidence: 'medium',
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
if (warnings.length > 0) {
|
|
1373
|
+
maybePushSignal(signals, {
|
|
1374
|
+
id: 'paddle_api_partial_read',
|
|
1375
|
+
title: 'Paddle metrics summary is partial',
|
|
1376
|
+
area: 'general',
|
|
1377
|
+
priority: 'low',
|
|
1378
|
+
metric: 'paddle_api_warnings',
|
|
1379
|
+
current_value: warnings.length,
|
|
1380
|
+
evidence: warnings.slice(0, 8),
|
|
1381
|
+
suggested_actions: [
|
|
1382
|
+
'Verify the Paddle API key has metrics.read permission on the live account',
|
|
1383
|
+
'Keep sandbox and live keys separate; Paddle metrics endpoints are intended for live account reporting',
|
|
1384
|
+
],
|
|
1385
|
+
keywords: ['paddle', 'api', 'metrics', 'permissions'],
|
|
1386
|
+
confidence: 'medium',
|
|
1387
|
+
});
|
|
1388
|
+
}
|
|
1389
|
+
return {
|
|
1390
|
+
project: 'paddle',
|
|
1391
|
+
window,
|
|
1392
|
+
metrics: input?.metrics || {},
|
|
1393
|
+
signals: sortSignals(signals).slice(0, Math.max(1, Number(input?.maxSignals) || 6)),
|
|
1394
|
+
meta: {
|
|
1395
|
+
generatedAt: new Date().toISOString(),
|
|
1396
|
+
source: 'paddle',
|
|
1397
|
+
environment: input?.environment || 'live',
|
|
1398
|
+
currencyCode: currency || null,
|
|
1399
|
+
updatedAt: [
|
|
1400
|
+
metricUpdatedAt(revenue),
|
|
1401
|
+
metricUpdatedAt(mrr),
|
|
1402
|
+
metricUpdatedAt(activeSubscribers),
|
|
1403
|
+
metricUpdatedAt(refunds),
|
|
1404
|
+
metricUpdatedAt(chargebacks),
|
|
1405
|
+
metricUpdatedAt(checkoutConversion),
|
|
1406
|
+
].filter(Boolean)[0] || null,
|
|
1407
|
+
warnings,
|
|
1408
|
+
},
|
|
1409
|
+
};
|
|
1410
|
+
}
|
|
1411
|
+
function seoNumber(value) {
|
|
1412
|
+
const numeric = coerceNumber(value);
|
|
1413
|
+
return numeric === null ? 0 : numeric;
|
|
1414
|
+
}
|
|
1415
|
+
function normalizeSeoRow(row) {
|
|
1416
|
+
const keys = Array.isArray(row?.keys) ? row.keys.map((value) => String(value || '').trim()) : [];
|
|
1417
|
+
const query = String(row?.query || row?.keyword || keys[0] || '').trim();
|
|
1418
|
+
const page = String(row?.page || row?.url || keys[1] || '').trim();
|
|
1419
|
+
return {
|
|
1420
|
+
query,
|
|
1421
|
+
page,
|
|
1422
|
+
clicks: seoNumber(row?.clicks),
|
|
1423
|
+
impressions: seoNumber(row?.impressions),
|
|
1424
|
+
ctr: coerceNumber(row?.ctr) ?? null,
|
|
1425
|
+
position: coerceNumber(row?.position) ?? null,
|
|
1426
|
+
volume: coerceNumber(row?.volume ?? row?.search_volume) ?? null,
|
|
1427
|
+
difficulty: coerceNumber(row?.difficulty ?? row?.keyword_difficulty ?? row?.competition_index) ?? null,
|
|
1428
|
+
cpc: coerceNumber(row?.cpc) ?? null,
|
|
1429
|
+
source: String(row?.source || 'seo').trim(),
|
|
1430
|
+
};
|
|
1431
|
+
}
|
|
1432
|
+
function seoCtr(row) {
|
|
1433
|
+
if (row.ctr !== null)
|
|
1434
|
+
return row.ctr;
|
|
1435
|
+
return row.impressions > 0 ? row.clicks / row.impressions : 0;
|
|
1436
|
+
}
|
|
1437
|
+
function seoLabel(row) {
|
|
1438
|
+
return [row.query, row.page].filter(Boolean).join(' -> ') || row.query || row.page || 'unknown';
|
|
1439
|
+
}
|
|
1440
|
+
export function buildSeoSummary(input) {
|
|
1441
|
+
const rows = Array.isArray(input?.rows) ? input.rows.map(normalizeSeoRow) : [];
|
|
1442
|
+
const keywordRows = Array.isArray(input?.keywordRows) ? input.keywordRows.map(normalizeSeoRow) : [];
|
|
1443
|
+
const warnings = Array.isArray(input?.warnings) ? input.warnings.filter(Boolean).map(String) : [];
|
|
1444
|
+
const maxSignals = Math.max(1, Number(input?.maxSignals) || 8);
|
|
1445
|
+
const signals = [];
|
|
1446
|
+
const gscRows = rows.filter((row) => row.impressions > 0);
|
|
1447
|
+
const lowCtr = [...gscRows]
|
|
1448
|
+
.filter((row) => row.impressions >= 100 && seoCtr(row) < 0.02)
|
|
1449
|
+
.sort((a, b) => b.impressions - a.impressions)
|
|
1450
|
+
.slice(0, 5);
|
|
1451
|
+
if (lowCtr.length > 0) {
|
|
1452
|
+
maybePushSignal(signals, {
|
|
1453
|
+
id: 'seo_gsc_high_impression_low_ctr',
|
|
1454
|
+
title: 'Google Search Console shows high-impression queries with low CTR',
|
|
1455
|
+
area: 'marketing',
|
|
1456
|
+
priority: lowCtr.some((row) => row.impressions >= 1000) ? 'high' : 'medium',
|
|
1457
|
+
metric: 'gsc_low_ctr_impressions',
|
|
1458
|
+
current_value: lowCtr.reduce((total, row) => total + row.impressions, 0),
|
|
1459
|
+
baseline_value: null,
|
|
1460
|
+
delta_percent: null,
|
|
1461
|
+
evidence: lowCtr.map((row) => `${seoLabel(row)}: ${row.impressions} impressions, ${row.clicks} clicks, ${(seoCtr(row) * 100).toFixed(2)}% CTR, avg position ${row.position ?? 'n/a'}`),
|
|
1462
|
+
suggested_actions: [
|
|
1463
|
+
'Refresh title, meta description, and above-the-fold promise for the affected page/query pair',
|
|
1464
|
+
'Check SERP intent and make the page answer the query more directly before creating new pages',
|
|
1465
|
+
],
|
|
1466
|
+
keywords: ['seo', 'gsc', 'ctr', 'search-console'],
|
|
1467
|
+
confidence: 'high',
|
|
1468
|
+
});
|
|
1469
|
+
}
|
|
1470
|
+
const strikingDistance = [...gscRows]
|
|
1471
|
+
.filter((row) => row.impressions >= 50 && row.position !== null && row.position >= 4 && row.position <= 20)
|
|
1472
|
+
.sort((a, b) => b.impressions - a.impressions)
|
|
1473
|
+
.slice(0, 5);
|
|
1474
|
+
if (strikingDistance.length > 0) {
|
|
1475
|
+
maybePushSignal(signals, {
|
|
1476
|
+
id: 'seo_gsc_striking_distance_queries',
|
|
1477
|
+
title: 'Google Search Console has striking-distance SEO opportunities',
|
|
1478
|
+
area: 'marketing',
|
|
1479
|
+
priority: strikingDistance.some((row) => row.position <= 10 && row.impressions >= 500) ? 'high' : 'medium',
|
|
1480
|
+
metric: 'gsc_striking_distance_impressions',
|
|
1481
|
+
current_value: strikingDistance.reduce((total, row) => total + row.impressions, 0),
|
|
1482
|
+
baseline_value: null,
|
|
1483
|
+
delta_percent: null,
|
|
1484
|
+
evidence: strikingDistance.map((row) => `${seoLabel(row)}: avg position ${row.position}, ${row.impressions} impressions, ${row.clicks} clicks`),
|
|
1485
|
+
suggested_actions: [
|
|
1486
|
+
'Improve the existing ranking URL first: intent match, internal links, comparison proof, and product-specific examples',
|
|
1487
|
+
'Only create a new page if the current URL cannot satisfy the query intent without cannibalization',
|
|
1488
|
+
],
|
|
1489
|
+
keywords: ['seo', 'gsc', 'ranking', 'content-refresh'],
|
|
1490
|
+
confidence: 'high',
|
|
1491
|
+
});
|
|
1492
|
+
}
|
|
1493
|
+
const keywordOpportunities = [...keywordRows]
|
|
1494
|
+
.filter((row) => (row.volume || 0) >= 20)
|
|
1495
|
+
.sort((a, b) => (b.volume || 0) - (a.volume || 0) || (a.difficulty || 100) - (b.difficulty || 100))
|
|
1496
|
+
.slice(0, 5);
|
|
1497
|
+
if (keywordOpportunities.length > 0) {
|
|
1498
|
+
maybePushSignal(signals, {
|
|
1499
|
+
id: 'seo_keyword_research_opportunities',
|
|
1500
|
+
title: 'Keyword research found acquisition opportunities',
|
|
1501
|
+
area: 'marketing',
|
|
1502
|
+
priority: keywordOpportunities.some((row) => (row.volume || 0) >= 500) ? 'high' : 'medium',
|
|
1503
|
+
metric: 'seo_keyword_volume',
|
|
1504
|
+
current_value: keywordOpportunities.reduce((total, row) => total + (row.volume || 0), 0),
|
|
1505
|
+
baseline_value: null,
|
|
1506
|
+
delta_percent: null,
|
|
1507
|
+
evidence: keywordOpportunities.map((row) => `${row.query}: volume ${row.volume ?? 'n/a'}, difficulty ${row.difficulty ?? 'n/a'}, CPC ${row.cpc ?? 'n/a'}, source ${row.source}`),
|
|
1508
|
+
suggested_actions: [
|
|
1509
|
+
'Map each keyword to an existing URL before creating new content',
|
|
1510
|
+
'Prioritize BOF, comparison, integration, and template pages where the product can add unique evidence',
|
|
1511
|
+
],
|
|
1512
|
+
keywords: ['seo', 'keyword-research', 'dataforseo', 'content'],
|
|
1513
|
+
confidence: keywordOpportunities.some((row) => row.source.includes('dataforseo')) ? 'high' : 'medium',
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
if (gscRows.length === 0 && keywordRows.length === 0) {
|
|
1517
|
+
maybePushSignal(signals, {
|
|
1518
|
+
id: 'seo_no_search_data',
|
|
1519
|
+
title: 'SEO connector has no search data yet',
|
|
1520
|
+
area: 'marketing',
|
|
1521
|
+
priority: 'low',
|
|
1522
|
+
metric: 'seo_rows',
|
|
1523
|
+
current_value: 0,
|
|
1524
|
+
baseline_value: 1,
|
|
1525
|
+
delta_percent: -100,
|
|
1526
|
+
evidence: warnings.length > 0 ? warnings.slice(0, 5) : ['No GSC rows, keyword CSV rows, or DataForSEO rows were available.'],
|
|
1527
|
+
suggested_actions: [
|
|
1528
|
+
'Connect Google Search Console or provide a recent GSC/keyword CSV export',
|
|
1529
|
+
'Use DataForSEO only after narrowing seed topics and setting a paid request cap',
|
|
1530
|
+
],
|
|
1531
|
+
keywords: ['seo', 'gsc', 'dataforseo', 'setup'],
|
|
1532
|
+
confidence: 'medium',
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
if (warnings.length > 0) {
|
|
1536
|
+
maybePushSignal(signals, {
|
|
1537
|
+
id: 'seo_partial_read',
|
|
1538
|
+
title: 'SEO summary is partial',
|
|
1539
|
+
area: 'marketing',
|
|
1540
|
+
priority: 'low',
|
|
1541
|
+
metric: 'seo_warnings',
|
|
1542
|
+
current_value: warnings.length,
|
|
1543
|
+
evidence: warnings.slice(0, 8),
|
|
1544
|
+
suggested_actions: [
|
|
1545
|
+
'Fix the missing SEO credential/export only if SEO is part of the active growth cadence',
|
|
1546
|
+
'Prefer cached exports for repeatable analysis and bounded paid API usage',
|
|
1547
|
+
],
|
|
1548
|
+
keywords: ['seo', 'gsc', 'dataforseo', 'credentials'],
|
|
1549
|
+
confidence: 'medium',
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1552
|
+
return {
|
|
1553
|
+
project: input?.siteUrl ? `seo:${input.siteUrl}` : 'seo',
|
|
1554
|
+
window: input?.window || 'latest',
|
|
1555
|
+
rows: gscRows.slice(0, 100),
|
|
1556
|
+
keywordRows: keywordRows.slice(0, 100),
|
|
1557
|
+
signals: sortSignals(signals).slice(0, maxSignals),
|
|
1558
|
+
meta: {
|
|
1559
|
+
generatedAt: new Date().toISOString(),
|
|
1560
|
+
source: 'seo',
|
|
1561
|
+
siteUrl: input?.siteUrl || null,
|
|
1562
|
+
gscRows: gscRows.length,
|
|
1563
|
+
keywordRows: keywordRows.length,
|
|
1564
|
+
paidProvider: input?.paidProvider || null,
|
|
1565
|
+
warnings,
|
|
1566
|
+
},
|
|
1567
|
+
};
|
|
1568
|
+
}
|
|
1097
1569
|
function normalizeSentryIssueCount(issue) {
|
|
1098
1570
|
return coerceNumber(issue?.count ?? issue?.events ?? issue?.eventCount ?? issue?.stats?.sum) || 0;
|
|
1099
1571
|
}
|
|
@@ -1103,6 +1575,16 @@ function normalizeSentryUserCount(issue) {
|
|
|
1103
1575
|
function normalizeSentryIssueTitle(issue) {
|
|
1104
1576
|
return String(issue?.title || issue?.metadata?.title || issue?.culprit || 'Untitled Sentry issue').trim();
|
|
1105
1577
|
}
|
|
1578
|
+
function normalizeSentryIssueUrl(issue) {
|
|
1579
|
+
return String(issue?.permalink ||
|
|
1580
|
+
issue?.issueUrl ||
|
|
1581
|
+
issue?.issue_url ||
|
|
1582
|
+
issue?.webUrl ||
|
|
1583
|
+
issue?.web_url ||
|
|
1584
|
+
issue?.links?.permalink ||
|
|
1585
|
+
issue?.links?.html ||
|
|
1586
|
+
'').trim();
|
|
1587
|
+
}
|
|
1106
1588
|
function normalizeSentryPriority(issue) {
|
|
1107
1589
|
const level = String(issue?.level || issue?.priority || '').toLowerCase();
|
|
1108
1590
|
const events = normalizeSentryIssueCount(issue);
|
|
@@ -1114,19 +1596,44 @@ function normalizeSentryPriority(issue) {
|
|
|
1114
1596
|
return 'low';
|
|
1115
1597
|
}
|
|
1116
1598
|
function normalizeSentryEvidence(issue, environment) {
|
|
1599
|
+
const releaseVersions = extractSentryReleaseVersions(issue);
|
|
1600
|
+
const issueUrl = normalizeSentryIssueUrl(issue);
|
|
1117
1601
|
return [
|
|
1118
1602
|
issue?.shortId ? `Sentry issue: ${issue.shortId}` : issue?.id ? `Sentry issue id: ${issue.id}` : null,
|
|
1119
|
-
|
|
1603
|
+
issueUrl ? `Issue link: ${issueUrl}` : null,
|
|
1120
1604
|
issue?.level ? `Level: ${issue.level}` : null,
|
|
1121
1605
|
issue?.status ? `Status: ${issue.status}` : null,
|
|
1122
1606
|
issue?.firstSeen ? `First seen: ${issue.firstSeen}` : null,
|
|
1123
1607
|
issue?.lastSeen ? `Last seen: ${issue.lastSeen}` : null,
|
|
1124
1608
|
environment ? `Environment: ${environment}` : null,
|
|
1609
|
+
releaseVersions.length > 0 ? `Release/app version: ${releaseVersions.join(', ')}` : null,
|
|
1125
1610
|
normalizeSentryIssueCount(issue) ? `Events: ${normalizeSentryIssueCount(issue)}` : null,
|
|
1126
1611
|
normalizeSentryUserCount(issue) ? `Affected users: ${normalizeSentryUserCount(issue)}` : null,
|
|
1127
1612
|
issue?.culprit ? `Culprit: ${issue.culprit}` : null,
|
|
1128
1613
|
].filter(Boolean);
|
|
1129
1614
|
}
|
|
1615
|
+
function extractSentryReleaseVersions(issue) {
|
|
1616
|
+
const values = [
|
|
1617
|
+
issue?.release,
|
|
1618
|
+
issue?.firstRelease?.version,
|
|
1619
|
+
issue?.firstRelease?.shortVersion,
|
|
1620
|
+
issue?.lastRelease?.version,
|
|
1621
|
+
issue?.lastRelease?.shortVersion,
|
|
1622
|
+
issue?.metadata?.release,
|
|
1623
|
+
issue?.metadata?.version,
|
|
1624
|
+
issue?.metadata?.appVersion,
|
|
1625
|
+
issue?.metadata?.['app.version'],
|
|
1626
|
+
];
|
|
1627
|
+
if (Array.isArray(issue?.tags)) {
|
|
1628
|
+
for (const tag of issue.tags) {
|
|
1629
|
+
const key = String(tag?.key || tag?.name || '').toLowerCase();
|
|
1630
|
+
if (/(release|version|app\.version|dist|build)/.test(key)) {
|
|
1631
|
+
values.push(tag?.value);
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
return [...new Set(values.map(normalizeVersionToken).filter(Boolean))].sort();
|
|
1636
|
+
}
|
|
1130
1637
|
function buildCombinedSentrySummary(input) {
|
|
1131
1638
|
const accounts = Array.isArray(input?.accounts) ? input.accounts : [];
|
|
1132
1639
|
const maxSignals = Math.max(1, Number(input?.maxSignals) || 5);
|
|
@@ -1148,11 +1655,14 @@ function buildCombinedSentrySummary(input) {
|
|
|
1148
1655
|
accountId,
|
|
1149
1656
|
accountLabel: label,
|
|
1150
1657
|
sourceProject: summary.project,
|
|
1658
|
+
app: issue.app || summary.project,
|
|
1151
1659
|
})));
|
|
1152
1660
|
const signals = summaries
|
|
1153
1661
|
.flatMap(({ accountId, label, summary }) => (Array.isArray(summary.signals) ? summary.signals : []).map((signal) => ({
|
|
1154
1662
|
...signal,
|
|
1155
1663
|
id: `${accountId}:${signal.id}`,
|
|
1664
|
+
app: signal.app || summary.project,
|
|
1665
|
+
sourceProject: summary.project,
|
|
1156
1666
|
evidence: [`Sentry account: ${label}`, `Sentry project: ${summary.project}`, ...(signal.evidence || [])],
|
|
1157
1667
|
keywords: [accountId, ...(signal.keywords || [])],
|
|
1158
1668
|
})))
|
|
@@ -1200,34 +1710,44 @@ export function buildSentrySummary(input) {
|
|
|
1200
1710
|
const maxSignals = Math.max(1, Number(input?.maxSignals) || 5);
|
|
1201
1711
|
const normalizedIssues = issues
|
|
1202
1712
|
.filter((issue) => issue && typeof issue === 'object')
|
|
1203
|
-
.map((issue, index) =>
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
?
|
|
1209
|
-
:
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
issue
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1713
|
+
.map((issue, index) => {
|
|
1714
|
+
const releaseVersions = extractSentryReleaseVersions(issue);
|
|
1715
|
+
const issueUrl = normalizeSentryIssueUrl(issue);
|
|
1716
|
+
return {
|
|
1717
|
+
id: String(issue.id || issue.shortId || `sentry_${index + 1}`),
|
|
1718
|
+
shortId: issue.shortId ? String(issue.shortId) : null,
|
|
1719
|
+
title: normalizeSentryIssueTitle(issue),
|
|
1720
|
+
priority: normalizeSentryPriority(issue),
|
|
1721
|
+
impact: normalizeSentryUserCount(issue) > 0
|
|
1722
|
+
? `${normalizeSentryUserCount(issue)} affected users in ${last}`
|
|
1723
|
+
: `Production stability issue observed in ${last}`,
|
|
1724
|
+
events: normalizeSentryIssueCount(issue),
|
|
1725
|
+
users: normalizeSentryUserCount(issue),
|
|
1726
|
+
releaseVersions,
|
|
1727
|
+
area: 'crash',
|
|
1728
|
+
metric: 'sentry_unresolved_issues',
|
|
1729
|
+
sourceUrl: issueUrl || null,
|
|
1730
|
+
issueUrl: issueUrl || null,
|
|
1731
|
+
app: org ? `sentry:${org}/${project}` : `sentry:${project}`,
|
|
1732
|
+
stack_keywords: [
|
|
1733
|
+
issue.level,
|
|
1734
|
+
issue.type,
|
|
1735
|
+
issue.platform,
|
|
1736
|
+
issue.metadata?.type,
|
|
1737
|
+
issue.culprit,
|
|
1738
|
+
...releaseVersions,
|
|
1739
|
+
]
|
|
1740
|
+
.filter(Boolean)
|
|
1741
|
+
.map((value) => String(value).slice(0, 80)),
|
|
1742
|
+
evidence: normalizeSentryEvidence(issue, environment),
|
|
1743
|
+
suggested_actions: [
|
|
1744
|
+
'Map this Sentry issue to the current production release and affected user journey',
|
|
1745
|
+
'Check whether the crash intersects onboarding, paywall, purchase, or first value events',
|
|
1746
|
+
'Fix or mitigate the highest-user-impact issue before running new growth experiments that send more traffic into the broken path',
|
|
1747
|
+
],
|
|
1748
|
+
confidence: 'high',
|
|
1749
|
+
};
|
|
1750
|
+
})
|
|
1231
1751
|
.sort((a, b) => {
|
|
1232
1752
|
const priorityDelta = priorityRank(b.priority) - priorityRank(a.priority);
|
|
1233
1753
|
if (priorityDelta !== 0)
|
|
@@ -1249,10 +1769,14 @@ export function buildSentrySummary(input) {
|
|
|
1249
1769
|
priority: issue.priority,
|
|
1250
1770
|
metric: issue.metric,
|
|
1251
1771
|
current_value: issue.events,
|
|
1772
|
+
releaseVersions: issue.releaseVersions,
|
|
1252
1773
|
evidence: issue.evidence,
|
|
1253
1774
|
suggested_actions: issue.suggested_actions,
|
|
1254
1775
|
keywords: issue.stack_keywords,
|
|
1255
1776
|
confidence: issue.confidence,
|
|
1777
|
+
app: issue.app,
|
|
1778
|
+
sourceUrl: issue.sourceUrl,
|
|
1779
|
+
issueUrl: issue.issueUrl,
|
|
1256
1780
|
})),
|
|
1257
1781
|
meta: {
|
|
1258
1782
|
generatedAt: new Date().toISOString(),
|
|
@@ -1264,6 +1788,186 @@ export function buildSentrySummary(input) {
|
|
|
1264
1788
|
},
|
|
1265
1789
|
};
|
|
1266
1790
|
}
|
|
1791
|
+
function normalizeCoolifyName(value, fallback) {
|
|
1792
|
+
const text = String(value || '').trim();
|
|
1793
|
+
return text || fallback;
|
|
1794
|
+
}
|
|
1795
|
+
function normalizeCoolifyDomains(resource) {
|
|
1796
|
+
const raw = resource?.domains ?? resource?.fqdn ?? resource?.domain ?? resource?.url ?? '';
|
|
1797
|
+
if (Array.isArray(raw))
|
|
1798
|
+
return raw.map((entry) => String(entry).trim()).filter(Boolean);
|
|
1799
|
+
return String(raw || '')
|
|
1800
|
+
.split(',')
|
|
1801
|
+
.map((entry) => entry.trim())
|
|
1802
|
+
.filter(Boolean);
|
|
1803
|
+
}
|
|
1804
|
+
function normalizeCoolifyStatus(value) {
|
|
1805
|
+
return String(value || '')
|
|
1806
|
+
.trim()
|
|
1807
|
+
.toLowerCase()
|
|
1808
|
+
.replace(/[^a-z0-9._-]+/g, '_');
|
|
1809
|
+
}
|
|
1810
|
+
function isCoolifyDeploymentFailed(deployment) {
|
|
1811
|
+
const status = normalizeCoolifyStatus(deployment?.status || deployment?.state);
|
|
1812
|
+
return Boolean(status && /(failed|error|cancelled|canceled|exited|unhealthy)/i.test(status));
|
|
1813
|
+
}
|
|
1814
|
+
function isCoolifyDeploymentRecent(deployment, sinceMs) {
|
|
1815
|
+
const candidates = [
|
|
1816
|
+
deployment?.created_at,
|
|
1817
|
+
deployment?.createdAt,
|
|
1818
|
+
deployment?.updated_at,
|
|
1819
|
+
deployment?.updatedAt,
|
|
1820
|
+
deployment?.finished_at,
|
|
1821
|
+
deployment?.finishedAt,
|
|
1822
|
+
];
|
|
1823
|
+
const timestamps = candidates
|
|
1824
|
+
.map((value) => Date.parse(String(value || '')))
|
|
1825
|
+
.filter((value) => Number.isFinite(value));
|
|
1826
|
+
if (timestamps.length === 0)
|
|
1827
|
+
return true;
|
|
1828
|
+
return Math.max(...timestamps) >= sinceMs;
|
|
1829
|
+
}
|
|
1830
|
+
function isCoolifyResourceUnhealthy(resource) {
|
|
1831
|
+
const status = normalizeCoolifyStatus(resource?.status || resource?.state || resource?.health);
|
|
1832
|
+
if (!status)
|
|
1833
|
+
return false;
|
|
1834
|
+
return /(unhealthy|failed|error|exited|stopped|dead|degraded|missing)/i.test(status);
|
|
1835
|
+
}
|
|
1836
|
+
function coolifyResourceLabel(resource, fallback) {
|
|
1837
|
+
return normalizeCoolifyName(resource?.name || resource?.application_name || resource?.service_name || resource?.uuid || resource?.id, fallback);
|
|
1838
|
+
}
|
|
1839
|
+
export function buildCoolifySummary(input) {
|
|
1840
|
+
const applications = Array.isArray(input?.applications) ? input.applications : [];
|
|
1841
|
+
const deployments = Array.isArray(input?.deployments) ? input.deployments : [];
|
|
1842
|
+
const resources = Array.isArray(input?.resources) ? input.resources : [];
|
|
1843
|
+
const servers = Array.isArray(input?.servers) ? input.servers : [];
|
|
1844
|
+
const warnings = Array.isArray(input?.warnings) ? input.warnings.map((entry) => String(entry)).filter(Boolean) : [];
|
|
1845
|
+
const baseUrl = String(input?.baseUrl || process.env.COOLIFY_BASE_URL || '').replace(/\/$/, '');
|
|
1846
|
+
const last = String(input?.last || input?.window || '24h');
|
|
1847
|
+
const maxSignals = Math.max(1, Number(input?.maxSignals) || 8);
|
|
1848
|
+
const durationValue = Number(last.slice(0, -1));
|
|
1849
|
+
const durationMs = Number.isFinite(durationValue) && last.endsWith('d')
|
|
1850
|
+
? durationValue * 24 * 60 * 60 * 1000
|
|
1851
|
+
: Number.isFinite(durationValue) && last.endsWith('h')
|
|
1852
|
+
? durationValue * 60 * 60 * 1000
|
|
1853
|
+
: Number.isFinite(durationValue) && last.endsWith('m')
|
|
1854
|
+
? durationValue * 60 * 1000
|
|
1855
|
+
: 24 * 60 * 60 * 1000;
|
|
1856
|
+
const sinceMs = Date.now() - durationMs;
|
|
1857
|
+
const signals = [];
|
|
1858
|
+
const failedDeployments = deployments
|
|
1859
|
+
.filter((deployment) => isCoolifyDeploymentFailed(deployment) && isCoolifyDeploymentRecent(deployment, sinceMs))
|
|
1860
|
+
.slice(0, maxSignals);
|
|
1861
|
+
if (failedDeployments.length > 0) {
|
|
1862
|
+
signals.push({
|
|
1863
|
+
id: 'coolify_failed_deployments',
|
|
1864
|
+
title: 'Coolify has recent failed deployments',
|
|
1865
|
+
area: 'crash',
|
|
1866
|
+
priority: failedDeployments.length >= 3 ? 'high' : 'medium',
|
|
1867
|
+
metric: 'coolify_failed_deployments',
|
|
1868
|
+
current_value: failedDeployments.length,
|
|
1869
|
+
evidence: failedDeployments.slice(0, 5).map((deployment) => {
|
|
1870
|
+
const app = coolifyResourceLabel(deployment, 'unknown resource');
|
|
1871
|
+
const status = deployment?.status || deployment?.state || 'unknown status';
|
|
1872
|
+
const when = deployment?.created_at || deployment?.createdAt || deployment?.finished_at || deployment?.finishedAt || '';
|
|
1873
|
+
return `${app}: ${status}${when ? ` at ${when}` : ''}`;
|
|
1874
|
+
}),
|
|
1875
|
+
suggested_actions: [
|
|
1876
|
+
'Open the failed Coolify deployment and inspect build/runtime logs before pushing more traffic to the app',
|
|
1877
|
+
'Correlate the failed deploy with Sentry issues, release changes, and AnalyticsCLI conversion or activation drops',
|
|
1878
|
+
'Fix the deployment blocker or roll back the affected service before running growth experiments',
|
|
1879
|
+
],
|
|
1880
|
+
keywords: ['coolify', 'deployment', 'hosting', 'production'],
|
|
1881
|
+
confidence: 'high',
|
|
1882
|
+
});
|
|
1883
|
+
}
|
|
1884
|
+
const unhealthyResources = [...applications, ...resources]
|
|
1885
|
+
.filter((resource) => isCoolifyResourceUnhealthy(resource))
|
|
1886
|
+
.slice(0, maxSignals);
|
|
1887
|
+
if (unhealthyResources.length > 0) {
|
|
1888
|
+
signals.push({
|
|
1889
|
+
id: 'coolify_unhealthy_resources',
|
|
1890
|
+
title: 'Coolify reports unhealthy or stopped resources',
|
|
1891
|
+
area: 'crash',
|
|
1892
|
+
priority: 'high',
|
|
1893
|
+
metric: 'coolify_unhealthy_resources',
|
|
1894
|
+
current_value: unhealthyResources.length,
|
|
1895
|
+
evidence: unhealthyResources.slice(0, 8).map((resource) => {
|
|
1896
|
+
const name = coolifyResourceLabel(resource, 'unknown resource');
|
|
1897
|
+
const status = resource?.status || resource?.state || resource?.health || 'unknown status';
|
|
1898
|
+
const domains = normalizeCoolifyDomains(resource);
|
|
1899
|
+
return `${name}: ${status}${domains.length ? ` (${domains.join(', ')})` : ''}`;
|
|
1900
|
+
}),
|
|
1901
|
+
suggested_actions: [
|
|
1902
|
+
'Restore or restart the unhealthy Coolify resource and verify its public domain before prioritizing product-growth work',
|
|
1903
|
+
'Check whether Sentry error volume and AnalyticsCLI active users changed after the resource became unhealthy',
|
|
1904
|
+
'Add or tighten health checks for the affected service so future incidents are caught earlier',
|
|
1905
|
+
],
|
|
1906
|
+
keywords: ['coolify', 'health', 'availability', 'hosting'],
|
|
1907
|
+
confidence: 'high',
|
|
1908
|
+
});
|
|
1909
|
+
}
|
|
1910
|
+
const publicAppsWithoutHealthChecks = applications
|
|
1911
|
+
.filter((app) => normalizeCoolifyDomains(app).length > 0 && app?.health_check_enabled === false)
|
|
1912
|
+
.slice(0, maxSignals);
|
|
1913
|
+
if (publicAppsWithoutHealthChecks.length > 0) {
|
|
1914
|
+
signals.push({
|
|
1915
|
+
id: 'coolify_public_apps_without_health_checks',
|
|
1916
|
+
title: 'Public Coolify applications are missing health checks',
|
|
1917
|
+
area: 'crash',
|
|
1918
|
+
priority: 'medium',
|
|
1919
|
+
metric: 'coolify_missing_health_checks',
|
|
1920
|
+
current_value: publicAppsWithoutHealthChecks.length,
|
|
1921
|
+
evidence: publicAppsWithoutHealthChecks.slice(0, 8).map((app) => {
|
|
1922
|
+
const name = coolifyResourceLabel(app, 'unknown application');
|
|
1923
|
+
return `${name}: ${normalizeCoolifyDomains(app).join(', ')}`;
|
|
1924
|
+
}),
|
|
1925
|
+
suggested_actions: [
|
|
1926
|
+
'Enable Coolify health checks for public production applications',
|
|
1927
|
+
'Use the health endpoint that best reflects the real app dependency path, not only process liveness',
|
|
1928
|
+
'Pair Coolify health-check alerts with Sentry and AnalyticsCLI anomaly checks in the daily guardrail',
|
|
1929
|
+
],
|
|
1930
|
+
keywords: ['coolify', 'health_check', 'production', 'monitoring'],
|
|
1931
|
+
confidence: 'medium',
|
|
1932
|
+
});
|
|
1933
|
+
}
|
|
1934
|
+
if (warnings.length > 0) {
|
|
1935
|
+
signals.push({
|
|
1936
|
+
id: 'coolify_api_partial_read',
|
|
1937
|
+
title: 'Coolify API summary is partial',
|
|
1938
|
+
area: 'general',
|
|
1939
|
+
priority: 'low',
|
|
1940
|
+
metric: 'coolify_api_warnings',
|
|
1941
|
+
current_value: warnings.length,
|
|
1942
|
+
evidence: warnings.slice(0, 8),
|
|
1943
|
+
suggested_actions: [
|
|
1944
|
+
'Verify the Coolify API token has read-only access to the team that owns the production resources',
|
|
1945
|
+
'Keep the token read-only; only expand permissions if a specific API endpoint requires it',
|
|
1946
|
+
],
|
|
1947
|
+
keywords: ['coolify', 'api', 'token', 'permissions'],
|
|
1948
|
+
confidence: 'medium',
|
|
1949
|
+
});
|
|
1950
|
+
}
|
|
1951
|
+
return {
|
|
1952
|
+
project: baseUrl ? `coolify:${baseUrl}` : 'coolify',
|
|
1953
|
+
window: normalizeWindow(last),
|
|
1954
|
+
applications,
|
|
1955
|
+
deployments,
|
|
1956
|
+
resources,
|
|
1957
|
+
servers,
|
|
1958
|
+
signals: signals.slice(0, maxSignals),
|
|
1959
|
+
meta: {
|
|
1960
|
+
generatedAt: new Date().toISOString(),
|
|
1961
|
+
source: 'coolify',
|
|
1962
|
+
baseUrl: baseUrl || null,
|
|
1963
|
+
applicationsReturned: applications.length,
|
|
1964
|
+
deploymentsReturned: deployments.length,
|
|
1965
|
+
resourcesReturned: resources.length,
|
|
1966
|
+
serversReturned: servers.length,
|
|
1967
|
+
warnings,
|
|
1968
|
+
},
|
|
1969
|
+
};
|
|
1970
|
+
}
|
|
1267
1971
|
export async function writeJsonOutput(outPath, payload) {
|
|
1268
1972
|
const serialized = `${JSON.stringify(payload, null, 2)}\n`;
|
|
1269
1973
|
if (outPath) {
|