@analyticscli/growth-engineer 0.1.0-preview.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 (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +26 -0
  3. package/dist/config.d.ts +1663 -0
  4. package/dist/config.js +266 -0
  5. package/dist/config.js.map +1 -0
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.js +1188 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/runtime/export-analytics-summary.d.mts +2 -0
  10. package/dist/runtime/export-analytics-summary.mjs +303 -0
  11. package/dist/runtime/export-analytics-summary.mjs.map +1 -0
  12. package/dist/runtime/export-asc-summary.d.mts +2 -0
  13. package/dist/runtime/export-asc-summary.mjs +376 -0
  14. package/dist/runtime/export-asc-summary.mjs.map +1 -0
  15. package/dist/runtime/export-revenuecat-summary.d.mts +2 -0
  16. package/dist/runtime/export-revenuecat-summary.mjs +176 -0
  17. package/dist/runtime/export-revenuecat-summary.mjs.map +1 -0
  18. package/dist/runtime/export-sentry-summary.d.mts +2 -0
  19. package/dist/runtime/export-sentry-summary.mjs +352 -0
  20. package/dist/runtime/export-sentry-summary.mjs.map +1 -0
  21. package/dist/runtime/openclaw-exporters-lib.d.mts +101 -0
  22. package/dist/runtime/openclaw-exporters-lib.mjs +1276 -0
  23. package/dist/runtime/openclaw-exporters-lib.mjs.map +1 -0
  24. package/dist/runtime/openclaw-feedback-api.d.mts +2 -0
  25. package/dist/runtime/openclaw-feedback-api.mjs +255 -0
  26. package/dist/runtime/openclaw-feedback-api.mjs.map +1 -0
  27. package/dist/runtime/openclaw-growth-charts.py +154 -0
  28. package/dist/runtime/openclaw-growth-engineer.d.mts +2 -0
  29. package/dist/runtime/openclaw-growth-engineer.mjs +1258 -0
  30. package/dist/runtime/openclaw-growth-engineer.mjs.map +1 -0
  31. package/dist/runtime/openclaw-growth-env.d.mts +9 -0
  32. package/dist/runtime/openclaw-growth-env.mjs +125 -0
  33. package/dist/runtime/openclaw-growth-env.mjs.map +1 -0
  34. package/dist/runtime/openclaw-growth-preflight.d.mts +2 -0
  35. package/dist/runtime/openclaw-growth-preflight.mjs +1111 -0
  36. package/dist/runtime/openclaw-growth-preflight.mjs.map +1 -0
  37. package/dist/runtime/openclaw-growth-runner.d.mts +2 -0
  38. package/dist/runtime/openclaw-growth-runner.mjs +1302 -0
  39. package/dist/runtime/openclaw-growth-runner.mjs.map +1 -0
  40. package/dist/runtime/openclaw-growth-shared.d.mts +33 -0
  41. package/dist/runtime/openclaw-growth-shared.mjs +208 -0
  42. package/dist/runtime/openclaw-growth-shared.mjs.map +1 -0
  43. package/dist/runtime/openclaw-growth-start.d.mts +2 -0
  44. package/dist/runtime/openclaw-growth-start.mjs +1575 -0
  45. package/dist/runtime/openclaw-growth-start.mjs.map +1 -0
  46. package/dist/runtime/openclaw-growth-status.d.mts +2 -0
  47. package/dist/runtime/openclaw-growth-status.mjs +387 -0
  48. package/dist/runtime/openclaw-growth-status.mjs.map +1 -0
  49. package/dist/runtime/openclaw-growth-wizard.d.mts +2 -0
  50. package/dist/runtime/openclaw-growth-wizard.mjs +3519 -0
  51. package/dist/runtime/openclaw-growth-wizard.mjs.map +1 -0
  52. package/dist/shell.d.ts +17 -0
  53. package/dist/shell.js +40 -0
  54. package/dist/shell.js.map +1 -0
  55. package/package.json +38 -0
  56. package/templates/analytics_summary.example.json +40 -0
  57. package/templates/config.example.json +197 -0
  58. package/templates/feedback_summary.example.json +37 -0
  59. package/templates/revenuecat_summary.example.json +25 -0
  60. package/templates/sentry_summary.example.json +23 -0
@@ -0,0 +1,1276 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ function coerceNumber(value) {
4
+ if (typeof value === 'number' && Number.isFinite(value)) {
5
+ return value;
6
+ }
7
+ if (typeof value === 'string' && value.trim()) {
8
+ const parsed = Number(value);
9
+ if (Number.isFinite(parsed)) {
10
+ return parsed;
11
+ }
12
+ }
13
+ return null;
14
+ }
15
+ function coerceRatioFromPercent(value) {
16
+ const numeric = coerceNumber(value);
17
+ if (numeric === null)
18
+ return null;
19
+ return numeric / 100;
20
+ }
21
+ function round(value, digits = 4) {
22
+ if (!Number.isFinite(value))
23
+ return 0;
24
+ return Number(value.toFixed(digits));
25
+ }
26
+ function computeDeltaPercent(currentValue, baselineValue) {
27
+ if (!Number.isFinite(currentValue) || !Number.isFinite(baselineValue)) {
28
+ return null;
29
+ }
30
+ if (Math.abs(baselineValue) < 1e-9) {
31
+ if (Math.abs(currentValue) < 1e-9)
32
+ return 0;
33
+ return currentValue > 0 ? 100 : -100;
34
+ }
35
+ return round(((currentValue - baselineValue) / Math.abs(baselineValue)) * 100, 2);
36
+ }
37
+ function normalizeWindow(last) {
38
+ const normalized = String(last || '30d')
39
+ .trim()
40
+ .toLowerCase();
41
+ if (!normalized)
42
+ return 'last_30d';
43
+ if (normalized.startsWith('last_'))
44
+ return normalized;
45
+ return `last_${normalized}`;
46
+ }
47
+ function priorityRank(priority) {
48
+ if (priority === 'high')
49
+ return 3;
50
+ if (priority === 'medium')
51
+ return 2;
52
+ return 1;
53
+ }
54
+ function sortSignals(signals) {
55
+ return [...signals].sort((a, b) => {
56
+ const priorityDelta = priorityRank(String(b.priority || 'low')) - priorityRank(String(a.priority || 'low'));
57
+ if (priorityDelta !== 0)
58
+ return priorityDelta;
59
+ const deltaA = Math.abs(coerceNumber(a.delta_percent ?? a.deltaPercent) || 0);
60
+ const deltaB = Math.abs(coerceNumber(b.delta_percent ?? b.deltaPercent) || 0);
61
+ return deltaB - deltaA;
62
+ });
63
+ }
64
+ function hasMinimumSample(value, minimum = 20) {
65
+ const numeric = coerceNumber(value);
66
+ return numeric !== null && numeric >= minimum;
67
+ }
68
+ function normalizeRetentionReliability(retention) {
69
+ if (!retention?.quality || typeof retention.quality !== 'object') {
70
+ return null;
71
+ }
72
+ const value = String(retention?.quality?.reliability || '')
73
+ .trim()
74
+ .toLowerCase();
75
+ if (value === 'high' || value === 'medium' || value === 'low' || value === 'unknown') {
76
+ return value;
77
+ }
78
+ return 'unknown';
79
+ }
80
+ function buildRetentionQualityEvidence(retention) {
81
+ const quality = retention?.quality;
82
+ if (!quality || typeof quality !== 'object') {
83
+ return [];
84
+ }
85
+ const evidence = [`Retention reliability: ${normalizeRetentionReliability(retention)}`];
86
+ const stableShare = coerceNumber(quality.stableIdentityShare);
87
+ const multiSessionShare = coerceNumber(quality.multiSessionShare);
88
+ if (stableShare !== null) {
89
+ evidence.push(`Stable identity share: ${(stableShare * 100).toFixed(1)}%`);
90
+ }
91
+ if (multiSessionShare !== null) {
92
+ evidence.push(`Multi-session identity share: ${(multiSessionShare * 100).toFixed(1)}%`);
93
+ }
94
+ if (Array.isArray(quality.warnings)) {
95
+ for (const warning of quality.warnings.slice(0, 2)) {
96
+ if (typeof warning === 'string' && warning.trim()) {
97
+ evidence.push(`Retention caveat: ${warning.trim()}`);
98
+ }
99
+ }
100
+ }
101
+ return evidence;
102
+ }
103
+ function maybePushSignal(signals, signal) {
104
+ if (!signal)
105
+ return;
106
+ signals.push(signal);
107
+ }
108
+ function buildAnalyticsTrendEvidence(label, trend) {
109
+ if (!trend || typeof trend !== 'object')
110
+ return null;
111
+ const direction = String(trend.direction || '').trim();
112
+ const percentChange = coerceNumber(trend.percentChange);
113
+ const startValue = coerceNumber(trend.startValue);
114
+ const currentValue = coerceNumber(trend.currentValue);
115
+ if (!direction || percentChange === null || startValue === null || currentValue === null) {
116
+ return null;
117
+ }
118
+ const signed = percentChange > 0 ? `+${percentChange}%` : `${percentChange}%`;
119
+ return `${label} trend: ${direction} ${signed} (start=${startValue}, current=${currentValue})`;
120
+ }
121
+ export function buildAnalyticsSummary(input) {
122
+ const last = String(input?.last || '30d');
123
+ const onboardingJourney = input?.onboardingJourney || null;
124
+ const retention = input?.retention || null;
125
+ const project = String(onboardingJourney?.projectId || input?.projectId || input?.project || 'analyticscli-project').trim() || 'analyticscli-project';
126
+ const signals = [];
127
+ const starters = coerceNumber(onboardingJourney?.starters) || 0;
128
+ const paywallReachedUsers = coerceNumber(onboardingJourney?.paywallReachedUsers) || 0;
129
+ const completionRate = coerceRatioFromPercent(onboardingJourney?.completionRate);
130
+ const paywallSkipRate = coerceRatioFromPercent(onboardingJourney?.paywallSkipRateFromPaywall);
131
+ const purchaseRateFromPaywall = coerceRatioFromPercent(onboardingJourney?.purchaseRateFromPaywall);
132
+ if (hasMinimumSample(starters)) {
133
+ const completionBaseline = 0.6;
134
+ if (completionRate !== null && completionRate < completionBaseline) {
135
+ maybePushSignal(signals, {
136
+ id: 'onboarding_completion_below_target',
137
+ title: 'Onboarding completion rate is below target',
138
+ area: 'onboarding',
139
+ priority: completionRate < 0.45 ? 'high' : 'medium',
140
+ metric: 'onboarding_completion_rate',
141
+ current_value: round(completionRate),
142
+ baseline_value: completionBaseline,
143
+ delta_percent: computeDeltaPercent(completionRate, completionBaseline),
144
+ evidence: [
145
+ `${onboardingJourney?.completedUsers || 0} of ${starters} onboarding starters completed successfully`,
146
+ onboardingJourney?.paywallAnchorEvent
147
+ ? `Paywall anchor event in the flow: ${onboardingJourney.paywallAnchorEvent}`
148
+ : 'No stable paywall anchor event detected in the onboarding journey payload',
149
+ buildAnalyticsTrendEvidence('Completion rate', onboardingJourney?.trends?.completionRate),
150
+ ].filter(Boolean),
151
+ suggested_actions: [
152
+ 'Shorten the onboarding path before the first value moment',
153
+ 'Delay monetization or permission friction until after the first core success event',
154
+ 'Inspect the heaviest drop-off steps in the onboarding journey and simplify one of them',
155
+ ],
156
+ keywords: ['onboarding', 'completion', 'dropoff', 'first_value'],
157
+ });
158
+ }
159
+ }
160
+ if (hasMinimumSample(paywallReachedUsers)) {
161
+ const paywallSkipBaseline = 0.45;
162
+ if (paywallSkipRate !== null && paywallSkipRate > paywallSkipBaseline) {
163
+ maybePushSignal(signals, {
164
+ id: 'paywall_skip_rate_above_target',
165
+ title: 'Paywall skip rate is above target',
166
+ area: 'paywall',
167
+ priority: paywallSkipRate > 0.6 ? 'high' : 'medium',
168
+ metric: 'paywall_skip_rate',
169
+ current_value: round(paywallSkipRate),
170
+ baseline_value: paywallSkipBaseline,
171
+ delta_percent: computeDeltaPercent(paywallSkipRate, paywallSkipBaseline),
172
+ evidence: [
173
+ `${onboardingJourney?.paywallSkippedUsers || 0} users skipped after ${paywallReachedUsers} reached the paywall`,
174
+ onboardingJourney?.paywallSkipEvent
175
+ ? `Most visible skip event: ${onboardingJourney.paywallSkipEvent}`
176
+ : 'No stable skip event detected in the onboarding journey payload',
177
+ buildAnalyticsTrendEvidence('Paywall reached rate', onboardingJourney?.trends?.paywallReachedRate),
178
+ ].filter(Boolean),
179
+ suggested_actions: [
180
+ 'Clarify the premium value proposition and annual-vs-monthly trade-off',
181
+ 'Reduce cognitive load on the first paywall view and tighten the CTA hierarchy',
182
+ 'Test a later paywall placement after a stronger proof-of-value moment',
183
+ ],
184
+ keywords: ['paywall', 'skip', 'pricing', 'conversion'],
185
+ });
186
+ }
187
+ const purchaseBaseline = 0.12;
188
+ if (purchaseRateFromPaywall !== null && purchaseRateFromPaywall < purchaseBaseline) {
189
+ maybePushSignal(signals, {
190
+ id: 'paywall_purchase_rate_below_target',
191
+ title: 'Paywall-to-purchase conversion is below target',
192
+ area: 'conversion',
193
+ priority: purchaseRateFromPaywall < 0.06 ? 'high' : 'medium',
194
+ metric: 'purchase_rate_from_paywall',
195
+ current_value: round(purchaseRateFromPaywall),
196
+ baseline_value: purchaseBaseline,
197
+ delta_percent: computeDeltaPercent(purchaseRateFromPaywall, purchaseBaseline),
198
+ evidence: [
199
+ `${onboardingJourney?.purchasedUsers || 0} purchases from ${paywallReachedUsers} paywall exposures`,
200
+ onboardingJourney?.purchaseEvent
201
+ ? `Purchase success event observed: ${onboardingJourney.purchaseEvent}`
202
+ : 'No stable purchase success event detected in the onboarding journey payload',
203
+ buildAnalyticsTrendEvidence('Purchase rate', onboardingJourney?.trends?.purchaseRate),
204
+ ].filter(Boolean),
205
+ suggested_actions: [
206
+ 'Simplify the paywall package comparison and highlight the default recommended offer',
207
+ 'Reduce ambiguity around trial terms, pricing cadence, and restore flow',
208
+ 'Test a stronger trust/benefit section near the purchase CTA',
209
+ ],
210
+ keywords: ['purchase', 'paywall', 'subscription', 'conversion'],
211
+ });
212
+ }
213
+ }
214
+ const retentionByDay = new Map(Array.isArray(retention?.days)
215
+ ? retention.days
216
+ .map((entry) => {
217
+ const day = coerceNumber(entry?.day);
218
+ const rate = coerceNumber(entry?.retentionRate);
219
+ if (day === null || rate === null)
220
+ return null;
221
+ return [day, rate];
222
+ })
223
+ .filter((entry) => entry !== null)
224
+ : []);
225
+ const retentionTargets = [
226
+ { day: 7, baseline: 0.1 },
227
+ { day: 3, baseline: 0.2 },
228
+ { day: 1, baseline: 0.35 },
229
+ ];
230
+ const retentionReliability = normalizeRetentionReliability(retention);
231
+ const retentionHasLowConfidence = retentionReliability === 'low' || retentionReliability === 'unknown';
232
+ const retentionQualityEvidence = buildRetentionQualityEvidence(retention);
233
+ 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
+ }
239
+ 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),
250
+ evidence: [
251
+ `Retention cohort size: ${retention.cohortSize}`,
252
+ `Observed D${target.day} retention: ${(actual * 100).toFixed(2)}%`,
253
+ ...retentionQualityEvidence,
254
+ retention?.avgActiveDays !== undefined
255
+ ? `Average active days in the cohort: ${retention.avgActiveDays}`
256
+ : null,
257
+ ].filter(Boolean),
258
+ 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}`],
267
+ });
268
+ break;
269
+ }
270
+ }
271
+ return {
272
+ project,
273
+ window: normalizeWindow(last),
274
+ signals: sortSignals(signals).slice(0, Math.max(1, Number(input?.maxSignals) || 4)),
275
+ meta: {
276
+ generatedAt: new Date().toISOString(),
277
+ source: 'analyticscli',
278
+ starters,
279
+ paywallReachedUsers,
280
+ retentionCohortSize: coerceNumber(retention?.cohortSize) || 0,
281
+ retentionReliability: retentionReliability || 'unreported',
282
+ retentionStableIdentityShare: coerceNumber(retention?.quality?.stableIdentityShare) || 0,
283
+ },
284
+ };
285
+ }
286
+ function isObject(value) {
287
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
288
+ }
289
+ function walk(value, visitor, pathParts = []) {
290
+ if (Array.isArray(value)) {
291
+ value.forEach((entry, index) => {
292
+ walk(entry, visitor, [...pathParts, String(index)]);
293
+ });
294
+ return;
295
+ }
296
+ if (!isObject(value)) {
297
+ visitor(value, pathParts);
298
+ return;
299
+ }
300
+ for (const [key, entry] of Object.entries(value)) {
301
+ const nextPath = [...pathParts, key];
302
+ visitor(entry, nextPath, key);
303
+ walk(entry, visitor, nextPath);
304
+ }
305
+ }
306
+ function collectStatusEntries(payload) {
307
+ const entries = [];
308
+ walk(payload, (value, pathParts, key) => {
309
+ if (typeof value !== 'string')
310
+ return;
311
+ const normalizedKey = String(key || '').toLowerCase();
312
+ if (!['state', 'status', 'processingstate', 'reviewstate'].includes(normalizedKey)) {
313
+ return;
314
+ }
315
+ entries.push({
316
+ path: pathParts.join('.'),
317
+ value: value.trim(),
318
+ });
319
+ });
320
+ return entries;
321
+ }
322
+ function classifyAscStatus(value) {
323
+ const normalized = String(value || '')
324
+ .trim()
325
+ .toLowerCase();
326
+ if (!normalized)
327
+ return null;
328
+ if (/(reject|rejected|fail|failed|error|invalid|missing|remove|blocked|denied|cancel)/.test(normalized)) {
329
+ return 'blocking';
330
+ }
331
+ if (/(processing|pending|waiting|prepare_for_submission|ready_for_review|in_review)/.test(normalized)) {
332
+ return 'watch';
333
+ }
334
+ if (/(ready_for_sale|approved|active|available|complete|passed|ok)/.test(normalized)) {
335
+ return 'healthy';
336
+ }
337
+ return null;
338
+ }
339
+ function findNumbersByCandidateKeys(payload, candidateKeys) {
340
+ const matches = [];
341
+ walk(payload, (value, pathParts, key) => {
342
+ if (!key)
343
+ return;
344
+ const normalizedKey = String(key).toLowerCase();
345
+ if (!candidateKeys.includes(normalizedKey))
346
+ return;
347
+ const numeric = coerceNumber(value);
348
+ if (numeric === null)
349
+ return;
350
+ matches.push({ path: pathParts.join('.'), value: numeric });
351
+ });
352
+ return matches;
353
+ }
354
+ function extractReviewTexts(payload) {
355
+ const texts = [];
356
+ walk(payload, (value, pathParts, key) => {
357
+ if (typeof value !== 'string')
358
+ return;
359
+ const normalizedKey = String(key || '').toLowerCase();
360
+ if (!['text', 'comment', 'summary', 'body', 'title', 'feedback'].includes(normalizedKey)) {
361
+ return;
362
+ }
363
+ const trimmed = value.trim();
364
+ if (!trimmed)
365
+ return;
366
+ texts.push({
367
+ path: pathParts.join('.'),
368
+ text: trimmed,
369
+ });
370
+ });
371
+ return texts;
372
+ }
373
+ function rankKeywordThemes(texts) {
374
+ const themeDefinitions = [
375
+ {
376
+ id: 'stability',
377
+ area: 'stability',
378
+ keywords: ['crash', 'crashes', 'crashing', 'freeze', 'frozen', 'bug', 'broken'],
379
+ suggestedActions: [
380
+ 'Review recent crash and review signals together to isolate the highest-impact regression',
381
+ 'Prioritize the failing flow in the next patch release and add deterministic regression coverage',
382
+ ],
383
+ },
384
+ {
385
+ id: 'pricing',
386
+ area: 'paywall',
387
+ keywords: [
388
+ 'subscription',
389
+ 'subscribe',
390
+ 'paywall',
391
+ 'price',
392
+ 'pricing',
393
+ 'trial',
394
+ 'premium',
395
+ 'restore',
396
+ ],
397
+ suggestedActions: [
398
+ 'Clarify package differences and restore messaging in the paywall flow',
399
+ 'Use review phrasing directly to rewrite confusing pricing copy',
400
+ ],
401
+ },
402
+ {
403
+ id: 'auth',
404
+ area: 'authentication',
405
+ keywords: ['login', 'log in', 'sign in', 'account', 'password'],
406
+ suggestedActions: [
407
+ 'Audit authentication entry points and reduce avoidable sign-in friction',
408
+ 'Surface clearer account state and recovery messaging in the first-session path',
409
+ ],
410
+ },
411
+ {
412
+ id: 'onboarding',
413
+ area: 'onboarding',
414
+ keywords: ['onboarding', 'tutorial', 'signup', 'sign up', 'permission', 'too long'],
415
+ suggestedActions: [
416
+ 'Trim the onboarding path and move optional steps later',
417
+ 'Match onboarding copy more closely to the first-value promise from the store listing',
418
+ ],
419
+ },
420
+ {
421
+ id: 'performance',
422
+ area: 'performance',
423
+ keywords: ['slow', 'lag', 'loading', 'stuck', 'wait'],
424
+ suggestedActions: [
425
+ 'Measure the slowest startup and primary interaction paths that users mention',
426
+ 'Ship a focused performance pass on the worst-loading user journeys',
427
+ ],
428
+ },
429
+ ];
430
+ return themeDefinitions
431
+ .map((theme) => {
432
+ let hits = 0;
433
+ for (const entry of texts) {
434
+ const normalized = entry.text.toLowerCase();
435
+ for (const keyword of theme.keywords) {
436
+ if (normalized.includes(keyword)) {
437
+ hits += 1;
438
+ }
439
+ }
440
+ }
441
+ return { ...theme, hits };
442
+ })
443
+ .filter((theme) => theme.hits > 0)
444
+ .sort((a, b) => b.hits - a.hits);
445
+ }
446
+ function collectAscMetricEntries(payload) {
447
+ if (Array.isArray(payload?.result?.results))
448
+ return payload.result.results;
449
+ if (Array.isArray(payload?.results))
450
+ return payload.results;
451
+ if (Array.isArray(payload?.acquisition))
452
+ return payload.acquisition;
453
+ if (Array.isArray(payload))
454
+ return payload;
455
+ return [];
456
+ }
457
+ function findAscMetric(payload, measure) {
458
+ const normalized = String(measure || '').toLowerCase();
459
+ return collectAscMetricEntries(payload).find((entry) => String(entry?.measure || '').toLowerCase() === normalized) || null;
460
+ }
461
+ function normalizeMetricData(entry) {
462
+ return Array.isArray(entry?.data)
463
+ ? entry.data
464
+ .map((point) => ({
465
+ date: String(point?.date || '').slice(0, 10),
466
+ value: coerceNumber(point?.value),
467
+ }))
468
+ .filter((point) => point.date && point.value !== null)
469
+ : [];
470
+ }
471
+ function summarizeSourceBreakdown(payload) {
472
+ const entries = Array.isArray(payload?.result?.results)
473
+ ? payload.result.results
474
+ : Array.isArray(payload?.results)
475
+ ? payload.results
476
+ : [];
477
+ return entries
478
+ .map((entry) => {
479
+ const data = Array.isArray(entry?.data) ? entry.data : [];
480
+ const total = data.reduce((sum, point) => sum + (coerceNumber(point?.pageViewUnique ?? point?.value) || 0), 0);
481
+ return {
482
+ key: String(entry?.group?.key || entry?.key || entry?.source || 'unknown'),
483
+ title: String(entry?.group?.title || entry?.title || entry?.label || entry?.group?.key || 'Unknown'),
484
+ pageViewUnique: total,
485
+ };
486
+ })
487
+ .filter((entry) => entry.pageViewUnique > 0)
488
+ .sort((a, b) => b.pageViewUnique - a.pageViewUnique);
489
+ }
490
+ function extractAscCrashBreakdowns(payload) {
491
+ const breakdowns = Array.isArray(payload?.appUsageBreakdowns) ? payload.appUsageBreakdowns : [];
492
+ return breakdowns
493
+ .filter((breakdown) => String(breakdown?.measure || '').toLowerCase() === 'crashes')
494
+ .flatMap((breakdown) => Array.isArray(breakdown?.items)
495
+ ? breakdown.items.map((item) => ({
496
+ label: String(item?.label || item?.key || 'Unknown app version'),
497
+ value: coerceNumber(item?.value) || 0,
498
+ }))
499
+ : [])
500
+ .filter((entry) => entry.value > 0)
501
+ .sort((a, b) => b.value - a.value);
502
+ }
503
+ function totalAscCrashes(payload) {
504
+ const breakdowns = Array.isArray(payload?.appUsageBreakdowns) ? payload.appUsageBreakdowns : [];
505
+ const directTotal = breakdowns
506
+ .filter((breakdown) => String(breakdown?.measure || '').toLowerCase() === 'crashes')
507
+ .reduce((sum, breakdown) => sum + (coerceNumber(breakdown?.total) || 0), 0);
508
+ if (directTotal > 0)
509
+ return directTotal;
510
+ return extractAscCrashBreakdowns(payload).reduce((sum, entry) => sum + entry.value, 0);
511
+ }
512
+ function isLikelyAscWebAuthMissing(warnings) {
513
+ return warnings.some((warning) => {
514
+ const normalized = String(warning || '').toLowerCase();
515
+ return (normalized.includes('asc web auth login') ||
516
+ normalized.includes('web session is unauthorized') ||
517
+ normalized.includes('web session is expired'));
518
+ });
519
+ }
520
+ function collectAscOverviewMetricCatalog(payload) {
521
+ const sections = ['acquisition', 'sales', 'subscriptions'];
522
+ const metrics = [];
523
+ for (const section of sections) {
524
+ const entries = Array.isArray(payload?.[section]) ? payload[section] : [];
525
+ for (const entry of entries) {
526
+ const measure = String(entry?.measure || '').trim();
527
+ if (!measure)
528
+ continue;
529
+ metrics.push({
530
+ section,
531
+ measure,
532
+ total: coerceNumber(entry?.total),
533
+ previousTotal: coerceNumber(entry?.previousTotal),
534
+ percentChange: coerceNumber(entry?.percentChange),
535
+ type: String(entry?.type || '').trim() || null,
536
+ });
537
+ }
538
+ }
539
+ const breakdownSections = ['featureBreakdowns', 'appUsageBreakdowns'];
540
+ for (const section of breakdownSections) {
541
+ const entries = Array.isArray(payload?.[section]) ? payload[section] : [];
542
+ for (const entry of entries) {
543
+ const measure = String(entry?.measure || entry?.name || '').trim();
544
+ if (!measure)
545
+ continue;
546
+ metrics.push({
547
+ section,
548
+ measure,
549
+ total: coerceNumber(entry?.total),
550
+ previousTotal: coerceNumber(entry?.previousTotal),
551
+ percentChange: coerceNumber(entry?.percentChange),
552
+ type: 'BREAKDOWN',
553
+ });
554
+ }
555
+ }
556
+ const planTimeline = Array.isArray(payload?.planTimeline) ? payload.planTimeline : [];
557
+ for (const entry of planTimeline) {
558
+ const totals = entry?.totals && typeof entry.totals === 'object' ? entry.totals : null;
559
+ const measure = String(totals?.key || '').trim();
560
+ if (!measure)
561
+ continue;
562
+ metrics.push({
563
+ section: 'planTimeline',
564
+ measure,
565
+ total: coerceNumber(totals?.value),
566
+ previousTotal: null,
567
+ percentChange: null,
568
+ type: String(totals?.type || 'COUNT'),
569
+ });
570
+ }
571
+ const byKey = new Map();
572
+ for (const metric of metrics) {
573
+ const key = `${metric.section}:${metric.measure}`;
574
+ if (!byKey.has(key))
575
+ byKey.set(key, metric);
576
+ }
577
+ return [...byKey.values()];
578
+ }
579
+ function formatMetricMovement(metric) {
580
+ const total = metric.total === null ? 'unknown' : round(metric.total);
581
+ const previous = metric.previousTotal === null ? null : ` previous ${round(metric.previousTotal)}`;
582
+ const change = metric.percentChange === null ? null : ` change ${formatPercent(metric.percentChange)}`;
583
+ return `${metric.section}.${metric.measure}: ${total}${previous || ''}${change || ''}`;
584
+ }
585
+ function formatPercent(value) {
586
+ if (value === null || value === undefined || !Number.isFinite(Number(value)))
587
+ return 'unknown';
588
+ return `${round(Number(value) * 100)}%`;
589
+ }
590
+ export function buildAscSummary(input) {
591
+ const appId = String(input?.appId || 'ASC_APP_ID').trim() || 'ASC_APP_ID';
592
+ const statusEntries = collectStatusEntries(input?.statusPayload);
593
+ const blockingStatuses = statusEntries.filter((entry) => classifyAscStatus(entry.value) === 'blocking');
594
+ const watchStatuses = statusEntries.filter((entry) => classifyAscStatus(entry.value) === 'watch');
595
+ const averageRatingCandidates = findNumbersByCandidateKeys(input?.ratingsPayload, [
596
+ 'averagerating',
597
+ 'averageuserrating',
598
+ 'ratingaverage',
599
+ 'avgrating',
600
+ ]).filter((entry) => entry.value >= 0 && entry.value <= 5);
601
+ const ratingCountCandidates = findNumbersByCandidateKeys(input?.ratingsPayload, [
602
+ 'ratingcount',
603
+ 'userratingcount',
604
+ 'ratingscount',
605
+ 'count',
606
+ ]).filter((entry) => entry.value >= 0);
607
+ const averageRating = averageRatingCandidates[0]?.value ?? null;
608
+ const ratingCount = ratingCountCandidates[0]?.value ?? null;
609
+ const reviewTexts = [
610
+ ...extractReviewTexts(input?.reviewSummariesPayload),
611
+ ...extractReviewTexts(input?.feedbackPayload),
612
+ ];
613
+ const topThemes = rankKeywordThemes(reviewTexts).slice(0, 2);
614
+ const analyticsMetricsPayload = input?.analyticsMetricsPayload || input?.analyticsOverviewPayload;
615
+ const unitsMetric = findAscMetric(analyticsMetricsPayload, 'units');
616
+ const redownloadsMetric = findAscMetric(analyticsMetricsPayload, 'redownloads');
617
+ const conversionRateMetric = findAscMetric(analyticsMetricsPayload || input?.analyticsOverviewPayload, 'conversionRate');
618
+ const crashRateMetric = findAscMetric(analyticsMetricsPayload, 'crashRate');
619
+ const sourceBreakdown = summarizeSourceBreakdown(input?.analyticsSourcesPayload);
620
+ const totalSourcePageViews = sourceBreakdown.reduce((sum, source) => sum + source.pageViewUnique, 0);
621
+ const topSource = sourceBreakdown[0] || null;
622
+ const crashBreakdown = extractAscCrashBreakdowns(input?.analyticsOverviewPayload);
623
+ const totalCrashes = totalAscCrashes(input?.analyticsOverviewPayload);
624
+ const analyticsWarnings = Array.isArray(input?.analyticsWarnings) ? input.analyticsWarnings : [];
625
+ const webAuthMissing = isLikelyAscWebAuthMissing(analyticsWarnings);
626
+ const analyticsAvailability = webAuthMissing
627
+ ? 'web_auth_missing'
628
+ : analyticsWarnings.some((warning) => String(warning).includes('403'))
629
+ ? 'not_public_or_not_analytics_ready'
630
+ : unitsMetric || conversionRateMetric || sourceBreakdown.length > 0 || totalCrashes > 0
631
+ ? 'available'
632
+ : 'unknown';
633
+ const overviewMetricCatalog = collectAscOverviewMetricCatalog(input?.analyticsOverviewPayload);
634
+ const notableOverviewMetrics = overviewMetricCatalog
635
+ .filter((metric) => {
636
+ const measure = metric.measure.toLowerCase();
637
+ if (['units', 'redownloads', 'conversionrate'].includes(measure))
638
+ return false;
639
+ if (measure === 'crashrate' || measure === 'crashes')
640
+ return false;
641
+ const percentChange = Math.abs(coerceNumber(metric.percentChange) || 0);
642
+ const total = Math.abs(coerceNumber(metric.total) || 0);
643
+ return percentChange >= 0.1 || total > 0;
644
+ })
645
+ .sort((a, b) => {
646
+ const aChange = Math.abs(coerceNumber(a.percentChange) || 0);
647
+ const bChange = Math.abs(coerceNumber(b.percentChange) || 0);
648
+ if (aChange !== bChange)
649
+ return bChange - aChange;
650
+ return Math.abs(coerceNumber(b.total) || 0) - Math.abs(coerceNumber(a.total) || 0);
651
+ })
652
+ .slice(0, 6);
653
+ const nonZeroCrashRateDays = normalizeMetricData(crashRateMetric)
654
+ .filter((point) => Number(point.value) > 0)
655
+ .slice(-5);
656
+ const signals = [];
657
+ if (webAuthMissing) {
658
+ maybePushSignal(signals, {
659
+ id: 'asc_web_analytics_access_missing',
660
+ title: 'ASC web analytics access needs login refresh',
661
+ area: 'connector',
662
+ priority: 'high',
663
+ metric: 'asc_web_analytics_access',
664
+ current_value: 0,
665
+ baseline_value: 1,
666
+ delta_percent: -100,
667
+ evidence: analyticsWarnings.slice(0, 3),
668
+ 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',
672
+ ],
673
+ keywords: ['asc', 'web_analytics', 'login', 'connector'],
674
+ });
675
+ }
676
+ if (blockingStatuses.length > 0) {
677
+ maybePushSignal(signals, {
678
+ id: 'asc_release_blockers_detected',
679
+ title: 'App Store Connect reports blocking release states',
680
+ area: 'release',
681
+ priority: 'high',
682
+ metric: 'asc_release_blockers',
683
+ current_value: blockingStatuses.length,
684
+ baseline_value: 0,
685
+ delta_percent: blockingStatuses.length > 0 ? 100 : 0,
686
+ evidence: blockingStatuses.slice(0, 5).map((entry) => `${entry.path}: ${entry.value}`),
687
+ suggested_actions: [
688
+ 'Open the failing ASC section and resolve the blocking review, submission, or build issue',
689
+ 'Link the blocking ASC state to the corresponding release checklist item before the next submission',
690
+ ],
691
+ keywords: ['asc', 'review', 'submission', 'release', 'blocker'],
692
+ });
693
+ }
694
+ else if (watchStatuses.length > 0) {
695
+ maybePushSignal(signals, {
696
+ id: 'asc_release_in_progress',
697
+ title: 'App Store Connect still shows in-progress release states',
698
+ area: 'release',
699
+ priority: 'medium',
700
+ metric: 'asc_release_watch_states',
701
+ current_value: watchStatuses.length,
702
+ baseline_value: 0,
703
+ delta_percent: watchStatuses.length > 0 ? 100 : 0,
704
+ evidence: watchStatuses.slice(0, 5).map((entry) => `${entry.path}: ${entry.value}`),
705
+ suggested_actions: [
706
+ 'Monitor build processing and review transitions until they reach a terminal healthy state',
707
+ 'Avoid scheduling a coordinated release action until ASC processing has finished',
708
+ ],
709
+ keywords: ['asc', 'processing', 'review', 'submission'],
710
+ });
711
+ }
712
+ if (averageRating !== null && ratingCount !== null && ratingCount >= 20 && averageRating < 4.2) {
713
+ const ratingBaseline = 4.2;
714
+ maybePushSignal(signals, {
715
+ id: 'asc_rating_below_target',
716
+ title: 'App Store rating is below target',
717
+ area: 'store',
718
+ priority: averageRating < 3.8 ? 'high' : 'medium',
719
+ metric: 'app_store_average_rating',
720
+ current_value: round(averageRating),
721
+ baseline_value: ratingBaseline,
722
+ delta_percent: computeDeltaPercent(averageRating, ratingBaseline),
723
+ evidence: [
724
+ `Average rating: ${averageRating.toFixed(2)} from ${Math.round(ratingCount)} ratings`,
725
+ 'Ratings came from the ASC review ratings command output',
726
+ ],
727
+ suggested_actions: [
728
+ 'Read recent review summaries to identify the dominant complaint before changing store copy',
729
+ 'Tie the next release notes and onboarding/paywall adjustments to the main rating complaint themes',
730
+ ],
731
+ keywords: ['app_store', 'rating', 'reviews', 'aso'],
732
+ });
733
+ }
734
+ for (const theme of topThemes) {
735
+ maybePushSignal(signals, {
736
+ id: `asc_review_theme_${theme.id}`,
737
+ title: `Store and beta feedback repeatedly mention ${theme.area} issues`,
738
+ area: theme.area,
739
+ priority: theme.hits >= 4 ? 'high' : 'medium',
740
+ metric: `feedback_theme_${theme.id}`,
741
+ current_value: theme.hits,
742
+ baseline_value: 0,
743
+ delta_percent: theme.hits > 0 ? 100 : 0,
744
+ evidence: reviewTexts
745
+ .slice(0, 3)
746
+ .map((entry) => entry.text)
747
+ .filter(Boolean),
748
+ suggested_actions: theme.suggestedActions,
749
+ keywords: ['reviews', 'feedback', theme.area, ...theme.keywords.slice(0, 3)],
750
+ });
751
+ }
752
+ if (totalCrashes > 0 || nonZeroCrashRateDays.length > 0) {
753
+ maybePushSignal(signals, {
754
+ id: 'asc_production_crashes_detected',
755
+ title: 'ASC reports production crashes',
756
+ area: 'crash',
757
+ priority: totalCrashes >= 10 || nonZeroCrashRateDays.length >= 3 ? 'high' : 'medium',
758
+ metric: 'asc_total_crashes',
759
+ current_value: totalCrashes,
760
+ baseline_value: 0,
761
+ delta_percent: totalCrashes > 0 ? 100 : null,
762
+ evidence: [
763
+ totalCrashes > 0 ? `ASC total crashes: ${totalCrashes}` : null,
764
+ ...crashBreakdown
765
+ .slice(0, 3)
766
+ .map((entry) => `Crashes by app version: ${entry.label} = ${entry.value}`),
767
+ ...nonZeroCrashRateDays.map((point) => `Crash rate ${point.date}: ${point.value}`),
768
+ ].filter(Boolean),
769
+ suggested_actions: [
770
+ 'Notify the OpenClaw user through the connected chat or social delivery channel before growth traffic is scaled',
771
+ 'Compare ASC total crashes with Sentry production issues for the same app version and date range',
772
+ 'If GitHub issue/PR write access is configured in OpenClaw, create the tracking issue or implementation PR automatically',
773
+ ],
774
+ keywords: ['asc', 'crash', 'production', 'sentry', 'release'],
775
+ });
776
+ }
777
+ if (notableOverviewMetrics.length > 0) {
778
+ maybePushSignal(signals, {
779
+ id: 'asc_overview_metric_movements_detected',
780
+ title: 'ASC overview metrics have movement worth comparing',
781
+ area: 'analytics',
782
+ priority: notableOverviewMetrics.some((metric) => Math.abs(coerceNumber(metric.percentChange) || 0) >= 0.5)
783
+ ? 'medium'
784
+ : 'low',
785
+ metric: 'asc_overview_metrics',
786
+ current_value: notableOverviewMetrics.length,
787
+ baseline_value: overviewMetricCatalog.length,
788
+ delta_percent: null,
789
+ evidence: notableOverviewMetrics.map(formatMetricMovement),
790
+ suggested_actions: [
791
+ 'Analyze every available ASC overview metric together with units, conversion, sources, AnalyticsCLI funnels, Sentry stability, and reviews before choosing a recommendation',
792
+ 'Keep financial metrics secondary unless the user asks, but still use them as validation for acquisition and conversion quality',
793
+ ],
794
+ keywords: ['asc', 'analytics', 'overview_metrics', 'conversion', 'handlungsempfehlung'],
795
+ });
796
+ }
797
+ if (unitsMetric && coerceNumber(unitsMetric.total) !== null) {
798
+ const units = coerceNumber(unitsMetric.total) || 0;
799
+ const percentChange = coerceNumber(unitsMetric.percentChange);
800
+ if (units >= 10 && percentChange !== null && percentChange <= -0.2) {
801
+ maybePushSignal(signals, {
802
+ id: 'asc_units_declining',
803
+ title: 'App Store downloads are down versus the previous comparable period',
804
+ area: 'acquisition',
805
+ priority: percentChange <= -0.4 ? 'high' : 'medium',
806
+ metric: 'asc_units',
807
+ current_value: units,
808
+ baseline_value: coerceNumber(unitsMetric.previousTotal),
809
+ delta_percent: round(percentChange * 100),
810
+ evidence: [
811
+ `Units: ${units}`,
812
+ `Previous units: ${coerceNumber(unitsMetric.previousTotal) ?? 'unknown'}`,
813
+ `Change: ${formatPercent(percentChange)}`,
814
+ redownloadsMetric ? `Redownloads: ${coerceNumber(redownloadsMetric.total) ?? 0}` : null,
815
+ ].filter(Boolean),
816
+ suggested_actions: [
817
+ 'Compare the download drop with source traffic, store impressions, page views, ranking/search changes, and recent releases',
818
+ 'Segment the recommendation into ASO, web/referrer, browse, or app-referrer work based on the source mix',
819
+ ],
820
+ keywords: ['asc', 'units', 'downloads', 'acquisition', 'sources'],
821
+ });
822
+ }
823
+ }
824
+ if (conversionRateMetric && coerceNumber(conversionRateMetric.total) !== null) {
825
+ const conversionRate = coerceNumber(conversionRateMetric.total) || 0;
826
+ const percentChange = coerceNumber(conversionRateMetric.percentChange);
827
+ if (percentChange !== null && percentChange <= -0.1) {
828
+ maybePushSignal(signals, {
829
+ id: 'asc_conversion_rate_declining',
830
+ title: 'App Store conversion rate is declining',
831
+ area: 'conversion',
832
+ priority: percentChange <= -0.25 ? 'high' : 'medium',
833
+ metric: 'asc_conversion_rate',
834
+ current_value: round(conversionRate),
835
+ baseline_value: coerceNumber(conversionRateMetric.previousTotal),
836
+ delta_percent: round(percentChange * 100),
837
+ evidence: [
838
+ `Conversion rate: ${round(conversionRate)}`,
839
+ `Previous conversion rate: ${coerceNumber(conversionRateMetric.previousTotal) ?? 'unknown'}`,
840
+ `Change: ${formatPercent(percentChange)}`,
841
+ topSource ? `Top source by unique product page views: ${topSource.title} (${topSource.pageViewUnique})` : null,
842
+ ].filter(Boolean),
843
+ suggested_actions: [
844
+ 'Review store listing screenshots, subtitle, keywords, and price/paywall promise for the source that changed most',
845
+ 'Compare ASC conversion movement with AnalyticsCLI onboarding and paywall conversion before changing app code',
846
+ ],
847
+ keywords: ['asc', 'conversion', 'store_listing', 'sources', 'aso'],
848
+ });
849
+ }
850
+ }
851
+ if (topSource && totalSourcePageViews > 0) {
852
+ const share = topSource.pageViewUnique / totalSourcePageViews;
853
+ if (share >= 0.5 || sourceBreakdown.length >= 2) {
854
+ maybePushSignal(signals, {
855
+ id: 'asc_source_mix_available',
856
+ title: 'ASC source traffic is available for acquisition recommendations',
857
+ area: 'acquisition',
858
+ priority: share >= 0.7 ? 'medium' : 'low',
859
+ metric: 'asc_source_page_view_unique',
860
+ current_value: topSource.pageViewUnique,
861
+ baseline_value: totalSourcePageViews,
862
+ delta_percent: round(share * 100),
863
+ evidence: [
864
+ `Top source: ${topSource.title} (${topSource.pageViewUnique} unique product page views)`,
865
+ `Source mix: ${sourceBreakdown
866
+ .slice(0, 5)
867
+ .map((source) => `${source.title} ${source.pageViewUnique}`)
868
+ .join(', ')}`,
869
+ 'ASC sources are product page views by unique devices, not source-level download units',
870
+ ],
871
+ suggested_actions: [
872
+ 'Turn the dominant source into a specific Handlungsempfehlung: Search -> ASO/keywords, Web Referrer -> landing pages/UTMs, Browse -> creative/category positioning, App Referrer -> cross-promo/deep links',
873
+ 'Compare source movement with units, redownloads, conversion rate, AnalyticsCLI activation, and Sentry crashes before recommending more spend or traffic',
874
+ ],
875
+ keywords: ['asc', 'sources', 'traffic', 'page_views', 'handlungsempfehlung'],
876
+ });
877
+ }
878
+ }
879
+ return {
880
+ project: `app-store-connect:${appId}`,
881
+ window: 'latest',
882
+ signals: sortSignals(signals).slice(0, Math.max(1, Number(input?.maxSignals) || 4)),
883
+ meta: {
884
+ generatedAt: new Date().toISOString(),
885
+ source: 'asc',
886
+ appId,
887
+ ratingCount: ratingCount ?? 0,
888
+ feedbackTextCount: reviewTexts.length,
889
+ analyticsWindow: input?.analyticsWindow || null,
890
+ analyticsAvailability,
891
+ analyticsWarnings,
892
+ analytics: {
893
+ units: unitsMetric
894
+ ? {
895
+ total: coerceNumber(unitsMetric.total) ?? 0,
896
+ previousTotal: coerceNumber(unitsMetric.previousTotal),
897
+ percentChange: coerceNumber(unitsMetric.percentChange),
898
+ }
899
+ : null,
900
+ redownloads: redownloadsMetric
901
+ ? {
902
+ total: coerceNumber(redownloadsMetric.total) ?? 0,
903
+ previousTotal: coerceNumber(redownloadsMetric.previousTotal),
904
+ percentChange: coerceNumber(redownloadsMetric.percentChange),
905
+ }
906
+ : null,
907
+ conversionRate: conversionRateMetric
908
+ ? {
909
+ total: coerceNumber(conversionRateMetric.total) ?? 0,
910
+ previousTotal: coerceNumber(conversionRateMetric.previousTotal),
911
+ percentChange: coerceNumber(conversionRateMetric.percentChange),
912
+ }
913
+ : null,
914
+ crashRate: crashRateMetric
915
+ ? {
916
+ total: coerceNumber(crashRateMetric.total) ?? 0,
917
+ previousTotal: coerceNumber(crashRateMetric.previousTotal),
918
+ percentChange: coerceNumber(crashRateMetric.percentChange),
919
+ nonZeroDays: nonZeroCrashRateDays,
920
+ }
921
+ : null,
922
+ totalCrashes,
923
+ crashBreakdown,
924
+ sourceBreakdown,
925
+ overviewMetricCatalog,
926
+ },
927
+ },
928
+ };
929
+ }
930
+ function extractListItems(payload) {
931
+ if (Array.isArray(payload))
932
+ return payload;
933
+ if (Array.isArray(payload?.items))
934
+ return payload.items;
935
+ if (Array.isArray(payload?.data))
936
+ return payload.data;
937
+ return [];
938
+ }
939
+ function displayName(value) {
940
+ return String(value?.display_name ||
941
+ value?.displayName ||
942
+ value?.name ||
943
+ value?.store_identifier ||
944
+ value?.lookup_key ||
945
+ value?.id ||
946
+ '').trim();
947
+ }
948
+ function metricValueById(metrics, candidateIds) {
949
+ const candidates = new Set(candidateIds.map((id) => String(id).toLowerCase()));
950
+ for (const metric of metrics) {
951
+ const id = String(metric?.id || metric?.name || '').toLowerCase();
952
+ if (!candidates.has(id))
953
+ continue;
954
+ const value = coerceNumber(metric?.value);
955
+ if (value !== null)
956
+ return { id, value, metric };
957
+ }
958
+ return null;
959
+ }
960
+ export function buildRevenueCatSummary(input) {
961
+ const projectId = String(input?.projectId || input?.project?.id || 'revenuecat-project').trim() ||
962
+ 'revenuecat-project';
963
+ const projectName = displayName(input?.project) || projectId;
964
+ const apps = extractListItems(input?.appsPayload);
965
+ const products = extractListItems(input?.productsPayload);
966
+ const offerings = extractListItems(input?.offeringsPayload);
967
+ const entitlements = extractListItems(input?.entitlementsPayload);
968
+ const metrics = Array.isArray(input?.overviewPayload?.metrics)
969
+ ? input.overviewPayload.metrics
970
+ : [];
971
+ const warnings = Array.isArray(input?.warnings) ? input.warnings.filter(Boolean) : [];
972
+ const signals = [];
973
+ const revenueMetric = metricValueById(metrics, [
974
+ 'revenue',
975
+ 'mrr',
976
+ 'arr',
977
+ 'new_revenue',
978
+ 'monthly_recurring_revenue',
979
+ ]);
980
+ const activeTrialsMetric = metricValueById(metrics, ['active_trials']);
981
+ const activeSubscriptionsMetric = metricValueById(metrics, ['active_subscriptions', 'actives']);
982
+ const churnMetric = metricValueById(metrics, ['churn', 'churn_rate']);
983
+ if (revenueMetric || activeSubscriptionsMetric || activeTrialsMetric) {
984
+ maybePushSignal(signals, {
985
+ id: 'revenuecat_overview_metrics_available',
986
+ title: 'RevenueCat overview metrics are connected',
987
+ area: 'revenue',
988
+ priority: 'medium',
989
+ metric: revenueMetric?.id ||
990
+ activeSubscriptionsMetric?.id ||
991
+ activeTrialsMetric?.id ||
992
+ 'revenuecat_metrics',
993
+ current_value: revenueMetric?.value ?? activeSubscriptionsMetric?.value ?? activeTrialsMetric?.value ?? 0,
994
+ baseline_value: null,
995
+ delta_percent: null,
996
+ evidence: [
997
+ revenueMetric
998
+ ? `${revenueMetric.metric?.name || revenueMetric.id}: ${revenueMetric.value}`
999
+ : null,
1000
+ activeSubscriptionsMetric
1001
+ ? `${activeSubscriptionsMetric.metric?.name || activeSubscriptionsMetric.id}: ${activeSubscriptionsMetric.value}`
1002
+ : null,
1003
+ activeTrialsMetric
1004
+ ? `${activeTrialsMetric.metric?.name || activeTrialsMetric.id}: ${activeTrialsMetric.value}`
1005
+ : null,
1006
+ ].filter(Boolean),
1007
+ suggested_actions: [
1008
+ 'Compare RevenueCat movement with AnalyticsCLI paywall and purchase funnel signals',
1009
+ 'Use product and entitlement metadata to verify the paid path users see in the app',
1010
+ ],
1011
+ keywords: ['revenuecat', 'revenue', 'subscription', 'metrics'],
1012
+ });
1013
+ }
1014
+ if (churnMetric && churnMetric.value > 0) {
1015
+ maybePushSignal(signals, {
1016
+ id: 'revenuecat_churn_visible',
1017
+ title: 'RevenueCat reports churn movement',
1018
+ area: 'retention',
1019
+ priority: churnMetric.value >= 10 ? 'high' : 'medium',
1020
+ metric: churnMetric.id,
1021
+ current_value: churnMetric.value,
1022
+ baseline_value: 0,
1023
+ delta_percent: 100,
1024
+ evidence: [`${churnMetric.metric?.name || churnMetric.id}: ${churnMetric.value}`],
1025
+ suggested_actions: [
1026
+ 'Inspect cancellation timing against onboarding and first-week retention signals',
1027
+ 'Prioritize paywall promise and subscription value alignment if churn clusters after trial or first renewal',
1028
+ ],
1029
+ keywords: ['revenuecat', 'churn', 'subscription', 'retention'],
1030
+ });
1031
+ }
1032
+ if (products.length === 0 || offerings.length === 0 || entitlements.length === 0) {
1033
+ maybePushSignal(signals, {
1034
+ id: 'revenuecat_catalog_incomplete',
1035
+ title: 'RevenueCat product catalog looks incomplete',
1036
+ area: 'paywall',
1037
+ priority: products.length === 0 || offerings.length === 0 ? 'high' : 'medium',
1038
+ metric: 'revenuecat_catalog_entities',
1039
+ current_value: products.length + offerings.length + entitlements.length,
1040
+ baseline_value: 3,
1041
+ delta_percent: computeDeltaPercent(products.length + offerings.length + entitlements.length, 3),
1042
+ evidence: [
1043
+ `Products: ${products.length}`,
1044
+ `Offerings: ${offerings.length}`,
1045
+ `Entitlements: ${entitlements.length}`,
1046
+ ],
1047
+ suggested_actions: [
1048
+ 'Verify the app has at least one active product, entitlement, and offering in RevenueCat',
1049
+ 'Check that App Store Connect product identifiers match the RevenueCat products used by the app',
1050
+ ],
1051
+ keywords: ['revenuecat', 'products', 'offerings', 'entitlements', 'paywall'],
1052
+ });
1053
+ }
1054
+ else {
1055
+ maybePushSignal(signals, {
1056
+ id: 'revenuecat_catalog_summary',
1057
+ title: 'RevenueCat catalog is available for monetization analysis',
1058
+ area: 'paywall',
1059
+ priority: 'low',
1060
+ metric: 'revenuecat_products',
1061
+ current_value: products.length,
1062
+ baseline_value: 1,
1063
+ delta_percent: computeDeltaPercent(products.length, 1),
1064
+ evidence: [
1065
+ `Apps: ${apps.length}`,
1066
+ `Products: ${products.slice(0, 5).map(displayName).filter(Boolean).join(', ') || products.length}`,
1067
+ `Offerings: ${offerings.slice(0, 5).map(displayName).filter(Boolean).join(', ') || offerings.length}`,
1068
+ `Entitlements: ${entitlements.slice(0, 5).map(displayName).filter(Boolean).join(', ') || entitlements.length}`,
1069
+ ],
1070
+ suggested_actions: [
1071
+ 'Use this catalog context when evaluating paywall copy, package order, and entitlement naming',
1072
+ 'Cross-check product availability with ASC if users report unavailable purchases',
1073
+ ],
1074
+ keywords: ['revenuecat', 'catalog', 'products', 'offerings', 'entitlements'],
1075
+ });
1076
+ }
1077
+ return {
1078
+ project: `revenuecat:${projectId}`,
1079
+ window: 'latest',
1080
+ signals: sortSignals(signals).slice(0, Math.max(1, Number(input?.maxSignals) || 4)),
1081
+ meta: {
1082
+ generatedAt: new Date().toISOString(),
1083
+ source: 'revenuecat',
1084
+ projectId,
1085
+ projectName,
1086
+ appsCount: apps.length,
1087
+ productsCount: products.length,
1088
+ offeringsCount: offerings.length,
1089
+ entitlementsCount: entitlements.length,
1090
+ metricsCount: metrics.length,
1091
+ warnings,
1092
+ },
1093
+ };
1094
+ }
1095
+ function normalizeSentryIssueCount(issue) {
1096
+ return coerceNumber(issue?.count ?? issue?.events ?? issue?.eventCount ?? issue?.stats?.sum) || 0;
1097
+ }
1098
+ function normalizeSentryUserCount(issue) {
1099
+ return coerceNumber(issue?.userCount ?? issue?.users ?? issue?.affectedUsers) || 0;
1100
+ }
1101
+ function normalizeSentryIssueTitle(issue) {
1102
+ return String(issue?.title || issue?.metadata?.title || issue?.culprit || 'Untitled Sentry issue').trim();
1103
+ }
1104
+ function normalizeSentryPriority(issue) {
1105
+ const level = String(issue?.level || issue?.priority || '').toLowerCase();
1106
+ const events = normalizeSentryIssueCount(issue);
1107
+ const users = normalizeSentryUserCount(issue);
1108
+ if (level === 'fatal' || events >= 100 || users >= 25)
1109
+ return 'high';
1110
+ if (level === 'error' || events >= 20 || users >= 5)
1111
+ return 'medium';
1112
+ return 'low';
1113
+ }
1114
+ function normalizeSentryEvidence(issue, environment) {
1115
+ return [
1116
+ issue?.shortId ? `Sentry issue: ${issue.shortId}` : issue?.id ? `Sentry issue id: ${issue.id}` : null,
1117
+ issue?.permalink ? `Permalink: ${issue.permalink}` : null,
1118
+ issue?.level ? `Level: ${issue.level}` : null,
1119
+ issue?.status ? `Status: ${issue.status}` : null,
1120
+ issue?.firstSeen ? `First seen: ${issue.firstSeen}` : null,
1121
+ issue?.lastSeen ? `Last seen: ${issue.lastSeen}` : null,
1122
+ environment ? `Environment: ${environment}` : null,
1123
+ normalizeSentryIssueCount(issue) ? `Events: ${normalizeSentryIssueCount(issue)}` : null,
1124
+ normalizeSentryUserCount(issue) ? `Affected users: ${normalizeSentryUserCount(issue)}` : null,
1125
+ issue?.culprit ? `Culprit: ${issue.culprit}` : null,
1126
+ ].filter(Boolean);
1127
+ }
1128
+ function buildCombinedSentrySummary(input) {
1129
+ const accounts = Array.isArray(input?.accounts) ? input.accounts : [];
1130
+ const maxSignals = Math.max(1, Number(input?.maxSignals) || 5);
1131
+ const summaries = accounts
1132
+ .filter((account) => account && typeof account === 'object')
1133
+ .map((account, index) => {
1134
+ const accountId = String(account.id || account.key || account.label || `sentry_${index + 1}`).trim();
1135
+ const label = String(account.label || accountId).trim();
1136
+ const summary = buildSentrySummary({
1137
+ ...account,
1138
+ accounts: undefined,
1139
+ maxSignals: account.maxSignals || maxSignals,
1140
+ });
1141
+ return { accountId, label, summary };
1142
+ });
1143
+ const issues = summaries.flatMap(({ accountId, label, summary }) => (Array.isArray(summary.issues) ? summary.issues : []).map((issue) => ({
1144
+ ...issue,
1145
+ id: `${accountId}:${issue.id}`,
1146
+ accountId,
1147
+ accountLabel: label,
1148
+ sourceProject: summary.project,
1149
+ })));
1150
+ const signals = summaries
1151
+ .flatMap(({ accountId, label, summary }) => (Array.isArray(summary.signals) ? summary.signals : []).map((signal) => ({
1152
+ ...signal,
1153
+ id: `${accountId}:${signal.id}`,
1154
+ evidence: [`Sentry account: ${label}`, `Sentry project: ${summary.project}`, ...(signal.evidence || [])],
1155
+ keywords: [accountId, ...(signal.keywords || [])],
1156
+ })))
1157
+ .sort((a, b) => {
1158
+ const priorityDelta = priorityRank(b.priority) - priorityRank(a.priority);
1159
+ if (priorityDelta !== 0)
1160
+ return priorityDelta;
1161
+ return (Number(b.current_value) || 0) - (Number(a.current_value) || 0);
1162
+ })
1163
+ .slice(0, maxSignals);
1164
+ return {
1165
+ project: 'sentry:multiple',
1166
+ window: normalizeWindow(input?.last || input?.window || '24h'),
1167
+ issues,
1168
+ signals,
1169
+ meta: {
1170
+ generatedAt: new Date().toISOString(),
1171
+ source: 'sentry',
1172
+ multiAccount: true,
1173
+ accountCount: summaries.length,
1174
+ accounts: summaries.map(({ accountId, label, summary }) => ({
1175
+ id: accountId,
1176
+ label,
1177
+ project: summary.project,
1178
+ issuesReturned: summary.meta?.issuesReturned ?? 0,
1179
+ environment: summary.meta?.environment ?? null,
1180
+ })),
1181
+ issuesReturned: issues.length,
1182
+ },
1183
+ };
1184
+ }
1185
+ export function buildSentrySummary(input) {
1186
+ if (Array.isArray(input?.accounts)) {
1187
+ return buildCombinedSentrySummary(input);
1188
+ }
1189
+ const issues = Array.isArray(input?.issuesPayload)
1190
+ ? input.issuesPayload
1191
+ : Array.isArray(input?.issues)
1192
+ ? input.issues
1193
+ : [];
1194
+ const org = String(input?.org || input?.organization || process.env.SENTRY_ORG || '').trim();
1195
+ const project = String(input?.project || process.env.SENTRY_PROJECT || 'sentry-project').trim();
1196
+ const environment = String(input?.environment || process.env.SENTRY_ENVIRONMENT || '').trim();
1197
+ const last = String(input?.last || input?.window || '24h');
1198
+ const maxSignals = Math.max(1, Number(input?.maxSignals) || 5);
1199
+ const normalizedIssues = issues
1200
+ .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
+ }))
1229
+ .sort((a, b) => {
1230
+ const priorityDelta = priorityRank(b.priority) - priorityRank(a.priority);
1231
+ if (priorityDelta !== 0)
1232
+ return priorityDelta;
1233
+ const usersDelta = (b.users || 0) - (a.users || 0);
1234
+ if (usersDelta !== 0)
1235
+ return usersDelta;
1236
+ return (b.events || 0) - (a.events || 0);
1237
+ })
1238
+ .slice(0, maxSignals);
1239
+ return {
1240
+ project: org ? `sentry:${org}/${project}` : `sentry:${project}`,
1241
+ window: normalizeWindow(last),
1242
+ issues: normalizedIssues,
1243
+ signals: normalizedIssues.map((issue) => ({
1244
+ id: issue.id,
1245
+ title: issue.title,
1246
+ area: issue.area,
1247
+ priority: issue.priority,
1248
+ metric: issue.metric,
1249
+ current_value: issue.events,
1250
+ evidence: issue.evidence,
1251
+ suggested_actions: issue.suggested_actions,
1252
+ keywords: issue.stack_keywords,
1253
+ confidence: issue.confidence,
1254
+ })),
1255
+ meta: {
1256
+ generatedAt: new Date().toISOString(),
1257
+ source: 'sentry',
1258
+ org,
1259
+ project,
1260
+ environment: environment || null,
1261
+ issuesReturned: normalizedIssues.length,
1262
+ },
1263
+ };
1264
+ }
1265
+ export async function writeJsonOutput(outPath, payload) {
1266
+ const serialized = `${JSON.stringify(payload, null, 2)}\n`;
1267
+ if (outPath) {
1268
+ const resolved = path.resolve(String(outPath));
1269
+ await fs.mkdir(path.dirname(resolved), { recursive: true });
1270
+ await fs.writeFile(resolved, serialized, 'utf8');
1271
+ return resolved;
1272
+ }
1273
+ process.stdout.write(serialized);
1274
+ return null;
1275
+ }
1276
+ //# sourceMappingURL=openclaw-exporters-lib.mjs.map