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
|
@@ -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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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, { ...
|
|
472
|
-
this.resolveMetricValue(app, { ...
|
|
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
|
|
739
|
+
let extractedFrom;
|
|
740
|
+
let sourceUsed = "/api/v1/chart/query/column";
|
|
741
|
+
let rawPayload;
|
|
584
742
|
const warnings = [];
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
.
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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
|
-
|
|
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:
|
|
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,
|
|
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
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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:
|
|
663
|
-
extracted_from:
|
|
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,
|
|
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
|
-
|
|
837
|
-
|
|
838
|
-
|
|
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
|
-
|
|
846
|
-
|
|
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:
|
|
860
|
-
source_used:
|
|
861
|
-
extracted_from:
|
|
862
|
-
raw_payload: this.selectRawPayload(options.auth, options.includeRaw,
|
|
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:
|
|
869
|
-
raw_payload: this.selectRawPayload(options.auth, options.includeRaw,
|
|
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
|
-
|
|
890
|
-
|
|
891
|
-
|
|
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
|
-
|
|
897
|
-
if (extractedChartNow.value !== null) {
|
|
1384
|
+
if (fromEvents.points.length === 0) {
|
|
898
1385
|
return {
|
|
899
|
-
value:
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
warnings,
|
|
1386
|
+
value: null,
|
|
1387
|
+
sourceUsed: fromEvents.sourceUsed,
|
|
1388
|
+
extractedFrom: fromEvents.extractedFrom,
|
|
1389
|
+
rawPayload: fromEvents.rawPayload,
|
|
904
1390
|
};
|
|
905
1391
|
}
|
|
906
|
-
const
|
|
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:
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
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;
|