apphud-mcp 0.2.2 → 0.2.5

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.
@@ -28,8 +28,8 @@ const BREAKDOWN_KEY_FIELDS = [
28
28
  ];
29
29
  const EVENT_ID_KEYS = ["event_id", "id", "uuid"];
30
30
  const EVENT_TYPE_KEYS = ["event_type", "event", "type", "name", "kind", "action"];
31
- const EVENT_USER_ID_KEYS = ["user_id", "userId", "customer_user_id", "customer_id", "uid", "subscriber_id"];
32
- const EVENT_PRODUCT_ID_KEYS = ["product_id", "productId", "sku", "subscription_product_id"];
31
+ const EVENT_USER_ID_KEYS = ["user_id", "userId", "customer_user_id", "customer_id", "customer_uid", "uid", "subscriber_id"];
32
+ const EVENT_PRODUCT_ID_KEYS = ["product_id", "productId", "sku", "subscription_product_id", "product"];
33
33
  const EVENT_ORIGINAL_TRANSACTION_ID_KEYS = [
34
34
  "original_transaction_id",
35
35
  "originalTransactionId",
@@ -39,7 +39,18 @@ const EVENT_ORIGINAL_TRANSACTION_ID_KEYS = [
39
39
  const EVENT_SUBSCRIPTION_STATUS_KEYS = ["subscription_status", "status", "subscriptionState"];
40
40
  const EVENT_COUNTRY_KEYS = ["country", "country_code", "countryCode", "storefront"];
41
41
  const EVENT_PLATFORM_KEYS = ["platform", "os", "store"];
42
- const EVENT_PRICE_KEYS = ["price", "amount", "revenue", "proceeds", "value"];
42
+ const EVENT_PRICE_KEYS = [
43
+ "price",
44
+ "price_usd",
45
+ "amount",
46
+ "revenue",
47
+ "proceeds",
48
+ "value",
49
+ "usd_price",
50
+ "local_price",
51
+ "usd_proceeds",
52
+ "local_proceeds",
53
+ ];
43
54
  const EVENT_CURRENCY_KEYS = ["currency", "currency_code", "currencyCode"];
44
55
  const METRIC_KEY_ALIASES = {
45
56
  active_subs: [
@@ -51,16 +62,51 @@ const METRIC_KEY_ALIASES = {
51
62
  "active_paid_subscriptions",
52
63
  ],
53
64
  active_trials: ["active_trials", "active_trial", "trials_active"],
54
- revenue_gross: ["revenue", "gross_revenue", "revenue_gross", "sales", "proceeds"],
65
+ revenue_gross: ["revenue", "gross_revenue", "revenue_gross"],
66
+ sales: ["sales"],
67
+ proceeds: ["proceeds"],
55
68
  refunds: ["refunds", "refunded", "refund_amount"],
56
69
  trials_started: ["trials_started", "trial_started", "trials"],
57
- trials_converted: ["trials_converted", "trial_converted", "trial_to_paid"],
58
- new_subscriptions: ["new_subscriptions", "subscription_started", "subscriptions_started", "new_subs"],
59
- renewals: ["renewals", "subscription_renewals"],
60
- cancellations: ["cancellations", "canceled", "expires", "expirations"],
61
- subscribers_retention: ["subscribers_retention", "retention", "subscription_retention"],
70
+ trials_converted: ["trials_converted", "trial_converted", "trial_conversions", "trial_to_paid"],
71
+ new_subscriptions: [
72
+ "new_subscriptions",
73
+ "new_regular_subscriptions",
74
+ "subscription_started",
75
+ "subscriptions_started",
76
+ "new_subs",
77
+ ],
78
+ renewals: ["renewals", "subscription_renewed", "subscription_renewals"],
79
+ cancellations: ["cancellations", "trial_canceled", "trial_cancelled", "trial_cancellations", "subscription_canceled"],
80
+ subscribers_retention: ["subscribers_retention", "subscribers_retention_new", "retention", "subscription_retention"],
62
81
  cumulative_ltv: ["cumulative_ltv", "ltv", "lifetime_value"],
63
82
  };
83
+ const METRIC_KEY_TO_LABELS = {
84
+ active_subs: ["Active Paid Subscriptions"],
85
+ active_trial: ["Active Trials"],
86
+ active_trials: ["Active Trials"],
87
+ revenue_gross: ["Gross revenue"],
88
+ sales: ["Sales"],
89
+ proceeds: ["Proceeds"],
90
+ refunds: ["Refunds"],
91
+ new_subscriptions: ["New Regular Subscriptions"],
92
+ trials_started: ["Trials"],
93
+ trials_converted: ["Trial Conversions"],
94
+ cancellations: ["Trial Cancellations"],
95
+ new_users: ["New Users"],
96
+ renewals: ["Renewals"],
97
+ active_intro: ["Active Intro Offers"],
98
+ active_promo: ["Active Promo Offers"],
99
+ };
100
+ const METRIC_KEY_REQUEST_ALIASES = {
101
+ active_trials: ["active_trial"],
102
+ trials_started: ["trial_started"],
103
+ trials_converted: ["trial_converted", "trial_conversions"],
104
+ new_subscriptions: ["subscription_started", "new_regular_subscriptions"],
105
+ renewals: ["subscription_renewed"],
106
+ cancellations: ["trial_canceled", "subscription_canceled"],
107
+ revenue_gross: ["revenue", "gross_revenue"],
108
+ subscribers_retention: ["subscribers_retention_new"],
109
+ };
64
110
  function asRecord(value) {
65
111
  if (!value || typeof value !== "object" || Array.isArray(value)) {
66
112
  return {};
@@ -338,6 +384,14 @@ function normalizeMetricKey(value) {
338
384
  .replace(/[^a-z0-9]+/g, "_")
339
385
  .replace(/^_+|_+$/g, "");
340
386
  }
387
+ function resolveMetricRequestKey(metricKey) {
388
+ const normalized = normalizeMetricKey(metricKey);
389
+ const direct = METRIC_KEY_REQUEST_ALIASES[normalized];
390
+ if (direct && direct.length > 0) {
391
+ return normalizeMetricKey(direct[0] ?? normalized);
392
+ }
393
+ return normalized;
394
+ }
341
395
  function expandMetricAliases(metricKey) {
342
396
  const normalized = normalizeMetricKey(metricKey);
343
397
  const aliases = new Set([normalized]);
@@ -355,6 +409,17 @@ function expandMetricAliases(metricKey) {
355
409
  }
356
410
  return aliases;
357
411
  }
412
+ function resolveMetricLabels(metricKey, aliases) {
413
+ const labels = new Set();
414
+ const keysToCheck = new Set([normalizeMetricKey(metricKey), ...aliases]);
415
+ for (const key of keysToCheck) {
416
+ const mappedLabels = METRIC_KEY_TO_LABELS[key] ?? [];
417
+ for (const label of mappedLabels) {
418
+ labels.add(normalizeMetricKey(label));
419
+ }
420
+ }
421
+ return labels;
422
+ }
358
423
  function parseNumericValue(value) {
359
424
  if (typeof value === "number" && Number.isFinite(value)) {
360
425
  return value;
@@ -362,11 +427,15 @@ function parseNumericValue(value) {
362
427
  if (typeof value !== "string") {
363
428
  return null;
364
429
  }
365
- const sanitized = value.replace(/[,_\s]/g, "");
366
- if (sanitized.length === 0) {
430
+ const compact = value.replace(/[,_\s]/g, "");
431
+ if (compact.length === 0) {
432
+ return null;
433
+ }
434
+ const cleaned = compact.replace(/[^0-9+.\-]/g, "");
435
+ if (!/[0-9]/.test(cleaned)) {
367
436
  return null;
368
437
  }
369
- const parsed = Number(sanitized);
438
+ const parsed = Number(cleaned);
370
439
  return Number.isFinite(parsed) ? parsed : null;
371
440
  }
372
441
  function readMetricValueFromRecord(record, aliases) {
@@ -514,8 +583,135 @@ function metricNamedKey(record) {
514
583
  function isLikelyMetricIdentifier(value) {
515
584
  return /[a-z]/.test(value) && value.length >= 3 && value.length <= 80;
516
585
  }
586
+ function extractMetricValueFromDashboardGroups(payload, metricKey, aliases) {
587
+ const expectedLabels = resolveMetricLabels(metricKey, aliases);
588
+ const matched = [];
589
+ const available = new Set();
590
+ const visited = new WeakSet();
591
+ let hasGroups = false;
592
+ const walk = (node, path) => {
593
+ if (Array.isArray(node)) {
594
+ for (let index = 0; index < node.length; index += 1) {
595
+ walk(node[index], `${path}[${index}]`);
596
+ }
597
+ return;
598
+ }
599
+ if (!isRecord(node)) {
600
+ return;
601
+ }
602
+ if (visited.has(node)) {
603
+ return;
604
+ }
605
+ visited.add(node);
606
+ const groups = asArray(node.groups);
607
+ if (groups.length > 0) {
608
+ hasGroups = true;
609
+ for (let groupIndex = 0; groupIndex < groups.length; groupIndex += 1) {
610
+ const group = groups[groupIndex];
611
+ if (!group) {
612
+ continue;
613
+ }
614
+ const items = asArray(group.items);
615
+ for (let itemIndex = 0; itemIndex < items.length; itemIndex += 1) {
616
+ const item = items[itemIndex];
617
+ if (!item) {
618
+ continue;
619
+ }
620
+ const itemPath = `${path}.groups[${groupIndex}].items[${itemIndex}]`;
621
+ const names = [item.short_name, item.name, item.raw_key, item.metric, item.key, item.chart_id, item.chartId]
622
+ .map((value) => readStringValue(value))
623
+ .filter((value) => Boolean(value));
624
+ const normalizedNames = new Set(names.map((value) => normalizeMetricKey(value)));
625
+ const title = readStringValue(item.name) ?? readStringValue(item.short_name) ?? readStringValue(item.raw_key);
626
+ if (title) {
627
+ available.add(title);
628
+ }
629
+ const matchesByAlias = Array.from(normalizedNames).some((name) => aliases.has(name));
630
+ const matchesByLabel = Array.from(normalizedNames).some((name) => expectedLabels.has(name));
631
+ if (!matchesByAlias && !matchesByLabel) {
632
+ continue;
633
+ }
634
+ const values = asArray(item.values);
635
+ for (let valueIndex = 0; valueIndex < values.length; valueIndex += 1) {
636
+ const valueEntry = values[valueIndex];
637
+ if (!valueEntry) {
638
+ continue;
639
+ }
640
+ if (normalizeMetricKey(String(valueEntry.name ?? "")) !== "value") {
641
+ continue;
642
+ }
643
+ const parsedValue = parseNumericValue(valueEntry.value);
644
+ if (parsedValue === null) {
645
+ continue;
646
+ }
647
+ matched.push({
648
+ name: title ?? "unknown",
649
+ path: `${itemPath}.values[${valueIndex}]`,
650
+ value: parsedValue,
651
+ });
652
+ }
653
+ }
654
+ }
655
+ }
656
+ for (const [key, value] of Object.entries(node)) {
657
+ walk(value, `${path}.${key}`);
658
+ }
659
+ };
660
+ walk(payload, "$");
661
+ if (!hasGroups) {
662
+ return { kind: "not_applicable" };
663
+ }
664
+ const availableMetrics = Array.from(available.values()).sort((left, right) => left.localeCompare(right));
665
+ if (matched.length === 1) {
666
+ const singleMatch = matched[0];
667
+ if (!singleMatch) {
668
+ return {
669
+ kind: "not_found",
670
+ availableMetrics,
671
+ };
672
+ }
673
+ return {
674
+ kind: "resolved",
675
+ value: singleMatch.value,
676
+ path: singleMatch.path,
677
+ };
678
+ }
679
+ if (matched.length > 1) {
680
+ return {
681
+ kind: "ambiguous",
682
+ candidates: matched.map((item) => ({ name: item.name, path: item.path })),
683
+ availableMetrics,
684
+ };
685
+ }
686
+ return {
687
+ kind: "not_found",
688
+ availableMetrics,
689
+ };
690
+ }
517
691
  export function extractMetricValue(payload, metricKey) {
518
692
  const aliases = expandMetricAliases(metricKey);
693
+ const dashboardExtraction = extractMetricValueFromDashboardGroups(payload, metricKey, aliases);
694
+ if (dashboardExtraction.kind === "resolved") {
695
+ return {
696
+ value: dashboardExtraction.value,
697
+ path: dashboardExtraction.path,
698
+ };
699
+ }
700
+ if (dashboardExtraction.kind === "ambiguous") {
701
+ return {
702
+ value: null,
703
+ code: "METRIC_AMBIGUOUS",
704
+ candidates: dashboardExtraction.candidates,
705
+ available_metrics: dashboardExtraction.availableMetrics,
706
+ };
707
+ }
708
+ if (dashboardExtraction.kind === "not_found") {
709
+ return {
710
+ value: null,
711
+ code: "METRIC_NOT_FOUND",
712
+ available_metrics: dashboardExtraction.availableMetrics,
713
+ };
714
+ }
519
715
  const search = (node, path, mode, visited) => {
520
716
  if (Array.isArray(node)) {
521
717
  for (let index = 0; index < node.length; index += 1) {
@@ -837,12 +1033,13 @@ export function extractMetricCatalog(payload) {
837
1033
  return Array.from(collected.values()).sort((a, b) => a.metric_key.localeCompare(b.metric_key));
838
1034
  }
839
1035
  function parseDashboardEventRecord(record) {
840
- const eventTypeRaw = readRecordString(record, EVENT_TYPE_KEYS);
1036
+ const properties = asRecord(record.properties);
1037
+ const eventTypeRaw = readRecordString(record, EVENT_TYPE_KEYS) ?? readRecordString(properties, EVENT_TYPE_KEYS);
841
1038
  const eventType = normalizeEventType(eventTypeRaw);
842
- const userId = readRecordString(record, EVENT_USER_ID_KEYS);
1039
+ const userId = readRecordString(record, EVENT_USER_ID_KEYS) ?? readRecordString(properties, EVENT_USER_ID_KEYS);
843
1040
  let occurredAt = null;
844
1041
  for (const key of ["occurred_at", "created_at", "timestamp", "time", "purchased_at"]) {
845
- const parsed = parseDateValue(record[key]);
1042
+ const parsed = parseDateValue(record[key] ?? properties[key]);
846
1043
  if (parsed) {
847
1044
  occurredAt = parsed;
848
1045
  break;
@@ -852,21 +1049,21 @@ function parseDashboardEventRecord(record) {
852
1049
  occurredAt = extractDateFromRecord(record);
853
1050
  }
854
1051
  const eventId = readRecordString(record, EVENT_ID_KEYS);
855
- const productId = readRecordString(record, EVENT_PRODUCT_ID_KEYS);
856
- const originalTransactionId = readRecordString(record, EVENT_ORIGINAL_TRANSACTION_ID_KEYS);
857
- const subscriptionStatus = readRecordString(record, EVENT_SUBSCRIPTION_STATUS_KEYS);
858
- const country = readRecordString(record, EVENT_COUNTRY_KEYS);
859
- const platform = normalizePlatform(readRecordString(record, EVENT_PLATFORM_KEYS));
1052
+ const productId = readRecordString(record, EVENT_PRODUCT_ID_KEYS) ?? readRecordString(properties, EVENT_PRODUCT_ID_KEYS);
1053
+ const originalTransactionId = readRecordString(record, EVENT_ORIGINAL_TRANSACTION_ID_KEYS) ?? readRecordString(properties, EVENT_ORIGINAL_TRANSACTION_ID_KEYS);
1054
+ const subscriptionStatus = readRecordString(record, EVENT_SUBSCRIPTION_STATUS_KEYS) ?? readRecordString(properties, EVENT_SUBSCRIPTION_STATUS_KEYS);
1055
+ const country = readRecordString(record, EVENT_COUNTRY_KEYS) ?? readRecordString(properties, EVENT_COUNTRY_KEYS);
1056
+ const platform = normalizePlatform(readRecordString(record, EVENT_PLATFORM_KEYS) ?? readRecordString(properties, EVENT_PLATFORM_KEYS));
860
1057
  const price = (() => {
861
1058
  for (const key of EVENT_PRICE_KEYS) {
862
- const parsed = parseNumericValue(record[key]);
1059
+ const parsed = parseNumericValue(record[key] ?? properties[key]);
863
1060
  if (parsed !== null) {
864
1061
  return parsed;
865
1062
  }
866
1063
  }
867
1064
  return undefined;
868
1065
  })();
869
- const currency = normalizeCurrency(readRecordString(record, EVENT_CURRENCY_KEYS));
1066
+ const currency = normalizeCurrency(readRecordString(record, EVENT_CURRENCY_KEYS) ?? readRecordString(properties, EVENT_CURRENCY_KEYS));
870
1067
  let score = 0;
871
1068
  if (eventType) {
872
1069
  score += 3;
@@ -1471,8 +1668,9 @@ export class ApphudClient {
1471
1668
  body.granularity = options.granularity;
1472
1669
  }
1473
1670
  if (options.metricKey) {
1474
- body.chart = options.metricKey;
1475
- body.metric = options.metricKey;
1671
+ const requestMetricKey = resolveMetricRequestKey(options.metricKey);
1672
+ body.chart = requestMetricKey;
1673
+ body.metric = requestMetricKey;
1476
1674
  }
1477
1675
  if (options.dimension) {
1478
1676
  body.dimension = options.dimension;
@@ -1492,6 +1690,7 @@ export class ApphudClient {
1492
1690
  }
1493
1691
  async fetchChartQuery(app, options) {
1494
1692
  const apphudAppId = options.apphudAppId ?? app.appId;
1693
+ const requestMetricKey = resolveMetricRequestKey(options.metricKey);
1495
1694
  const step = resolveAnalyticsStepDays({
1496
1695
  from: options.from,
1497
1696
  to: options.to,
@@ -1499,8 +1698,8 @@ export class ApphudClient {
1499
1698
  });
1500
1699
  const body = {
1501
1700
  app: apphudAppId,
1502
- chart: options.metricKey,
1503
- metric: options.metricKey,
1701
+ chart: requestMetricKey,
1702
+ metric: requestMetricKey,
1504
1703
  step,
1505
1704
  use_and: true,
1506
1705
  filters: this.normalizeFilters(options.filters),