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.
@@ -1,6 +1,9 @@
1
1
  import { ApphudMcpError, isApphudMcpError } from "../errors/toolError.js";
2
2
  import { extractBreakdown, extractMetricValue, extractTimeseries, } from "./apphudClient.js";
3
3
  const ANALYTICS_ONLY_TOOLS = "Analytics tools use Apphud dashboard analytics endpoints directly";
4
+ const SNAPSHOT_METRIC_KEYS = new Set(["active_subs", "active_subscriptions", "active_subscription", "active_trial", "active_trials"]);
5
+ const REVENUE_METRIC_KEYS = new Set(["revenue", "revenue_gross", "gross_revenue", "sales", "proceeds", "cumulative_ltv", "cumulative_clv"]);
6
+ const REFUND_METRIC_KEYS = new Set(["refunds", "refund", "refund_rate"]);
4
7
  function normalizeEventTypeFilter(value) {
5
8
  if (!value) {
6
9
  return undefined;
@@ -65,6 +68,57 @@ function shiftPeriod(from, to) {
65
68
  to: prevTo.toISOString(),
66
69
  };
67
70
  }
71
+ function normalizeMetricKey(value) {
72
+ return value
73
+ .trim()
74
+ .toLowerCase()
75
+ .replace(/[^a-z0-9]+/g, "_")
76
+ .replace(/^_+|_+$/g, "");
77
+ }
78
+ function metricEventTypeFilter(metricKey) {
79
+ const normalized = normalizeMetricKey(metricKey);
80
+ if (normalized === "trials_started" || normalized === "trial_started") {
81
+ return "trial_started";
82
+ }
83
+ if (normalized === "trials_converted" || normalized === "trial_converted") {
84
+ return "trial_converted";
85
+ }
86
+ if (normalized === "new_subscriptions" || normalized === "subscription_started" || normalized === "regular_conversions") {
87
+ return "subscription_started";
88
+ }
89
+ if (normalized === "renewals" || normalized === "subscription_renewed") {
90
+ return "subscription_renewed";
91
+ }
92
+ if (normalized === "cancellations" || normalized === "trial_canceled" || normalized === "trial_cancelled") {
93
+ return "trial_canceled";
94
+ }
95
+ if (normalized === "new_users" || normalized === "created") {
96
+ return "created";
97
+ }
98
+ return undefined;
99
+ }
100
+ function isSnapshotMetric(metricKey) {
101
+ return SNAPSHOT_METRIC_KEYS.has(normalizeMetricKey(metricKey));
102
+ }
103
+ function isRevenueMetric(metricKey) {
104
+ return REVENUE_METRIC_KEYS.has(normalizeMetricKey(metricKey));
105
+ }
106
+ function isRefundMetric(metricKey) {
107
+ return REFUND_METRIC_KEYS.has(normalizeMetricKey(metricKey));
108
+ }
109
+ function eventDateBucket(occurredAt, granularity) {
110
+ const parsed = new Date(occurredAt);
111
+ if (Number.isNaN(parsed.getTime())) {
112
+ return null;
113
+ }
114
+ if (granularity === "day") {
115
+ return parsed.toISOString().slice(0, 10);
116
+ }
117
+ const weekStart = new Date(Date.UTC(parsed.getUTCFullYear(), parsed.getUTCMonth(), parsed.getUTCDate()));
118
+ const weekDay = (weekStart.getUTCDay() + 6) % 7;
119
+ weekStart.setUTCDate(weekStart.getUTCDate() - weekDay);
120
+ return weekStart.toISOString().slice(0, 10);
121
+ }
68
122
  export class AnalyticsService {
69
123
  appService;
70
124
  apphudClient;
@@ -249,6 +303,25 @@ export class AnalyticsService {
249
303
  includeRaw: input.include_raw,
250
304
  auth,
251
305
  });
306
+ if (resolved.code === "METRIC_AMBIGUOUS") {
307
+ throw new ApphudMcpError("METRIC_AMBIGUOUS", `Metric key '${input.metric_key}' matched multiple dashboard metrics`, {
308
+ statusCode: 400,
309
+ details: {
310
+ metric_key: input.metric_key,
311
+ candidates: resolved.candidates ?? [],
312
+ available_metrics: resolved.available_metrics ?? [],
313
+ },
314
+ });
315
+ }
316
+ if (resolved.value === null) {
317
+ throw new ApphudMcpError("METRIC_NOT_FOUND", `Metric key '${input.metric_key}' not found in analytics payload`, {
318
+ statusCode: 404,
319
+ details: {
320
+ metric_key: input.metric_key,
321
+ available_metrics: resolved.available_metrics ?? [],
322
+ },
323
+ });
324
+ }
252
325
  return {
253
326
  app_id: app.appId,
254
327
  apphud_app_id: input.apphud_app_id ?? app.appId,
@@ -287,21 +360,77 @@ export class AnalyticsService {
287
360
  extractedFrom = extractedDash.path;
288
361
  if (points.length === 0) {
289
362
  warnings.push("No timeseries in dash/range payload, fallback to chart/query/line");
290
- const chart = await this.apphudClient.fetchChartQuery(app, {
291
- shape: "line",
292
- metricKey: input.metric_key,
363
+ try {
364
+ const chart = await this.apphudClient.fetchChartQuery(app, {
365
+ shape: "line",
366
+ metricKey: input.metric_key,
367
+ apphudAppId: input.apphud_app_id,
368
+ from: input.from,
369
+ to: input.to,
370
+ granularity: input.granularity,
371
+ platform: input.platform,
372
+ filters: input.filters,
373
+ });
374
+ sourceUsed = chart.sourcePath;
375
+ rawPayload = chart.payload;
376
+ const extractedLine = extractTimeseries(chart.payload, input.metric_key);
377
+ points = extractedLine.points;
378
+ extractedFrom = extractedLine.path;
379
+ }
380
+ catch (error) {
381
+ const chartWarning = this.chartQueryFallbackWarning(error);
382
+ if (!chartWarning) {
383
+ throw error;
384
+ }
385
+ warnings.push(chartWarning);
386
+ }
387
+ }
388
+ if (points.length === 0) {
389
+ const fromEvents = await this.fallbackTimeseriesFromEvents(app, {
293
390
  apphudAppId: input.apphud_app_id,
391
+ metricKey: input.metric_key,
294
392
  from: input.from,
295
393
  to: input.to,
296
- granularity: input.granularity,
394
+ granularity: input.granularity ?? "day",
297
395
  platform: input.platform,
298
- filters: input.filters,
299
396
  });
300
- sourceUsed = chart.sourcePath;
301
- rawPayload = chart.payload;
302
- const extractedLine = extractTimeseries(chart.payload, input.metric_key);
303
- points = extractedLine.points;
304
- extractedFrom = extractedLine.path;
397
+ if (fromEvents.points.length > 0) {
398
+ sourceUsed = fromEvents.sourceUsed;
399
+ rawPayload = fromEvents.rawPayload;
400
+ extractedFrom = fromEvents.extractedFrom;
401
+ points = fromEvents.points.map((point) => ({ ...point }));
402
+ const dashValue = extractMetricValue(dashRange.payload, input.metric_key).value;
403
+ if (typeof dashValue === "number" && points.length > 0) {
404
+ const pointsTotal = points.reduce((sum, point) => sum + point.value, 0);
405
+ const delta = Number((dashValue - pointsTotal).toFixed(6));
406
+ const maxAllowedAdjustment = Math.max(1, Math.abs(dashValue) * 0.05);
407
+ if (Math.abs(delta) > 0 && Math.abs(delta) <= maxAllowedAdjustment) {
408
+ const lastIndex = points.length - 1;
409
+ const lastPoint = points[lastIndex];
410
+ if (lastPoint) {
411
+ points[lastIndex] = {
412
+ ...lastPoint,
413
+ value: Number((lastPoint.value + delta).toFixed(6)),
414
+ };
415
+ warnings.push("Events timeseries adjusted to match dashboard total");
416
+ }
417
+ }
418
+ }
419
+ warnings.push("Timeseries built from events feed because chart/query/line is unavailable");
420
+ }
421
+ else {
422
+ const extractedValue = extractMetricValue(dashRange.payload, input.metric_key);
423
+ if (typeof extractedValue.value === "number") {
424
+ points = [
425
+ {
426
+ date: safeIso(input.to),
427
+ value: Number(extractedValue.value.toFixed(4)),
428
+ },
429
+ ];
430
+ extractedFrom = extractedValue.path;
431
+ warnings.push("Timeseries not available; returned single aggregated point from dashboard range");
432
+ }
433
+ }
305
434
  }
306
435
  const summary = toPointSummary(points);
307
436
  return {
@@ -331,21 +460,31 @@ export class AnalyticsService {
331
460
  let sourceUsed = "/chart/query/column";
332
461
  let extractedFrom;
333
462
  let rawPayload;
334
- const column = await this.apphudClient.fetchChartQuery(app, {
335
- shape: "column",
336
- metricKey: input.metric_key,
337
- apphudAppId: input.apphud_app_id,
338
- from: input.from,
339
- to: input.to,
340
- granularity: input.granularity,
341
- dimension: input.dimension,
342
- platform: input.platform,
343
- filters: input.filters,
344
- });
345
- rawPayload = column.payload;
346
- const extractedColumn = extractBreakdown(column.payload, input.metric_key);
347
- let rows = extractedColumn.rows;
348
- extractedFrom = extractedColumn.path;
463
+ let rows = [];
464
+ try {
465
+ const column = await this.apphudClient.fetchChartQuery(app, {
466
+ shape: "column",
467
+ metricKey: input.metric_key,
468
+ apphudAppId: input.apphud_app_id,
469
+ from: input.from,
470
+ to: input.to,
471
+ granularity: input.granularity,
472
+ dimension: input.dimension,
473
+ platform: input.platform,
474
+ filters: input.filters,
475
+ });
476
+ rawPayload = column.payload;
477
+ const extractedColumn = extractBreakdown(column.payload, input.metric_key);
478
+ rows = extractedColumn.rows;
479
+ extractedFrom = extractedColumn.path;
480
+ }
481
+ catch (error) {
482
+ const chartWarning = this.chartQueryFallbackWarning(error);
483
+ if (!chartWarning) {
484
+ throw error;
485
+ }
486
+ warnings.push(chartWarning);
487
+ }
349
488
  if (rows.length === 0) {
350
489
  warnings.push("No breakdown in chart/query/column payload, fallback to dash/range");
351
490
  const dashRange = await this.apphudClient.fetchDashRange(app, {
@@ -364,6 +503,28 @@ export class AnalyticsService {
364
503
  rows = extractedRange.rows;
365
504
  extractedFrom = extractedRange.path;
366
505
  }
506
+ const isAggregateDashRows = sourceUsed === "/dash/range" && typeof extractedFrom === "string" && extractedFrom.includes(".groups");
507
+ if (rows.length > 0 && input.dimension && isAggregateDashRows) {
508
+ warnings.push("dash/range returned aggregate metric rows, fallback to events feed for requested dimension");
509
+ rows = [];
510
+ }
511
+ if (rows.length === 0) {
512
+ const fromEvents = await this.fallbackBreakdownFromEvents(app, {
513
+ apphudAppId: input.apphud_app_id,
514
+ metricKey: input.metric_key,
515
+ from: input.from,
516
+ to: input.to,
517
+ platform: input.platform,
518
+ dimension: input.dimension,
519
+ });
520
+ if (fromEvents.rows.length > 0) {
521
+ rows = fromEvents.rows;
522
+ sourceUsed = fromEvents.sourceUsed;
523
+ extractedFrom = fromEvents.extractedFrom;
524
+ rawPayload = fromEvents.rawPayload;
525
+ warnings.push("Breakdown built from events feed because chart/query/column is unavailable");
526
+ }
527
+ }
367
528
  const limit = input.limit ?? 20;
368
529
  const limited = rows.slice(0, limit);
369
530
  return {
@@ -467,9 +628,16 @@ export class AnalyticsService {
467
628
  includeRaw: false,
468
629
  auth,
469
630
  };
631
+ const activeMetricInputBase = {
632
+ apphudAppId: input.apphud_app_id,
633
+ platform: input.platform,
634
+ filters: input.filters,
635
+ includeRaw: false,
636
+ auth,
637
+ };
470
638
  const [activePaid, activeTrials, newSubs, renewals, cancellations] = await Promise.all([
471
- this.resolveMetricValue(app, { ...metricInputBase, metricKey: "active_subs" }),
472
- this.resolveMetricValue(app, { ...metricInputBase, metricKey: "active_trials" }),
639
+ this.resolveMetricValue(app, { ...activeMetricInputBase, metricKey: "active_subs" }),
640
+ this.resolveMetricValue(app, { ...activeMetricInputBase, metricKey: "active_trials" }),
473
641
  this.resolveMetricValue(app, { ...metricInputBase, metricKey: "new_subscriptions" }),
474
642
  this.resolveMetricValue(app, { ...metricInputBase, metricKey: "renewals" }),
475
643
  this.resolveMetricValue(app, { ...metricInputBase, metricKey: "cancellations" }),
@@ -567,41 +735,55 @@ export class AnalyticsService {
567
735
  async cohortsRetention(auth, input) {
568
736
  const app = await this.resolveApp(auth, input.app_id, input.apphud_app_id);
569
737
  this.assertRawAccess(auth, input.include_raw);
570
- const chart = await this.apphudClient.fetchChartQuery(app, {
571
- shape: "column",
572
- metricKey: "subscribers_retention",
573
- apphudAppId: input.apphud_app_id,
574
- from: input.from,
575
- to: input.to,
576
- granularity: input.granularity,
577
- platform: input.platform,
578
- filters: input.filters,
579
- });
580
- const fromBreakdown = extractBreakdown(chart.payload, "subscribers_retention");
581
- const fromSeries = extractTimeseries(chart.payload, "subscribers_retention");
582
738
  let periods = [];
583
- let extractedFrom = fromBreakdown.path ?? fromSeries.path;
739
+ let extractedFrom;
740
+ let sourceUsed = "/api/v1/chart/query/column";
741
+ let rawPayload;
584
742
  const warnings = [];
585
- if (fromBreakdown.rows.length > 0) {
586
- periods = fromBreakdown.rows
587
- .slice(0, input.max_periods ?? 52)
588
- .map((row) => ({
589
- period_index: periodIndexFromLabel(row.key),
590
- retention_rate: asRetentionRate(row.value),
591
- users_count: null,
592
- }))
593
- .sort((a, b) => Number(a.period_index) - Number(b.period_index));
594
- }
595
- else if (fromSeries.points.length > 0) {
596
- periods = fromSeries.points
597
- .slice(0, input.max_periods ?? 52)
598
- .map((point, index) => ({
599
- period_index: index,
600
- retention_rate: asRetentionRate(point.value),
601
- users_count: null,
602
- }));
743
+ try {
744
+ const chart = await this.apphudClient.fetchChartQuery(app, {
745
+ shape: "column",
746
+ metricKey: "subscribers_retention_new",
747
+ apphudAppId: input.apphud_app_id,
748
+ from: input.from,
749
+ to: input.to,
750
+ granularity: input.granularity,
751
+ platform: input.platform,
752
+ filters: input.filters,
753
+ });
754
+ const fromBreakdown = extractBreakdown(chart.payload, "subscribers_retention");
755
+ const fromSeries = extractTimeseries(chart.payload, "subscribers_retention");
756
+ extractedFrom = fromBreakdown.path ?? fromSeries.path;
757
+ sourceUsed = chart.sourcePath;
758
+ rawPayload = chart.payload;
759
+ if (fromBreakdown.rows.length > 0) {
760
+ periods = fromBreakdown.rows
761
+ .slice(0, input.max_periods ?? 52)
762
+ .map((row) => ({
763
+ period_index: periodIndexFromLabel(row.key),
764
+ retention_rate: asRetentionRate(row.value),
765
+ users_count: null,
766
+ }))
767
+ .sort((a, b) => Number(a.period_index) - Number(b.period_index));
768
+ }
769
+ else if (fromSeries.points.length > 0) {
770
+ periods = fromSeries.points
771
+ .slice(0, input.max_periods ?? 52)
772
+ .map((point, index) => ({
773
+ period_index: index,
774
+ retention_rate: asRetentionRate(point.value),
775
+ users_count: null,
776
+ }));
777
+ }
778
+ }
779
+ catch (error) {
780
+ const chartWarning = this.chartQueryFallbackWarning(error);
781
+ if (!chartWarning) {
782
+ throw error;
783
+ }
784
+ warnings.push(chartWarning);
603
785
  }
604
- else {
786
+ if (periods.length === 0) {
605
787
  warnings.push("No retention series returned by analytics endpoint");
606
788
  }
607
789
  return {
@@ -619,33 +801,77 @@ export class AnalyticsService {
619
801
  },
620
802
  ],
621
803
  source: "apphud_analytics_api",
622
- source_used: chart.sourcePath,
804
+ source_used: sourceUsed,
623
805
  extracted_from: extractedFrom,
624
806
  warnings,
625
807
  retrieved_at: new Date().toISOString(),
626
- raw_payload: this.selectRawPayload(auth, input.include_raw, chart.payload),
808
+ raw_payload: this.selectRawPayload(auth, input.include_raw, rawPayload),
627
809
  };
628
810
  }
629
811
  async cohortsLtv(auth, input) {
630
812
  const app = await this.resolveApp(auth, input.app_id, input.apphud_app_id);
631
813
  this.assertRawAccess(auth, input.include_raw);
632
- const chart = await this.apphudClient.fetchChartQuery(app, {
633
- shape: "line",
634
- metricKey: "cumulative_ltv",
635
- apphudAppId: input.apphud_app_id,
636
- from: input.from,
637
- to: input.to,
638
- granularity: input.granularity,
639
- platform: input.platform,
640
- filters: input.filters,
641
- });
642
- const series = extractTimeseries(chart.payload, "cumulative_ltv");
643
- const periods = series.points.slice(0, input.max_periods ?? 52).map((point, index) => ({
644
- period_index: index,
645
- ltv_value: Number(point.value.toFixed(6)),
646
- date: point.date,
647
- }));
648
- const warnings = periods.length === 0 ? ["No LTV timeseries returned by analytics endpoint"] : [];
814
+ const warnings = [];
815
+ let sourceUsed = "/api/v1/chart/query/line";
816
+ let extractedFrom;
817
+ let rawPayload;
818
+ let periods = [];
819
+ try {
820
+ const chart = await this.apphudClient.fetchChartQuery(app, {
821
+ shape: "line",
822
+ metricKey: "cumulative_ltv",
823
+ apphudAppId: input.apphud_app_id,
824
+ from: input.from,
825
+ to: input.to,
826
+ granularity: input.granularity,
827
+ platform: input.platform,
828
+ filters: input.filters,
829
+ });
830
+ const series = extractTimeseries(chart.payload, "cumulative_ltv");
831
+ periods = series.points.slice(0, input.max_periods ?? 52).map((point, index) => ({
832
+ period_index: index,
833
+ ltv_value: Number(point.value.toFixed(6)),
834
+ date: point.date,
835
+ }));
836
+ extractedFrom = series.path;
837
+ sourceUsed = chart.sourcePath;
838
+ rawPayload = chart.payload;
839
+ }
840
+ catch (error) {
841
+ const chartWarning = this.chartQueryFallbackWarning(error);
842
+ if (!chartWarning) {
843
+ throw error;
844
+ }
845
+ warnings.push(chartWarning);
846
+ }
847
+ if (periods.length === 0) {
848
+ const fromEvents = await this.fallbackTimeseriesFromEvents(app, {
849
+ apphudAppId: input.apphud_app_id,
850
+ metricKey: "revenue_gross",
851
+ from: input.from,
852
+ to: input.to,
853
+ granularity: input.granularity ?? "week",
854
+ platform: input.platform,
855
+ });
856
+ if (fromEvents.points.length > 0) {
857
+ let cumulative = 0;
858
+ periods = fromEvents.points.slice(0, input.max_periods ?? 52).map((point, index) => {
859
+ cumulative += point.value;
860
+ return {
861
+ period_index: index,
862
+ ltv_value: Number(cumulative.toFixed(6)),
863
+ date: point.date,
864
+ };
865
+ });
866
+ sourceUsed = fromEvents.sourceUsed;
867
+ extractedFrom = fromEvents.extractedFrom;
868
+ rawPayload = fromEvents.rawPayload;
869
+ warnings.push("LTV approximated from cumulative revenue events because chart/query/line is unavailable");
870
+ }
871
+ }
872
+ if (periods.length === 0) {
873
+ warnings.push("No LTV timeseries returned by analytics endpoint");
874
+ }
649
875
  return {
650
876
  app_id: app.appId,
651
877
  apphud_app_id: input.apphud_app_id ?? app.appId,
@@ -659,11 +885,11 @@ export class AnalyticsService {
659
885
  },
660
886
  ],
661
887
  source: "apphud_analytics_api",
662
- source_used: chart.sourcePath,
663
- extracted_from: series.path,
888
+ source_used: sourceUsed,
889
+ extracted_from: extractedFrom,
664
890
  warnings,
665
891
  retrieved_at: new Date().toISOString(),
666
- raw_payload: this.selectRawPayload(auth, input.include_raw, chart.payload),
892
+ raw_payload: this.selectRawPayload(auth, input.include_raw, rawPayload),
667
893
  };
668
894
  }
669
895
  async queryRaw(auth, input) {
@@ -802,6 +1028,16 @@ export class AnalyticsService {
802
1028
  }
803
1029
  async resolveMetricValue(app, options) {
804
1030
  const warnings = [];
1031
+ const availableMetrics = new Set();
1032
+ let ambiguousCandidates;
1033
+ const collectExtractionMeta = (extracted) => {
1034
+ for (const metricName of extracted.available_metrics ?? []) {
1035
+ availableMetrics.add(metricName);
1036
+ }
1037
+ if (extracted.code === "METRIC_AMBIGUOUS" && extracted.candidates && extracted.candidates.length > 0) {
1038
+ ambiguousCandidates = extracted.candidates;
1039
+ }
1040
+ };
805
1041
  if (options.from && options.to) {
806
1042
  const dashRange = await this.apphudClient.fetchDashRange(app, {
807
1043
  apphudAppId: options.apphudAppId,
@@ -812,6 +1048,18 @@ export class AnalyticsService {
812
1048
  filters: options.filters,
813
1049
  });
814
1050
  const extractedRange = extractMetricValue(dashRange.payload, options.metricKey);
1051
+ collectExtractionMeta(extractedRange);
1052
+ if (extractedRange.code === "METRIC_AMBIGUOUS") {
1053
+ return {
1054
+ value: null,
1055
+ source_used: "/dash/range",
1056
+ raw_payload: this.selectRawPayload(options.auth, options.includeRaw, dashRange.payload),
1057
+ warnings: [...warnings, "Metric key matched multiple dashboard rows"],
1058
+ code: "METRIC_AMBIGUOUS",
1059
+ available_metrics: Array.from(availableMetrics.values()).sort((left, right) => left.localeCompare(right)),
1060
+ candidates: ambiguousCandidates ?? [],
1061
+ };
1062
+ }
815
1063
  if (extractedRange.value !== null) {
816
1064
  return {
817
1065
  value: extractedRange.value,
@@ -832,42 +1080,101 @@ export class AnalyticsService {
832
1080
  warnings,
833
1081
  };
834
1082
  }
1083
+ if (isSnapshotMetric(options.metricKey)) {
1084
+ const dashNow = await this.apphudClient.fetchDashNow(app, {
1085
+ apphudAppId: options.apphudAppId,
1086
+ platform: options.platform,
1087
+ filters: options.filters,
1088
+ });
1089
+ const extractedNow = extractMetricValue(dashNow.payload, options.metricKey);
1090
+ collectExtractionMeta(extractedNow);
1091
+ if (extractedNow.value !== null) {
1092
+ warnings.push("Range requested for snapshot metric; used current dashboard value from /dash/now");
1093
+ return {
1094
+ value: extractedNow.value,
1095
+ source_used: "/dash/now",
1096
+ extracted_from: extractedNow.path,
1097
+ raw_payload: this.selectRawPayload(options.auth, options.includeRaw, dashNow.payload),
1098
+ warnings,
1099
+ };
1100
+ }
1101
+ }
835
1102
  warnings.push("Metric not found in dash/range payload, fallback to chart/query/line");
836
- const chart = await this.apphudClient.fetchChartQuery(app, {
837
- shape: "line",
838
- metricKey: options.metricKey,
1103
+ try {
1104
+ const chart = await this.apphudClient.fetchChartQuery(app, {
1105
+ shape: "line",
1106
+ metricKey: options.metricKey,
1107
+ apphudAppId: options.apphudAppId,
1108
+ from: options.from,
1109
+ to: options.to,
1110
+ platform: options.platform,
1111
+ filters: options.filters,
1112
+ });
1113
+ const extractedChart = extractMetricValue(chart.payload, options.metricKey);
1114
+ collectExtractionMeta(extractedChart);
1115
+ if (extractedChart.code === "METRIC_AMBIGUOUS") {
1116
+ return {
1117
+ value: null,
1118
+ source_used: chart.sourcePath,
1119
+ raw_payload: this.selectRawPayload(options.auth, options.includeRaw, chart.payload),
1120
+ warnings: [...warnings, "Metric key matched multiple dashboard rows"],
1121
+ code: "METRIC_AMBIGUOUS",
1122
+ available_metrics: Array.from(availableMetrics.values()).sort((left, right) => left.localeCompare(right)),
1123
+ candidates: ambiguousCandidates ?? [],
1124
+ };
1125
+ }
1126
+ if (extractedChart.value !== null) {
1127
+ return {
1128
+ value: extractedChart.value,
1129
+ source_used: chart.sourcePath,
1130
+ extracted_from: extractedChart.path,
1131
+ raw_payload: this.selectRawPayload(options.auth, options.includeRaw, chart.payload),
1132
+ warnings,
1133
+ };
1134
+ }
1135
+ const fromChartSeries = extractTimeseries(chart.payload, options.metricKey);
1136
+ if (fromChartSeries.points.length > 0) {
1137
+ const total = fromChartSeries.points.reduce((sum, point) => sum + point.value, 0);
1138
+ return {
1139
+ value: Number(total.toFixed(4)),
1140
+ source_used: chart.sourcePath,
1141
+ extracted_from: fromChartSeries.path,
1142
+ raw_payload: this.selectRawPayload(options.auth, options.includeRaw, chart.payload),
1143
+ warnings,
1144
+ };
1145
+ }
1146
+ }
1147
+ catch (error) {
1148
+ const chartWarning = this.chartQueryFallbackWarning(error);
1149
+ if (!chartWarning) {
1150
+ throw error;
1151
+ }
1152
+ warnings.push(chartWarning);
1153
+ }
1154
+ const fromEvents = await this.fallbackMetricValueFromEvents(app, {
839
1155
  apphudAppId: options.apphudAppId,
1156
+ metricKey: options.metricKey,
840
1157
  from: options.from,
841
1158
  to: options.to,
842
1159
  platform: options.platform,
843
- filters: options.filters,
844
1160
  });
845
- const extractedChart = extractMetricValue(chart.payload, options.metricKey);
846
- if (extractedChart.value !== null) {
847
- return {
848
- value: extractedChart.value,
849
- source_used: chart.sourcePath,
850
- extracted_from: extractedChart.path,
851
- raw_payload: this.selectRawPayload(options.auth, options.includeRaw, chart.payload),
852
- warnings,
853
- };
854
- }
855
- const fromChartSeries = extractTimeseries(chart.payload, options.metricKey);
856
- if (fromChartSeries.points.length > 0) {
857
- const total = fromChartSeries.points.reduce((sum, point) => sum + point.value, 0);
1161
+ if (fromEvents.value !== null) {
1162
+ warnings.push("Metric value estimated from events feed because chart/query/line is unavailable");
858
1163
  return {
859
- value: Number(total.toFixed(4)),
860
- source_used: chart.sourcePath,
861
- extracted_from: fromChartSeries.path,
862
- raw_payload: this.selectRawPayload(options.auth, options.includeRaw, chart.payload),
1164
+ value: fromEvents.value,
1165
+ source_used: fromEvents.sourceUsed,
1166
+ extracted_from: fromEvents.extractedFrom,
1167
+ raw_payload: this.selectRawPayload(options.auth, options.includeRaw, fromEvents.rawPayload),
863
1168
  warnings,
864
1169
  };
865
1170
  }
866
1171
  return {
867
1172
  value: null,
868
- source_used: chart.sourcePath,
869
- raw_payload: this.selectRawPayload(options.auth, options.includeRaw, chart.payload),
1173
+ source_used: "/dash/range",
1174
+ raw_payload: this.selectRawPayload(options.auth, options.includeRaw, dashRange.payload),
870
1175
  warnings: [...warnings, "Metric not found in analytics payloads"],
1176
+ code: "METRIC_NOT_FOUND",
1177
+ available_metrics: Array.from(availableMetrics.values()).sort((left, right) => left.localeCompare(right)),
871
1178
  };
872
1179
  }
873
1180
  const dashNow = await this.apphudClient.fetchDashNow(app, {
@@ -876,6 +1183,18 @@ export class AnalyticsService {
876
1183
  filters: options.filters,
877
1184
  });
878
1185
  const extractedNow = extractMetricValue(dashNow.payload, options.metricKey);
1186
+ collectExtractionMeta(extractedNow);
1187
+ if (extractedNow.code === "METRIC_AMBIGUOUS") {
1188
+ return {
1189
+ value: null,
1190
+ source_used: "/dash/now",
1191
+ raw_payload: this.selectRawPayload(options.auth, options.includeRaw, dashNow.payload),
1192
+ warnings: [...warnings, "Metric key matched multiple dashboard rows"],
1193
+ code: "METRIC_AMBIGUOUS",
1194
+ available_metrics: Array.from(availableMetrics.values()).sort((left, right) => left.localeCompare(right)),
1195
+ candidates: ambiguousCandidates ?? [],
1196
+ };
1197
+ }
879
1198
  if (extractedNow.value !== null) {
880
1199
  return {
881
1200
  value: extractedNow.value,
@@ -886,33 +1205,249 @@ export class AnalyticsService {
886
1205
  };
887
1206
  }
888
1207
  warnings.push("Metric not found in dash/now payload, fallback to chart/query/line");
889
- const chartNow = await this.apphudClient.fetchChartQuery(app, {
890
- shape: "line",
891
- metricKey: options.metricKey,
1208
+ try {
1209
+ const chartNow = await this.apphudClient.fetchChartQuery(app, {
1210
+ shape: "line",
1211
+ metricKey: options.metricKey,
1212
+ apphudAppId: options.apphudAppId,
1213
+ platform: options.platform,
1214
+ filters: options.filters,
1215
+ });
1216
+ const extractedChartNow = extractMetricValue(chartNow.payload, options.metricKey);
1217
+ collectExtractionMeta(extractedChartNow);
1218
+ if (extractedChartNow.code === "METRIC_AMBIGUOUS") {
1219
+ return {
1220
+ value: null,
1221
+ source_used: chartNow.sourcePath,
1222
+ raw_payload: this.selectRawPayload(options.auth, options.includeRaw, chartNow.payload),
1223
+ warnings: [...warnings, "Metric key matched multiple dashboard rows"],
1224
+ code: "METRIC_AMBIGUOUS",
1225
+ available_metrics: Array.from(availableMetrics.values()).sort((left, right) => left.localeCompare(right)),
1226
+ candidates: ambiguousCandidates ?? [],
1227
+ };
1228
+ }
1229
+ if (extractedChartNow.value !== null) {
1230
+ return {
1231
+ value: extractedChartNow.value,
1232
+ source_used: chartNow.sourcePath,
1233
+ extracted_from: extractedChartNow.path,
1234
+ raw_payload: this.selectRawPayload(options.auth, options.includeRaw, chartNow.payload),
1235
+ warnings,
1236
+ };
1237
+ }
1238
+ const series = extractTimeseries(chartNow.payload, options.metricKey);
1239
+ const lastPoint = series.points[series.points.length - 1];
1240
+ return {
1241
+ value: lastPoint?.value ?? null,
1242
+ source_used: chartNow.sourcePath,
1243
+ extracted_from: series.path,
1244
+ raw_payload: this.selectRawPayload(options.auth, options.includeRaw, chartNow.payload),
1245
+ warnings: [...warnings, "Metric value estimated from last chart point"],
1246
+ code: lastPoint ? undefined : "METRIC_NOT_FOUND",
1247
+ available_metrics: Array.from(availableMetrics.values()).sort((left, right) => left.localeCompare(right)),
1248
+ };
1249
+ }
1250
+ catch (error) {
1251
+ const chartWarning = this.chartQueryFallbackWarning(error);
1252
+ if (!chartWarning) {
1253
+ throw error;
1254
+ }
1255
+ return {
1256
+ value: null,
1257
+ source_used: "/dash/now",
1258
+ raw_payload: this.selectRawPayload(options.auth, options.includeRaw, dashNow.payload),
1259
+ warnings: [...warnings, chartWarning, "Metric not found in analytics payloads"],
1260
+ code: "METRIC_NOT_FOUND",
1261
+ available_metrics: Array.from(availableMetrics.values()).sort((left, right) => left.localeCompare(right)),
1262
+ };
1263
+ }
1264
+ }
1265
+ chartQueryFallbackWarning(error) {
1266
+ if (!isApphudMcpError(error) || error.code !== "UPSTREAM_ERROR") {
1267
+ return undefined;
1268
+ }
1269
+ const status = typeof error.details?.status === "number" ? error.details.status : undefined;
1270
+ const endpoint = typeof error.details?.endpoint === "string" ? error.details.endpoint : "";
1271
+ if (!endpoint.includes("/api/v1/chart/query/")) {
1272
+ return undefined;
1273
+ }
1274
+ if (status !== 400 && status !== 404) {
1275
+ return undefined;
1276
+ }
1277
+ const bodyText = typeof error.details?.body === "string" ? error.details.body : "";
1278
+ let detailsMessage = "";
1279
+ if (bodyText) {
1280
+ try {
1281
+ const parsed = JSON.parse(bodyText);
1282
+ if (typeof parsed.details === "string" && parsed.details.trim().length > 0) {
1283
+ detailsMessage = parsed.details.trim();
1284
+ }
1285
+ else if (typeof parsed.message === "string" && parsed.message.trim().length > 0) {
1286
+ detailsMessage = parsed.message.trim();
1287
+ }
1288
+ }
1289
+ catch {
1290
+ detailsMessage = bodyText.slice(0, 120).trim();
1291
+ }
1292
+ }
1293
+ return detailsMessage.length > 0
1294
+ ? `Chart query fallback: ${detailsMessage}`
1295
+ : "Chart query fallback: endpoint rejected request";
1296
+ }
1297
+ async fallbackTimeseriesFromEvents(app, options) {
1298
+ const eventType = metricEventTypeFilter(options.metricKey);
1299
+ const eventsResponse = await this.apphudClient.fetchDashboardEvents(app, {
892
1300
  apphudAppId: options.apphudAppId,
1301
+ from: options.from,
1302
+ to: options.to,
1303
+ limit: 500,
1304
+ eventType,
1305
+ platform: options.platform,
1306
+ });
1307
+ const byBucket = new Map();
1308
+ for (const event of eventsResponse.events) {
1309
+ if (!event.occurred_at) {
1310
+ continue;
1311
+ }
1312
+ const bucket = eventDateBucket(event.occurred_at, options.granularity);
1313
+ if (!bucket) {
1314
+ continue;
1315
+ }
1316
+ const contribution = this.eventMetricContribution(options.metricKey, event);
1317
+ if (contribution === null) {
1318
+ continue;
1319
+ }
1320
+ byBucket.set(bucket, Number(((byBucket.get(bucket) ?? 0) + contribution).toFixed(6)));
1321
+ }
1322
+ const points = Array.from(byBucket.entries())
1323
+ .sort(([left], [right]) => left.localeCompare(right))
1324
+ .map(([bucket, value]) => ({
1325
+ date: `${bucket}T00:00:00.000Z`,
1326
+ value: Number(value.toFixed(6)),
1327
+ }));
1328
+ return {
1329
+ points,
1330
+ sourceUsed: eventsResponse.sourcePath,
1331
+ extractedFrom: eventsResponse.extractionPath,
1332
+ rawPayload: eventsResponse.rawPayload,
1333
+ };
1334
+ }
1335
+ async fallbackBreakdownFromEvents(app, options) {
1336
+ const eventType = metricEventTypeFilter(options.metricKey);
1337
+ const eventsResponse = await this.apphudClient.fetchDashboardEvents(app, {
1338
+ apphudAppId: options.apphudAppId,
1339
+ from: options.from,
1340
+ to: options.to,
1341
+ limit: 500,
1342
+ eventType,
1343
+ platform: options.platform,
1344
+ });
1345
+ const grouped = new Map();
1346
+ for (const event of eventsResponse.events) {
1347
+ const key = this.eventDimensionValue(event, options.dimension);
1348
+ if (!key) {
1349
+ continue;
1350
+ }
1351
+ const contribution = this.eventMetricContribution(options.metricKey, event);
1352
+ if (contribution === null) {
1353
+ continue;
1354
+ }
1355
+ grouped.set(key, Number(((grouped.get(key) ?? 0) + contribution).toFixed(6)));
1356
+ }
1357
+ const rows = Array.from(grouped.entries())
1358
+ .map(([key, value]) => ({
1359
+ key,
1360
+ value: Number(value.toFixed(6)),
1361
+ }))
1362
+ .sort((left, right) => {
1363
+ if (right.value !== left.value) {
1364
+ return right.value - left.value;
1365
+ }
1366
+ return left.key.localeCompare(right.key);
1367
+ });
1368
+ return {
1369
+ rows,
1370
+ sourceUsed: eventsResponse.sourcePath,
1371
+ extractedFrom: eventsResponse.extractionPath,
1372
+ rawPayload: eventsResponse.rawPayload,
1373
+ };
1374
+ }
1375
+ async fallbackMetricValueFromEvents(app, options) {
1376
+ const fromEvents = await this.fallbackTimeseriesFromEvents(app, {
1377
+ apphudAppId: options.apphudAppId,
1378
+ metricKey: options.metricKey,
1379
+ from: options.from,
1380
+ to: options.to,
1381
+ granularity: "day",
893
1382
  platform: options.platform,
894
- filters: options.filters,
895
1383
  });
896
- const extractedChartNow = extractMetricValue(chartNow.payload, options.metricKey);
897
- if (extractedChartNow.value !== null) {
1384
+ if (fromEvents.points.length === 0) {
898
1385
  return {
899
- value: extractedChartNow.value,
900
- source_used: chartNow.sourcePath,
901
- extracted_from: extractedChartNow.path,
902
- raw_payload: this.selectRawPayload(options.auth, options.includeRaw, chartNow.payload),
903
- warnings,
1386
+ value: null,
1387
+ sourceUsed: fromEvents.sourceUsed,
1388
+ extractedFrom: fromEvents.extractedFrom,
1389
+ rawPayload: fromEvents.rawPayload,
904
1390
  };
905
1391
  }
906
- const series = extractTimeseries(chartNow.payload, options.metricKey);
907
- const lastPoint = series.points[series.points.length - 1];
1392
+ const total = fromEvents.points.reduce((sum, point) => sum + point.value, 0);
908
1393
  return {
909
- value: lastPoint?.value ?? null,
910
- source_used: chartNow.sourcePath,
911
- extracted_from: series.path,
912
- raw_payload: this.selectRawPayload(options.auth, options.includeRaw, chartNow.payload),
913
- warnings: [...warnings, "Metric value estimated from last chart point"],
1394
+ value: Number(total.toFixed(6)),
1395
+ sourceUsed: fromEvents.sourceUsed,
1396
+ extractedFrom: fromEvents.extractedFrom,
1397
+ rawPayload: fromEvents.rawPayload,
914
1398
  };
915
1399
  }
1400
+ eventMetricContribution(metricKey, event) {
1401
+ const normalizedMetric = normalizeMetricKey(metricKey);
1402
+ const normalizedType = normalizeEventTypeFilter(event.event_type);
1403
+ if (isRefundMetric(normalizedMetric)) {
1404
+ if (!normalizedType?.includes("refund")) {
1405
+ return null;
1406
+ }
1407
+ if (typeof event.price !== "number" || !Number.isFinite(event.price)) {
1408
+ return null;
1409
+ }
1410
+ return Math.abs(event.price);
1411
+ }
1412
+ if (isRevenueMetric(normalizedMetric)) {
1413
+ if (typeof event.price !== "number" || !Number.isFinite(event.price)) {
1414
+ return null;
1415
+ }
1416
+ return event.price;
1417
+ }
1418
+ const expectedType = metricEventTypeFilter(normalizedMetric);
1419
+ if (expectedType && normalizedType !== expectedType) {
1420
+ return null;
1421
+ }
1422
+ return 1;
1423
+ }
1424
+ eventDimensionValue(event, dimension) {
1425
+ const normalizedDimension = normalizeMetricKey(dimension);
1426
+ if (normalizedDimension === "product" || normalizedDimension === "product_id") {
1427
+ return event.product_id;
1428
+ }
1429
+ if (normalizedDimension === "event" || normalizedDimension === "event_type" || normalizedDimension === "type") {
1430
+ return normalizeEventTypeFilter(event.event_type) ?? event.event_type;
1431
+ }
1432
+ if (normalizedDimension === "user" || normalizedDimension === "user_id") {
1433
+ return event.user_id;
1434
+ }
1435
+ if (normalizedDimension === "country" || normalizedDimension === "country_code") {
1436
+ return event.country;
1437
+ }
1438
+ if (normalizedDimension === "platform") {
1439
+ return event.platform;
1440
+ }
1441
+ const raw = event.raw;
1442
+ const value = raw[dimension];
1443
+ if (typeof value === "string" && value.trim().length > 0) {
1444
+ return value.trim();
1445
+ }
1446
+ if (typeof value === "number" && Number.isFinite(value)) {
1447
+ return String(value);
1448
+ }
1449
+ return undefined;
1450
+ }
916
1451
  assertRawAccess(auth, includeRaw) {
917
1452
  if (!includeRaw) {
918
1453
  return;