@analyticscli/growth-engineer 0.1.0-preview.9 → 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.
Files changed (40) hide show
  1. package/dist/config.d.ts +925 -45
  2. package/dist/config.js +58 -6
  3. package/dist/config.js.map +1 -1
  4. package/dist/index.js +134 -21
  5. package/dist/index.js.map +1 -1
  6. package/dist/runtime/export-asc-summary.mjs +295 -4
  7. package/dist/runtime/export-asc-summary.mjs.map +1 -1
  8. package/dist/runtime/export-coolify-summary.d.mts +2 -0
  9. package/dist/runtime/export-coolify-summary.mjs +230 -0
  10. package/dist/runtime/export-coolify-summary.mjs.map +1 -0
  11. package/dist/runtime/export-paddle-summary.d.mts +2 -0
  12. package/dist/runtime/export-paddle-summary.mjs +170 -0
  13. package/dist/runtime/export-paddle-summary.mjs.map +1 -0
  14. package/dist/runtime/export-sentry-summary.mjs +265 -38
  15. package/dist/runtime/export-sentry-summary.mjs.map +1 -1
  16. package/dist/runtime/export-seo-summary.d.mts +2 -0
  17. package/dist/runtime/export-seo-summary.mjs +503 -0
  18. package/dist/runtime/export-seo-summary.mjs.map +1 -0
  19. package/dist/runtime/openclaw-exporters-lib.d.mts +51 -0
  20. package/dist/runtime/openclaw-exporters-lib.mjs +769 -63
  21. package/dist/runtime/openclaw-exporters-lib.mjs.map +1 -1
  22. package/dist/runtime/openclaw-growth-engineer.mjs +163 -4
  23. package/dist/runtime/openclaw-growth-engineer.mjs.map +1 -1
  24. package/dist/runtime/openclaw-growth-env.mjs +5 -0
  25. package/dist/runtime/openclaw-growth-env.mjs.map +1 -1
  26. package/dist/runtime/openclaw-growth-preflight.mjs +446 -30
  27. package/dist/runtime/openclaw-growth-preflight.mjs.map +1 -1
  28. package/dist/runtime/openclaw-growth-runner.mjs +831 -146
  29. package/dist/runtime/openclaw-growth-runner.mjs.map +1 -1
  30. package/dist/runtime/openclaw-growth-shared.d.mts +158 -3
  31. package/dist/runtime/openclaw-growth-shared.mjs +574 -8
  32. package/dist/runtime/openclaw-growth-shared.mjs.map +1 -1
  33. package/dist/runtime/openclaw-growth-start.mjs +802 -39
  34. package/dist/runtime/openclaw-growth-start.mjs.map +1 -1
  35. package/dist/runtime/openclaw-growth-status.mjs +85 -31
  36. package/dist/runtime/openclaw-growth-status.mjs.map +1 -1
  37. package/dist/runtime/openclaw-growth-wizard.mjs +1952 -217
  38. package/dist/runtime/openclaw-growth-wizard.mjs.map +1 -1
  39. package/package.json +3 -1
  40. 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
- for (const target of retentionTargets) {
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: `retention_d${target.day}_below_target`,
241
- title: retentionHasLowConfidence
242
- ? `Day-${target.day} retention appears below target, but identity quality is low`
243
- : `Day-${target.day} retention is below target`,
244
- area: 'retention',
245
- priority: retentionHasLowConfidence ? 'medium' : target.day >= 3 ? 'high' : 'medium',
246
- metric: `d${target.day}_retention`,
247
- current_value: round(actual),
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?.avgActiveDays !== undefined
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
- retentionHasLowConfidence
260
- ? 'Verify SDK identity persistence and rerun retention with stable identity filtering before treating D1/D7 as a product fact'
261
- : null,
262
- 'Revisit the first-session value loop and ensure the core action completes quickly',
263
- 'Add targeted re-entry prompts or reminders after the first session',
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
- break;
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
- 'Tell the OpenClaw user to run: asc web auth login',
670
- 'After login, verify with: asc web auth status --output json --pretty',
671
- 'Retry the ASC exporter so units, conversion, source traffic, and production crash totals are available',
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 overview metric together with units, conversion, sources, AnalyticsCLI funnels, Sentry stability, and reviews before choosing a recommendation',
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
- issue?.permalink ? `Permalink: ${issue.permalink}` : null,
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
- id: String(issue.id || issue.shortId || `sentry_${index + 1}`),
1203
- title: normalizeSentryIssueTitle(issue),
1204
- priority: normalizeSentryPriority(issue),
1205
- impact: normalizeSentryUserCount(issue) > 0
1206
- ? `${normalizeSentryUserCount(issue)} affected users in ${last}`
1207
- : `Production stability issue observed in ${last}`,
1208
- events: normalizeSentryIssueCount(issue),
1209
- users: normalizeSentryUserCount(issue),
1210
- area: 'crash',
1211
- metric: 'sentry_unresolved_issues',
1212
- stack_keywords: [
1213
- issue.level,
1214
- issue.type,
1215
- issue.platform,
1216
- issue.metadata?.type,
1217
- issue.culprit,
1218
- ]
1219
- .filter(Boolean)
1220
- .map((value) => String(value).slice(0, 80)),
1221
- evidence: normalizeSentryEvidence(issue, environment),
1222
- suggested_actions: [
1223
- 'Map this Sentry issue to the current production release and affected user journey',
1224
- 'Check whether the crash intersects onboarding, paywall, purchase, or first value events',
1225
- 'Fix or mitigate the highest-user-impact issue before running new growth experiments that send more traffic into the broken path',
1226
- ],
1227
- confidence: 'high',
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) {