apphud-mcp 0.2.1 → 0.2.4
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 +69 -80
- package/dist/src/cli.js +27 -0
- package/dist/src/config/env.js +22 -22
- package/dist/src/services/analyticsService.js +81 -0
- package/dist/src/services/apphudClient.js +145 -0
- package/dist/src/services/etlService.js +351 -436
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,104 +1,93 @@
|
|
|
1
1
|
# apphud-mcp <img src="./assets/apphud-mcp-logo.svg" alt="apphud + mcp" height="28" />
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
MCP server for Apphud analytics with storage-based ETL ingestion (GCS/S3) and local SQLite query cache.
|
|
4
4
|
|
|
5
5
|
## Quick Start
|
|
6
6
|
|
|
7
|
-
1.
|
|
8
|
-
|
|
9
|
-
```json
|
|
10
|
-
{
|
|
11
|
-
"storage": {
|
|
12
|
-
"backend": "sqlite",
|
|
13
|
-
"sqlite_path": ".apphud-mcp/apphud.db"
|
|
14
|
-
},
|
|
15
|
-
"etl": {
|
|
16
|
-
"enabled": true,
|
|
17
|
-
"tenant_id": "default",
|
|
18
|
-
"poll_interval_minutes": 60,
|
|
19
|
-
"storage_dir": ".apphud-etl",
|
|
20
|
-
"incoming_dir": ".apphud-etl/incoming",
|
|
21
|
-
"alerts_stale_hours": 30,
|
|
22
|
-
"remote_fetch_enabled": false
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
2. Cursor MCP config:
|
|
7
|
+
1. Add MCP server:
|
|
28
8
|
|
|
29
9
|
```json
|
|
30
10
|
{
|
|
31
11
|
"mcpServers": {
|
|
32
12
|
"apphud-mcp": {
|
|
33
|
-
"command": "
|
|
34
|
-
"args": [
|
|
35
|
-
"/Users/you/apphud-mcp/dist/src/cli.js",
|
|
36
|
-
"start",
|
|
37
|
-
"--config",
|
|
38
|
-
"/Users/you/apphud-mcp/apphud-mcp.config.json"
|
|
39
|
-
],
|
|
13
|
+
"command": "npx",
|
|
14
|
+
"args": ["-y", "apphud-mcp@0.2.2", "start"],
|
|
40
15
|
"env": {
|
|
41
16
|
"login": "your@apphud.email",
|
|
42
|
-
"password": "your_apphud_password"
|
|
17
|
+
"password": "your_apphud_password",
|
|
18
|
+
"ETL_SOURCE": "gcs",
|
|
19
|
+
"ETL_GCS_BUCKET": "your-bucket",
|
|
20
|
+
"ETL_GCS_PREFIX": "apphud/exports"
|
|
43
21
|
}
|
|
44
22
|
}
|
|
45
23
|
}
|
|
46
24
|
}
|
|
47
25
|
```
|
|
48
26
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
27
|
+
2. Restart MCP server.
|
|
28
|
+
3. Check ingestion:
|
|
29
|
+
- `apphud_etl_local_status`
|
|
30
|
+
- `apphud_apps_list_local`
|
|
31
|
+
- `apphud_dashboard_local`
|
|
32
|
+
|
|
33
|
+
## ETL Setup
|
|
34
|
+
|
|
35
|
+
<details>
|
|
36
|
+
<summary>Google Cloud Storage (GCS) setup</summary>
|
|
37
|
+
|
|
38
|
+
1. In Apphud:
|
|
39
|
+
- Open `Integrations` -> `GCS ETL` -> `Add connection`.
|
|
40
|
+
- Enter project/service account/bucket and enable export for apps.
|
|
41
|
+
2. In Google Cloud Console:
|
|
42
|
+
- Create/select a bucket in [Cloud Storage](https://console.cloud.google.com/storage/browser).
|
|
43
|
+
- Create a Service Account in [IAM](https://console.cloud.google.com/iam-admin/serviceaccounts).
|
|
44
|
+
- Create JSON key for that service account.
|
|
45
|
+
- Grant access to the bucket (read for MCP side, write for Apphud side as needed by your policy).
|
|
46
|
+
3. MCP env vars:
|
|
47
|
+
- `ETL_SOURCE=gcs`
|
|
48
|
+
- `ETL_GCS_BUCKET=<bucket-name>`
|
|
49
|
+
- `ETL_GCS_PREFIX=<optional-prefix>`
|
|
50
|
+
- `GOOGLE_APPLICATION_CREDENTIALS=<path-to-service-account-json>`
|
|
51
|
+
4. Ensure `gsutil` is installed and authenticated in runtime environment.
|
|
52
|
+
|
|
53
|
+
</details>
|
|
54
|
+
|
|
55
|
+
<details>
|
|
56
|
+
<summary>Amazon S3 setup</summary>
|
|
57
|
+
|
|
58
|
+
1. In Apphud:
|
|
59
|
+
- Open `Integrations` -> `S3 ETL` -> `Add connection`.
|
|
60
|
+
- Fill bucket/credentials/region and enable export for apps.
|
|
61
|
+
2. In AWS Console:
|
|
62
|
+
- Create/select bucket in [S3](https://s3.console.aws.amazon.com/s3/home).
|
|
63
|
+
- Create IAM user/role with bucket access.
|
|
64
|
+
- Generate access key (if using user credentials).
|
|
65
|
+
3. MCP env vars:
|
|
66
|
+
- `ETL_SOURCE=s3`
|
|
67
|
+
- `ETL_S3_BUCKET=<bucket-name>`
|
|
68
|
+
- `ETL_S3_PREFIX=<optional-prefix>`
|
|
69
|
+
- `AWS_ACCESS_KEY_ID=<key-id>`
|
|
70
|
+
- `AWS_SECRET_ACCESS_KEY=<secret>`
|
|
71
|
+
- `AWS_REGION=<region>`
|
|
72
|
+
4. Ensure AWS CLI (`aws`) is installed and usable in runtime environment.
|
|
73
|
+
|
|
74
|
+
</details>
|
|
75
|
+
|
|
76
|
+
## Defaults
|
|
77
|
+
|
|
78
|
+
- SQLite DB: `.apphud-mcp/apphud.db`
|
|
79
|
+
- Incoming ETL files: `.apphud-etl/incoming`
|
|
80
|
+
- Poll interval: 60 minutes
|
|
81
|
+
- ETL enabled: `true`
|
|
82
|
+
- ETL source default: `none` (set `ETL_SOURCE=gcs` or `ETL_SOURCE=s3`)
|
|
83
|
+
|
|
84
|
+
## Useful Tools
|
|
52
85
|
|
|
53
|
-
- Default mode is analytics-only.
|
|
54
|
-
- No Postgres required (SQLite is default).
|
|
55
|
-
- No webhook/event-store setup required.
|
|
56
|
-
- Apps are fetched directly from Apphud Dashboard API.
|
|
57
|
-
- ETL worker can run hourly and auto-sync app API keys from Dashboard (`settings/general` fallback).
|
|
58
|
-
|
|
59
|
-
## Basic Checks
|
|
60
|
-
|
|
61
|
-
Ask MCP to call:
|
|
62
|
-
|
|
63
|
-
1. `apphud_apps_list`
|
|
64
|
-
2. `apphud_analytics_capabilities_get` (with `app_id` from step 1)
|
|
65
|
-
3. `apphud_analytics_revenue_summary` (for a date range)
|
|
66
|
-
|
|
67
|
-
## Available Tools
|
|
68
|
-
|
|
69
|
-
Local-first tools (higher priority):
|
|
70
86
|
- `apphud_etl_local_status`
|
|
71
|
-
- `apphud_apps_list_local`
|
|
72
87
|
- `apphud_dashboard_local`
|
|
73
|
-
- `
|
|
74
|
-
- `
|
|
75
|
-
- `
|
|
76
|
-
- `apphud_analytics_metrics_list_local`
|
|
77
|
-
- `apphud_analytics_metric_value_local`
|
|
78
|
-
- `apphud_analytics_metric_timeseries_local`
|
|
79
|
-
- `apphud_analytics_metric_breakdown_local`
|
|
80
|
-
- `apphud_analytics_revenue_summary_local`
|
|
81
|
-
- `apphud_analytics_subscriptions_summary_local`
|
|
82
|
-
- `apphud_analytics_conversion_trial_to_paid_local`
|
|
83
|
-
- `apphud_analytics_cohorts_retention_local`
|
|
84
|
-
- `apphud_analytics_cohorts_ltv_local`
|
|
85
|
-
- `apphud_analytics_query_raw_local`
|
|
86
|
-
|
|
87
|
-
Remote tools (still available):
|
|
88
|
-
- `apphud_apps_list`
|
|
89
|
-
- `apphud_analytics_events_list`
|
|
90
|
-
- `apphud_analytics_active_subscriptions`
|
|
91
|
-
- `apphud_analytics_capabilities_get`
|
|
92
|
-
- `apphud_analytics_metrics_list`
|
|
93
|
-
- `apphud_analytics_metric_value`
|
|
94
|
-
- `apphud_analytics_metric_timeseries`
|
|
95
|
-
- `apphud_analytics_metric_breakdown`
|
|
96
|
-
- `apphud_analytics_revenue_summary`
|
|
97
|
-
- `apphud_analytics_subscriptions_summary`
|
|
98
|
-
- `apphud_analytics_conversion_trial_to_paid`
|
|
99
|
-
- `apphud_analytics_cohorts_retention`
|
|
100
|
-
- `apphud_analytics_cohorts_ltv`
|
|
101
|
-
- `apphud_analytics_query_raw`
|
|
88
|
+
- `apphud_apps_list_local`
|
|
89
|
+
- `apphud_analytics_*_local`
|
|
90
|
+
- `apphud_analytics_*` (remote analytics endpoints)
|
|
102
91
|
|
|
103
92
|
## Development
|
|
104
93
|
|
package/dist/src/cli.js
CHANGED
|
@@ -33,6 +33,33 @@ function printHelp() {
|
|
|
33
33
|
Usage:
|
|
34
34
|
apphud-mcp start [--config <path>] [--config-json '<json>']
|
|
35
35
|
apphud-mcp init-config [--out <path>] [--force]
|
|
36
|
+
|
|
37
|
+
Zero-config quick start:
|
|
38
|
+
1) Add MCP server using:
|
|
39
|
+
npx -y apphud-mcp@0.2.2 start
|
|
40
|
+
2) Set env vars:
|
|
41
|
+
login=<your apphud email>
|
|
42
|
+
password=<your apphud password>
|
|
43
|
+
3) Use local-first tools:
|
|
44
|
+
apphud_etl_local_status
|
|
45
|
+
apphud_dashboard_local
|
|
46
|
+
apphud_apps_list_local
|
|
47
|
+
|
|
48
|
+
Defaults:
|
|
49
|
+
- SQLite DB: .apphud-mcp/apphud.db
|
|
50
|
+
- ETL storage: .apphud-etl
|
|
51
|
+
- Poll interval: 60 minutes
|
|
52
|
+
- ETL worker enabled; source is none until you set GCS/S3
|
|
53
|
+
|
|
54
|
+
Storage source setup:
|
|
55
|
+
GCS:
|
|
56
|
+
ETL_SOURCE=gcs
|
|
57
|
+
ETL_GCS_BUCKET=<bucket>
|
|
58
|
+
ETL_GCS_PREFIX=<prefix optional>
|
|
59
|
+
S3:
|
|
60
|
+
ETL_SOURCE=s3
|
|
61
|
+
ETL_S3_BUCKET=<bucket>
|
|
62
|
+
ETL_S3_PREFIX=<prefix optional>
|
|
36
63
|
`);
|
|
37
64
|
}
|
|
38
65
|
async function ensureWritable(filePath, force = false) {
|
package/dist/src/config/env.js
CHANGED
|
@@ -190,18 +190,16 @@ export function loadEnvConfig(options = {}) {
|
|
|
190
190
|
: `${etlStorageDir}/incoming`;
|
|
191
191
|
const etlTenantIdRaw = simpleConfig.etl?.tenant_id ?? process.env.ETL_TENANT_ID;
|
|
192
192
|
const etlTenantId = typeof etlTenantIdRaw === "string" && etlTenantIdRaw.trim().length > 0 ? etlTenantIdRaw.trim() : "default";
|
|
193
|
-
const
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const
|
|
202
|
-
const
|
|
203
|
-
? apphudEtlExportsDownloadPathRaw.trim()
|
|
204
|
-
: "/apps/{app_id}/etl-exports/{export_id}";
|
|
193
|
+
const etlSourceRaw = simpleConfig.etl?.source ?? process.env.ETL_SOURCE;
|
|
194
|
+
const etlSource = etlSourceRaw === "gcs" || etlSourceRaw === "s3" || etlSourceRaw === "none" ? etlSourceRaw : "none";
|
|
195
|
+
const etlGcsBucketRaw = simpleConfig.etl?.gcs_bucket ?? process.env.ETL_GCS_BUCKET;
|
|
196
|
+
const etlGcsBucket = typeof etlGcsBucketRaw === "string" && etlGcsBucketRaw.trim().length > 0 ? etlGcsBucketRaw.trim() : undefined;
|
|
197
|
+
const etlGcsPrefixRaw = simpleConfig.etl?.gcs_prefix ?? process.env.ETL_GCS_PREFIX;
|
|
198
|
+
const etlGcsPrefix = typeof etlGcsPrefixRaw === "string" && etlGcsPrefixRaw.trim().length > 0 ? etlGcsPrefixRaw.trim() : undefined;
|
|
199
|
+
const etlS3BucketRaw = simpleConfig.etl?.s3_bucket ?? process.env.ETL_S3_BUCKET;
|
|
200
|
+
const etlS3Bucket = typeof etlS3BucketRaw === "string" && etlS3BucketRaw.trim().length > 0 ? etlS3BucketRaw.trim() : undefined;
|
|
201
|
+
const etlS3PrefixRaw = simpleConfig.etl?.s3_prefix ?? process.env.ETL_S3_PREFIX;
|
|
202
|
+
const etlS3Prefix = typeof etlS3PrefixRaw === "string" && etlS3PrefixRaw.trim().length > 0 ? etlS3PrefixRaw.trim() : undefined;
|
|
205
203
|
return {
|
|
206
204
|
nodeEnv: simpleConfig.node_env ?? process.env.NODE_ENV ?? "development",
|
|
207
205
|
port: parseNumber(simpleConfig.transport?.port ?? process.env.PORT, 8080),
|
|
@@ -234,17 +232,18 @@ export function loadEnvConfig(options = {}) {
|
|
|
234
232
|
"/sessions",
|
|
235
233
|
apphudAnalyticsLoginEmailSecretRef: analyticsLoginEmailSecretRef,
|
|
236
234
|
apphudAnalyticsLoginPasswordSecretRef: analyticsLoginPasswordSecretRef,
|
|
237
|
-
apphudEtlExportsApiBaseUrl,
|
|
238
|
-
apphudEtlExportsListPath,
|
|
239
|
-
apphudEtlExportsDownloadPath,
|
|
240
235
|
apphudWebhookTokenHeader: simpleConfig.security?.webhook_token_header ?? process.env.APPHUD_WEBHOOK_TOKEN_HEADER ?? "x-apphud-token",
|
|
241
|
-
etlEnabled: parseBoolean(simpleConfig.etl?.enabled ?? process.env.ETL_ENABLED,
|
|
236
|
+
etlEnabled: parseBoolean(simpleConfig.etl?.enabled ?? process.env.ETL_ENABLED, true),
|
|
242
237
|
etlTenantId,
|
|
243
238
|
etlPollIntervalMinutes: parseNumber(simpleConfig.etl?.poll_interval_minutes ?? process.env.ETL_POLL_INTERVAL_MINUTES, 60),
|
|
244
239
|
etlStorageDir,
|
|
245
240
|
etlIncomingDir,
|
|
246
241
|
etlAlertsStaleHours: parseNumber(simpleConfig.etl?.alerts_stale_hours ?? process.env.ETL_ALERTS_STALE_HOURS, 30),
|
|
247
|
-
|
|
242
|
+
etlSource,
|
|
243
|
+
etlGcsBucket,
|
|
244
|
+
etlGcsPrefix,
|
|
245
|
+
etlS3Bucket,
|
|
246
|
+
etlS3Prefix,
|
|
248
247
|
httpEnabled: parseBoolean(simpleConfig.transport?.http_enabled ?? process.env.HTTP_ENABLED, false),
|
|
249
248
|
mcpStdioEnabled: parseBoolean(simpleConfig.transport?.mcp_stdio_enabled ?? process.env.MCP_STDIO_ENABLED, true),
|
|
250
249
|
};
|
|
@@ -256,13 +255,17 @@ export function buildExampleSimpleConfig() {
|
|
|
256
255
|
sqlite_path: ".apphud-mcp/apphud.db",
|
|
257
256
|
},
|
|
258
257
|
etl: {
|
|
259
|
-
enabled:
|
|
258
|
+
enabled: true,
|
|
260
259
|
tenant_id: "default",
|
|
261
260
|
poll_interval_minutes: 60,
|
|
262
261
|
storage_dir: ".apphud-etl",
|
|
263
262
|
incoming_dir: ".apphud-etl/incoming",
|
|
264
263
|
alerts_stale_hours: 30,
|
|
265
|
-
|
|
264
|
+
source: "none",
|
|
265
|
+
gcs_bucket: "your-gcs-bucket",
|
|
266
|
+
gcs_prefix: "apphud/exports",
|
|
267
|
+
s3_bucket: "your-s3-bucket",
|
|
268
|
+
s3_prefix: "apphud/exports",
|
|
266
269
|
},
|
|
267
270
|
defaults: {
|
|
268
271
|
tenant_id: "tenant_default",
|
|
@@ -289,9 +292,6 @@ export function buildExampleSimpleConfig() {
|
|
|
289
292
|
analytics_login_path: "/sessions",
|
|
290
293
|
analytics_login_email_secret_ref: "login",
|
|
291
294
|
analytics_login_password_secret_ref: "password",
|
|
292
|
-
etl_exports_api_base_url: "https://api.apphud.com/v1",
|
|
293
|
-
etl_exports_list_path: "/apps/{app_id}/etl-exports",
|
|
294
|
-
etl_exports_download_path: "/apps/{app_id}/etl-exports/{export_id}",
|
|
295
295
|
},
|
|
296
296
|
};
|
|
297
297
|
}
|
|
@@ -249,6 +249,25 @@ export class AnalyticsService {
|
|
|
249
249
|
includeRaw: input.include_raw,
|
|
250
250
|
auth,
|
|
251
251
|
});
|
|
252
|
+
if (resolved.code === "METRIC_AMBIGUOUS") {
|
|
253
|
+
throw new ApphudMcpError("METRIC_AMBIGUOUS", `Metric key '${input.metric_key}' matched multiple dashboard metrics`, {
|
|
254
|
+
statusCode: 400,
|
|
255
|
+
details: {
|
|
256
|
+
metric_key: input.metric_key,
|
|
257
|
+
candidates: resolved.candidates ?? [],
|
|
258
|
+
available_metrics: resolved.available_metrics ?? [],
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
if (resolved.value === null) {
|
|
263
|
+
throw new ApphudMcpError("METRIC_NOT_FOUND", `Metric key '${input.metric_key}' not found in analytics payload`, {
|
|
264
|
+
statusCode: 404,
|
|
265
|
+
details: {
|
|
266
|
+
metric_key: input.metric_key,
|
|
267
|
+
available_metrics: resolved.available_metrics ?? [],
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
}
|
|
252
271
|
return {
|
|
253
272
|
app_id: app.appId,
|
|
254
273
|
apphud_app_id: input.apphud_app_id ?? app.appId,
|
|
@@ -802,6 +821,16 @@ export class AnalyticsService {
|
|
|
802
821
|
}
|
|
803
822
|
async resolveMetricValue(app, options) {
|
|
804
823
|
const warnings = [];
|
|
824
|
+
const availableMetrics = new Set();
|
|
825
|
+
let ambiguousCandidates;
|
|
826
|
+
const collectExtractionMeta = (extracted) => {
|
|
827
|
+
for (const metricName of extracted.available_metrics ?? []) {
|
|
828
|
+
availableMetrics.add(metricName);
|
|
829
|
+
}
|
|
830
|
+
if (extracted.code === "METRIC_AMBIGUOUS" && extracted.candidates && extracted.candidates.length > 0) {
|
|
831
|
+
ambiguousCandidates = extracted.candidates;
|
|
832
|
+
}
|
|
833
|
+
};
|
|
805
834
|
if (options.from && options.to) {
|
|
806
835
|
const dashRange = await this.apphudClient.fetchDashRange(app, {
|
|
807
836
|
apphudAppId: options.apphudAppId,
|
|
@@ -812,6 +841,18 @@ export class AnalyticsService {
|
|
|
812
841
|
filters: options.filters,
|
|
813
842
|
});
|
|
814
843
|
const extractedRange = extractMetricValue(dashRange.payload, options.metricKey);
|
|
844
|
+
collectExtractionMeta(extractedRange);
|
|
845
|
+
if (extractedRange.code === "METRIC_AMBIGUOUS") {
|
|
846
|
+
return {
|
|
847
|
+
value: null,
|
|
848
|
+
source_used: "/dash/range",
|
|
849
|
+
raw_payload: this.selectRawPayload(options.auth, options.includeRaw, dashRange.payload),
|
|
850
|
+
warnings: [...warnings, "Metric key matched multiple dashboard rows"],
|
|
851
|
+
code: "METRIC_AMBIGUOUS",
|
|
852
|
+
available_metrics: Array.from(availableMetrics.values()).sort((left, right) => left.localeCompare(right)),
|
|
853
|
+
candidates: ambiguousCandidates ?? [],
|
|
854
|
+
};
|
|
855
|
+
}
|
|
815
856
|
if (extractedRange.value !== null) {
|
|
816
857
|
return {
|
|
817
858
|
value: extractedRange.value,
|
|
@@ -843,6 +884,18 @@ export class AnalyticsService {
|
|
|
843
884
|
filters: options.filters,
|
|
844
885
|
});
|
|
845
886
|
const extractedChart = extractMetricValue(chart.payload, options.metricKey);
|
|
887
|
+
collectExtractionMeta(extractedChart);
|
|
888
|
+
if (extractedChart.code === "METRIC_AMBIGUOUS") {
|
|
889
|
+
return {
|
|
890
|
+
value: null,
|
|
891
|
+
source_used: chart.sourcePath,
|
|
892
|
+
raw_payload: this.selectRawPayload(options.auth, options.includeRaw, chart.payload),
|
|
893
|
+
warnings: [...warnings, "Metric key matched multiple dashboard rows"],
|
|
894
|
+
code: "METRIC_AMBIGUOUS",
|
|
895
|
+
available_metrics: Array.from(availableMetrics.values()).sort((left, right) => left.localeCompare(right)),
|
|
896
|
+
candidates: ambiguousCandidates ?? [],
|
|
897
|
+
};
|
|
898
|
+
}
|
|
846
899
|
if (extractedChart.value !== null) {
|
|
847
900
|
return {
|
|
848
901
|
value: extractedChart.value,
|
|
@@ -868,6 +921,8 @@ export class AnalyticsService {
|
|
|
868
921
|
source_used: chart.sourcePath,
|
|
869
922
|
raw_payload: this.selectRawPayload(options.auth, options.includeRaw, chart.payload),
|
|
870
923
|
warnings: [...warnings, "Metric not found in analytics payloads"],
|
|
924
|
+
code: "METRIC_NOT_FOUND",
|
|
925
|
+
available_metrics: Array.from(availableMetrics.values()).sort((left, right) => left.localeCompare(right)),
|
|
871
926
|
};
|
|
872
927
|
}
|
|
873
928
|
const dashNow = await this.apphudClient.fetchDashNow(app, {
|
|
@@ -876,6 +931,18 @@ export class AnalyticsService {
|
|
|
876
931
|
filters: options.filters,
|
|
877
932
|
});
|
|
878
933
|
const extractedNow = extractMetricValue(dashNow.payload, options.metricKey);
|
|
934
|
+
collectExtractionMeta(extractedNow);
|
|
935
|
+
if (extractedNow.code === "METRIC_AMBIGUOUS") {
|
|
936
|
+
return {
|
|
937
|
+
value: null,
|
|
938
|
+
source_used: "/dash/now",
|
|
939
|
+
raw_payload: this.selectRawPayload(options.auth, options.includeRaw, dashNow.payload),
|
|
940
|
+
warnings: [...warnings, "Metric key matched multiple dashboard rows"],
|
|
941
|
+
code: "METRIC_AMBIGUOUS",
|
|
942
|
+
available_metrics: Array.from(availableMetrics.values()).sort((left, right) => left.localeCompare(right)),
|
|
943
|
+
candidates: ambiguousCandidates ?? [],
|
|
944
|
+
};
|
|
945
|
+
}
|
|
879
946
|
if (extractedNow.value !== null) {
|
|
880
947
|
return {
|
|
881
948
|
value: extractedNow.value,
|
|
@@ -894,6 +961,18 @@ export class AnalyticsService {
|
|
|
894
961
|
filters: options.filters,
|
|
895
962
|
});
|
|
896
963
|
const extractedChartNow = extractMetricValue(chartNow.payload, options.metricKey);
|
|
964
|
+
collectExtractionMeta(extractedChartNow);
|
|
965
|
+
if (extractedChartNow.code === "METRIC_AMBIGUOUS") {
|
|
966
|
+
return {
|
|
967
|
+
value: null,
|
|
968
|
+
source_used: chartNow.sourcePath,
|
|
969
|
+
raw_payload: this.selectRawPayload(options.auth, options.includeRaw, chartNow.payload),
|
|
970
|
+
warnings: [...warnings, "Metric key matched multiple dashboard rows"],
|
|
971
|
+
code: "METRIC_AMBIGUOUS",
|
|
972
|
+
available_metrics: Array.from(availableMetrics.values()).sort((left, right) => left.localeCompare(right)),
|
|
973
|
+
candidates: ambiguousCandidates ?? [],
|
|
974
|
+
};
|
|
975
|
+
}
|
|
897
976
|
if (extractedChartNow.value !== null) {
|
|
898
977
|
return {
|
|
899
978
|
value: extractedChartNow.value,
|
|
@@ -911,6 +990,8 @@ export class AnalyticsService {
|
|
|
911
990
|
extracted_from: series.path,
|
|
912
991
|
raw_payload: this.selectRawPayload(options.auth, options.includeRaw, chartNow.payload),
|
|
913
992
|
warnings: [...warnings, "Metric value estimated from last chart point"],
|
|
993
|
+
code: lastPoint ? undefined : "METRIC_NOT_FOUND",
|
|
994
|
+
available_metrics: Array.from(availableMetrics.values()).sort((left, right) => left.localeCompare(right)),
|
|
914
995
|
};
|
|
915
996
|
}
|
|
916
997
|
assertRawAccess(auth, includeRaw) {
|
|
@@ -61,6 +61,13 @@ const METRIC_KEY_ALIASES = {
|
|
|
61
61
|
subscribers_retention: ["subscribers_retention", "retention", "subscription_retention"],
|
|
62
62
|
cumulative_ltv: ["cumulative_ltv", "ltv", "lifetime_value"],
|
|
63
63
|
};
|
|
64
|
+
const METRIC_KEY_TO_LABELS = {
|
|
65
|
+
active_subs: ["Active Paid Subscriptions"],
|
|
66
|
+
active_trial: ["Active Trials"],
|
|
67
|
+
active_trials: ["Active Trials"],
|
|
68
|
+
active_intro: ["Active Intro Offers"],
|
|
69
|
+
active_promo: ["Active Promo Offers"],
|
|
70
|
+
};
|
|
64
71
|
function asRecord(value) {
|
|
65
72
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
66
73
|
return {};
|
|
@@ -355,6 +362,17 @@ function expandMetricAliases(metricKey) {
|
|
|
355
362
|
}
|
|
356
363
|
return aliases;
|
|
357
364
|
}
|
|
365
|
+
function resolveMetricLabels(metricKey, aliases) {
|
|
366
|
+
const labels = new Set();
|
|
367
|
+
const keysToCheck = new Set([normalizeMetricKey(metricKey), ...aliases]);
|
|
368
|
+
for (const key of keysToCheck) {
|
|
369
|
+
const mappedLabels = METRIC_KEY_TO_LABELS[key] ?? [];
|
|
370
|
+
for (const label of mappedLabels) {
|
|
371
|
+
labels.add(normalizeMetricKey(label));
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return labels;
|
|
375
|
+
}
|
|
358
376
|
function parseNumericValue(value) {
|
|
359
377
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
360
378
|
return value;
|
|
@@ -514,8 +532,135 @@ function metricNamedKey(record) {
|
|
|
514
532
|
function isLikelyMetricIdentifier(value) {
|
|
515
533
|
return /[a-z]/.test(value) && value.length >= 3 && value.length <= 80;
|
|
516
534
|
}
|
|
535
|
+
function extractMetricValueFromDashboardGroups(payload, metricKey, aliases) {
|
|
536
|
+
const expectedLabels = resolveMetricLabels(metricKey, aliases);
|
|
537
|
+
const matched = [];
|
|
538
|
+
const available = new Set();
|
|
539
|
+
const visited = new WeakSet();
|
|
540
|
+
let hasGroups = false;
|
|
541
|
+
const walk = (node, path) => {
|
|
542
|
+
if (Array.isArray(node)) {
|
|
543
|
+
for (let index = 0; index < node.length; index += 1) {
|
|
544
|
+
walk(node[index], `${path}[${index}]`);
|
|
545
|
+
}
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (!isRecord(node)) {
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
if (visited.has(node)) {
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
visited.add(node);
|
|
555
|
+
const groups = asArray(node.groups);
|
|
556
|
+
if (groups.length > 0) {
|
|
557
|
+
hasGroups = true;
|
|
558
|
+
for (let groupIndex = 0; groupIndex < groups.length; groupIndex += 1) {
|
|
559
|
+
const group = groups[groupIndex];
|
|
560
|
+
if (!group) {
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
const items = asArray(group.items);
|
|
564
|
+
for (let itemIndex = 0; itemIndex < items.length; itemIndex += 1) {
|
|
565
|
+
const item = items[itemIndex];
|
|
566
|
+
if (!item) {
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
const itemPath = `${path}.groups[${groupIndex}].items[${itemIndex}]`;
|
|
570
|
+
const names = [item.short_name, item.name, item.raw_key, item.metric, item.key, item.chart_id, item.chartId]
|
|
571
|
+
.map((value) => readStringValue(value))
|
|
572
|
+
.filter((value) => Boolean(value));
|
|
573
|
+
const normalizedNames = new Set(names.map((value) => normalizeMetricKey(value)));
|
|
574
|
+
const title = readStringValue(item.name) ?? readStringValue(item.short_name) ?? readStringValue(item.raw_key);
|
|
575
|
+
if (title) {
|
|
576
|
+
available.add(title);
|
|
577
|
+
}
|
|
578
|
+
const matchesByAlias = Array.from(normalizedNames).some((name) => aliases.has(name));
|
|
579
|
+
const matchesByLabel = Array.from(normalizedNames).some((name) => expectedLabels.has(name));
|
|
580
|
+
if (!matchesByAlias && !matchesByLabel) {
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
const values = asArray(item.values);
|
|
584
|
+
for (let valueIndex = 0; valueIndex < values.length; valueIndex += 1) {
|
|
585
|
+
const valueEntry = values[valueIndex];
|
|
586
|
+
if (!valueEntry) {
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
if (normalizeMetricKey(String(valueEntry.name ?? "")) !== "value") {
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
const parsedValue = parseNumericValue(valueEntry.value);
|
|
593
|
+
if (parsedValue === null) {
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
matched.push({
|
|
597
|
+
name: title ?? "unknown",
|
|
598
|
+
path: `${itemPath}.values[${valueIndex}]`,
|
|
599
|
+
value: parsedValue,
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
for (const [key, value] of Object.entries(node)) {
|
|
606
|
+
walk(value, `${path}.${key}`);
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
walk(payload, "$");
|
|
610
|
+
if (!hasGroups) {
|
|
611
|
+
return { kind: "not_applicable" };
|
|
612
|
+
}
|
|
613
|
+
const availableMetrics = Array.from(available.values()).sort((left, right) => left.localeCompare(right));
|
|
614
|
+
if (matched.length === 1) {
|
|
615
|
+
const singleMatch = matched[0];
|
|
616
|
+
if (!singleMatch) {
|
|
617
|
+
return {
|
|
618
|
+
kind: "not_found",
|
|
619
|
+
availableMetrics,
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
return {
|
|
623
|
+
kind: "resolved",
|
|
624
|
+
value: singleMatch.value,
|
|
625
|
+
path: singleMatch.path,
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
if (matched.length > 1) {
|
|
629
|
+
return {
|
|
630
|
+
kind: "ambiguous",
|
|
631
|
+
candidates: matched.map((item) => ({ name: item.name, path: item.path })),
|
|
632
|
+
availableMetrics,
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
return {
|
|
636
|
+
kind: "not_found",
|
|
637
|
+
availableMetrics,
|
|
638
|
+
};
|
|
639
|
+
}
|
|
517
640
|
export function extractMetricValue(payload, metricKey) {
|
|
518
641
|
const aliases = expandMetricAliases(metricKey);
|
|
642
|
+
const dashboardExtraction = extractMetricValueFromDashboardGroups(payload, metricKey, aliases);
|
|
643
|
+
if (dashboardExtraction.kind === "resolved") {
|
|
644
|
+
return {
|
|
645
|
+
value: dashboardExtraction.value,
|
|
646
|
+
path: dashboardExtraction.path,
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
if (dashboardExtraction.kind === "ambiguous") {
|
|
650
|
+
return {
|
|
651
|
+
value: null,
|
|
652
|
+
code: "METRIC_AMBIGUOUS",
|
|
653
|
+
candidates: dashboardExtraction.candidates,
|
|
654
|
+
available_metrics: dashboardExtraction.availableMetrics,
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
if (dashboardExtraction.kind === "not_found") {
|
|
658
|
+
return {
|
|
659
|
+
value: null,
|
|
660
|
+
code: "METRIC_NOT_FOUND",
|
|
661
|
+
available_metrics: dashboardExtraction.availableMetrics,
|
|
662
|
+
};
|
|
663
|
+
}
|
|
519
664
|
const search = (node, path, mode, visited) => {
|
|
520
665
|
if (Array.isArray(node)) {
|
|
521
666
|
for (let index = 0; index < node.length; index += 1) {
|