@analyticscli/growth-engineer 0.1.0-preview.15 → 0.1.0-preview.18

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