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 CHANGED
@@ -1,104 +1,93 @@
1
1
  # apphud-mcp <img src="./assets/apphud-mcp-logo.svg" alt="apphud + mcp" height="28" />
2
2
 
3
- Zero-config MCP server for Apphud Dashboard Analytics with local SQLite storage.
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. Optional config file (`apphud-mcp.config.json`):
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": "node",
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
- 3. Restart MCP server in Cursor.
50
-
51
- ## What Changed
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
- - `apphud_analytics_events_list_local`
74
- - `apphud_analytics_active_subscriptions_local`
75
- - `apphud_analytics_capabilities_get_local`
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) {
@@ -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 etlExportsApiBaseUrlRaw = simpleConfig.apphud?.etl_exports_api_base_url ?? process.env.APPHUD_ETL_EXPORTS_API_BASE_URL;
194
- const apphudEtlExportsApiBaseUrl = typeof etlExportsApiBaseUrlRaw === "string" && etlExportsApiBaseUrlRaw.trim().length > 0
195
- ? etlExportsApiBaseUrlRaw.trim()
196
- : "https://api.apphud.com/v1";
197
- const apphudEtlExportsListPathRaw = simpleConfig.apphud?.etl_exports_list_path ?? process.env.APPHUD_ETL_EXPORTS_LIST_PATH;
198
- const apphudEtlExportsListPath = typeof apphudEtlExportsListPathRaw === "string" && apphudEtlExportsListPathRaw.trim().length > 0
199
- ? apphudEtlExportsListPathRaw.trim()
200
- : "/apps/{app_id}/etl-exports";
201
- const apphudEtlExportsDownloadPathRaw = simpleConfig.apphud?.etl_exports_download_path ?? process.env.APPHUD_ETL_EXPORTS_DOWNLOAD_PATH;
202
- const apphudEtlExportsDownloadPath = typeof apphudEtlExportsDownloadPathRaw === "string" && apphudEtlExportsDownloadPathRaw.trim().length > 0
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, false),
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
- etlRemoteFetchEnabled: parseBoolean(simpleConfig.etl?.remote_fetch_enabled ?? process.env.ETL_REMOTE_FETCH_ENABLED, false),
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: false,
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
- remote_fetch_enabled: false,
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) {