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.
- package/README.md +78 -50
- package/dist/src/cli.js +16 -10
- package/dist/src/config/env.js +20 -20
- package/dist/src/domain/constants.js +19 -96
- package/dist/src/http/server.js +81 -1
- package/dist/src/mcp/server.js +14 -835
- package/dist/src/services/analyticsService.js +657 -122
- package/dist/src/services/apphudClient.js +225 -26
- package/dist/src/services/etlService.js +351 -436
- package/dist/src/tools/remoteTools.js +398 -0
- package/package.json +1 -1
|
@@ -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 = [
|
|
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"
|
|
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: [
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
366
|
-
if (
|
|
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(
|
|
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
|
|
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
|
-
|
|
1475
|
-
body.
|
|
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:
|
|
1503
|
-
metric:
|
|
1701
|
+
chart: requestMetricKey,
|
|
1702
|
+
metric: requestMetricKey,
|
|
1504
1703
|
step,
|
|
1505
1704
|
use_and: true,
|
|
1506
1705
|
filters: this.normalizeFilters(options.filters),
|