@analyticscli/growth-engineer 0.1.0-preview.8 → 0.1.0
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 +925 -45
- package/dist/config.js +58 -6
- package/dist/config.js.map +1 -1
- package/dist/index.js +134 -21
- package/dist/index.js.map +1 -1
- package/dist/runtime/export-asc-summary.mjs +295 -4
- 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 +51 -0
- package/dist/runtime/openclaw-exporters-lib.mjs +769 -63
- 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 +446 -30
- package/dist/runtime/openclaw-growth-preflight.mjs.map +1 -1
- package/dist/runtime/openclaw-growth-runner.mjs +847 -150
- package/dist/runtime/openclaw-growth-runner.mjs.map +1 -1
- package/dist/runtime/openclaw-growth-shared.d.mts +158 -3
- package/dist/runtime/openclaw-growth-shared.mjs +574 -8
- package/dist/runtime/openclaw-growth-shared.mjs.map +1 -1
- package/dist/runtime/openclaw-growth-start.mjs +816 -41
- package/dist/runtime/openclaw-growth-start.mjs.map +1 -1
- package/dist/runtime/openclaw-growth-status.mjs +100 -34
- package/dist/runtime/openclaw-growth-status.mjs.map +1 -1
- package/dist/runtime/openclaw-growth-wizard.mjs +1997 -226
- package/dist/runtime/openclaw-growth-wizard.mjs.map +1 -1
- package/package.json +3 -1
- package/templates/config.example.json +128 -65
|
@@ -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, [
|
|
@@ -611,7 +689,7 @@ export function buildAscSummary(input) {
|
|
|
611
689
|
...extractReviewTexts(input?.feedbackPayload),
|
|
612
690
|
];
|
|
613
691
|
const topThemes = rankKeywordThemes(reviewTexts).slice(0, 2);
|
|
614
|
-
const analyticsMetricsPayload = input?.analyticsMetricsPayload || input?.analyticsOverviewPayload;
|
|
692
|
+
const analyticsMetricsPayload = input?.batchAnalyticsPayload || input?.analyticsMetricsPayload || input?.analyticsOverviewPayload;
|
|
615
693
|
const unitsMetric = findAscMetric(analyticsMetricsPayload, 'units');
|
|
616
694
|
const redownloadsMetric = findAscMetric(analyticsMetricsPayload, 'redownloads');
|
|
617
695
|
const conversionRateMetric = findAscMetric(analyticsMetricsPayload || input?.analyticsOverviewPayload, 'conversionRate');
|
|
@@ -623,11 +701,12 @@ export function buildAscSummary(input) {
|
|
|
623
701
|
const totalCrashes = totalAscCrashes(input?.analyticsOverviewPayload);
|
|
624
702
|
const analyticsWarnings = Array.isArray(input?.analyticsWarnings) ? input.analyticsWarnings : [];
|
|
625
703
|
const webAuthMissing = isLikelyAscWebAuthMissing(analyticsWarnings);
|
|
704
|
+
const batchReports = Array.isArray(input?.batchReports) ? input.batchReports : [];
|
|
626
705
|
const analyticsAvailability = webAuthMissing
|
|
627
706
|
? 'web_auth_missing'
|
|
628
707
|
: analyticsWarnings.some((warning) => String(warning).includes('403'))
|
|
629
708
|
? 'not_public_or_not_analytics_ready'
|
|
630
|
-
: unitsMetric || conversionRateMetric || sourceBreakdown.length > 0 || totalCrashes > 0
|
|
709
|
+
: unitsMetric || conversionRateMetric || sourceBreakdown.length > 0 || totalCrashes > 0 || batchReports.length > 0
|
|
631
710
|
? 'available'
|
|
632
711
|
: 'unknown';
|
|
633
712
|
const overviewMetricCatalog = collectAscOverviewMetricCatalog(input?.analyticsOverviewPayload);
|
|
@@ -666,9 +745,9 @@ export function buildAscSummary(input) {
|
|
|
666
745
|
delta_percent: -100,
|
|
667
746
|
evidence: analyticsWarnings.slice(0, 3),
|
|
668
747
|
suggested_actions: [
|
|
669
|
-
'
|
|
670
|
-
'
|
|
671
|
-
'
|
|
748
|
+
'Ask the OpenClaw user whether to enable experimental ASC web analytics for the specific missing metric that API-key batch reports could not provide',
|
|
749
|
+
'If the user accepts, set ASC_WEB_APPLE_ID in the host terminal and run: asc web auth login --apple-id "$ASC_WEB_APPLE_ID"',
|
|
750
|
+
'Continue using API-key ASC batch reports if the user declines or the Apple Account web session expires again',
|
|
672
751
|
],
|
|
673
752
|
keywords: ['asc', 'web_analytics', 'login', 'connector'],
|
|
674
753
|
});
|
|
@@ -788,7 +867,7 @@ export function buildAscSummary(input) {
|
|
|
788
867
|
delta_percent: null,
|
|
789
868
|
evidence: notableOverviewMetrics.map(formatMetricMovement),
|
|
790
869
|
suggested_actions: [
|
|
791
|
-
'Analyze every available ASC
|
|
870
|
+
'Analyze every available ASC batch-report metric together with units, conversion, sources, AnalyticsCLI funnels, Sentry stability, and reviews before choosing a recommendation',
|
|
792
871
|
'Keep financial metrics secondary unless the user asks, but still use them as validation for acquisition and conversion quality',
|
|
793
872
|
],
|
|
794
873
|
keywords: ['asc', 'analytics', 'overview_metrics', 'conversion', 'handlungsempfehlung'],
|
|
@@ -889,6 +968,8 @@ export function buildAscSummary(input) {
|
|
|
889
968
|
analyticsWindow: input?.analyticsWindow || null,
|
|
890
969
|
analyticsAvailability,
|
|
891
970
|
analyticsWarnings,
|
|
971
|
+
batchReports,
|
|
972
|
+
productionVersions,
|
|
892
973
|
analytics: {
|
|
893
974
|
units: unitsMetric
|
|
894
975
|
? {
|
|
@@ -1092,6 +1173,399 @@ export function buildRevenueCatSummary(input) {
|
|
|
1092
1173
|
},
|
|
1093
1174
|
};
|
|
1094
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
|
+
}
|
|
1095
1569
|
function normalizeSentryIssueCount(issue) {
|
|
1096
1570
|
return coerceNumber(issue?.count ?? issue?.events ?? issue?.eventCount ?? issue?.stats?.sum) || 0;
|
|
1097
1571
|
}
|
|
@@ -1101,6 +1575,16 @@ function normalizeSentryUserCount(issue) {
|
|
|
1101
1575
|
function normalizeSentryIssueTitle(issue) {
|
|
1102
1576
|
return String(issue?.title || issue?.metadata?.title || issue?.culprit || 'Untitled Sentry issue').trim();
|
|
1103
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
|
+
}
|
|
1104
1588
|
function normalizeSentryPriority(issue) {
|
|
1105
1589
|
const level = String(issue?.level || issue?.priority || '').toLowerCase();
|
|
1106
1590
|
const events = normalizeSentryIssueCount(issue);
|
|
@@ -1112,19 +1596,44 @@ function normalizeSentryPriority(issue) {
|
|
|
1112
1596
|
return 'low';
|
|
1113
1597
|
}
|
|
1114
1598
|
function normalizeSentryEvidence(issue, environment) {
|
|
1599
|
+
const releaseVersions = extractSentryReleaseVersions(issue);
|
|
1600
|
+
const issueUrl = normalizeSentryIssueUrl(issue);
|
|
1115
1601
|
return [
|
|
1116
1602
|
issue?.shortId ? `Sentry issue: ${issue.shortId}` : issue?.id ? `Sentry issue id: ${issue.id}` : null,
|
|
1117
|
-
|
|
1603
|
+
issueUrl ? `Issue link: ${issueUrl}` : null,
|
|
1118
1604
|
issue?.level ? `Level: ${issue.level}` : null,
|
|
1119
1605
|
issue?.status ? `Status: ${issue.status}` : null,
|
|
1120
1606
|
issue?.firstSeen ? `First seen: ${issue.firstSeen}` : null,
|
|
1121
1607
|
issue?.lastSeen ? `Last seen: ${issue.lastSeen}` : null,
|
|
1122
1608
|
environment ? `Environment: ${environment}` : null,
|
|
1609
|
+
releaseVersions.length > 0 ? `Release/app version: ${releaseVersions.join(', ')}` : null,
|
|
1123
1610
|
normalizeSentryIssueCount(issue) ? `Events: ${normalizeSentryIssueCount(issue)}` : null,
|
|
1124
1611
|
normalizeSentryUserCount(issue) ? `Affected users: ${normalizeSentryUserCount(issue)}` : null,
|
|
1125
1612
|
issue?.culprit ? `Culprit: ${issue.culprit}` : null,
|
|
1126
1613
|
].filter(Boolean);
|
|
1127
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
|
+
}
|
|
1128
1637
|
function buildCombinedSentrySummary(input) {
|
|
1129
1638
|
const accounts = Array.isArray(input?.accounts) ? input.accounts : [];
|
|
1130
1639
|
const maxSignals = Math.max(1, Number(input?.maxSignals) || 5);
|
|
@@ -1146,11 +1655,14 @@ function buildCombinedSentrySummary(input) {
|
|
|
1146
1655
|
accountId,
|
|
1147
1656
|
accountLabel: label,
|
|
1148
1657
|
sourceProject: summary.project,
|
|
1658
|
+
app: issue.app || summary.project,
|
|
1149
1659
|
})));
|
|
1150
1660
|
const signals = summaries
|
|
1151
1661
|
.flatMap(({ accountId, label, summary }) => (Array.isArray(summary.signals) ? summary.signals : []).map((signal) => ({
|
|
1152
1662
|
...signal,
|
|
1153
1663
|
id: `${accountId}:${signal.id}`,
|
|
1664
|
+
app: signal.app || summary.project,
|
|
1665
|
+
sourceProject: summary.project,
|
|
1154
1666
|
evidence: [`Sentry account: ${label}`, `Sentry project: ${summary.project}`, ...(signal.evidence || [])],
|
|
1155
1667
|
keywords: [accountId, ...(signal.keywords || [])],
|
|
1156
1668
|
})))
|
|
@@ -1198,34 +1710,44 @@ export function buildSentrySummary(input) {
|
|
|
1198
1710
|
const maxSignals = Math.max(1, Number(input?.maxSignals) || 5);
|
|
1199
1711
|
const normalizedIssues = issues
|
|
1200
1712
|
.filter((issue) => issue && typeof issue === 'object')
|
|
1201
|
-
.map((issue, index) =>
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
?
|
|
1207
|
-
:
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
issue
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
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
|
+
})
|
|
1229
1751
|
.sort((a, b) => {
|
|
1230
1752
|
const priorityDelta = priorityRank(b.priority) - priorityRank(a.priority);
|
|
1231
1753
|
if (priorityDelta !== 0)
|
|
@@ -1247,10 +1769,14 @@ export function buildSentrySummary(input) {
|
|
|
1247
1769
|
priority: issue.priority,
|
|
1248
1770
|
metric: issue.metric,
|
|
1249
1771
|
current_value: issue.events,
|
|
1772
|
+
releaseVersions: issue.releaseVersions,
|
|
1250
1773
|
evidence: issue.evidence,
|
|
1251
1774
|
suggested_actions: issue.suggested_actions,
|
|
1252
1775
|
keywords: issue.stack_keywords,
|
|
1253
1776
|
confidence: issue.confidence,
|
|
1777
|
+
app: issue.app,
|
|
1778
|
+
sourceUrl: issue.sourceUrl,
|
|
1779
|
+
issueUrl: issue.issueUrl,
|
|
1254
1780
|
})),
|
|
1255
1781
|
meta: {
|
|
1256
1782
|
generatedAt: new Date().toISOString(),
|
|
@@ -1262,6 +1788,186 @@ export function buildSentrySummary(input) {
|
|
|
1262
1788
|
},
|
|
1263
1789
|
};
|
|
1264
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
|
+
}
|
|
1265
1971
|
export async function writeJsonOutput(outPath, payload) {
|
|
1266
1972
|
const serialized = `${JSON.stringify(payload, null, 2)}\n`;
|
|
1267
1973
|
if (outPath) {
|