cubeapm-mcp 1.2.0 → 1.3.1

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.
Files changed (3) hide show
  1. package/README.md +85 -8
  2. package/dist/index.js +254 -20
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -207,7 +207,7 @@ Readable resources exposing CubeAPM data and configuration:
207
207
 
208
208
  ## CubeAPM Query Patterns
209
209
 
210
- ### Metrics Naming Conventions
210
+ ### Metrics (PromQL / MetricsQL)
211
211
 
212
212
  CubeAPM uses specific naming conventions that differ from standard OpenTelemetry:
213
213
 
@@ -217,7 +217,7 @@ CubeAPM uses specific naming conventions that differ from standard OpenTelemetry
217
217
  | Service label | `service` (NOT `server` or `service_name`) |
218
218
  | Common labels | `env`, `service`, `span_kind`, `status_code`, `http_code` |
219
219
 
220
- ### Histogram Queries (P50, P90, P95, P99)
220
+ #### Histogram Queries (P50, P90, P95, P99)
221
221
 
222
222
  CubeAPM uses **VictoriaMetrics-style histograms** with `vmrange` labels instead of Prometheus `le` buckets:
223
223
 
@@ -233,7 +233,9 @@ histogram_quantile(0.95, sum by (le) (rate(http_request_duration_bucket[5m])))
233
233
 
234
234
  > **Note:** Latency values are returned in **seconds** (0.05 = 50ms)
235
235
 
236
- ### Logs Label Discovery
236
+ ### Logs (LogsQL)
237
+
238
+ #### Stream Selectors
237
239
 
238
240
  Log labels vary by source. Use `*` query first to discover available labels:
239
241
 
@@ -249,17 +251,92 @@ Log labels vary by source. Use `*` query first to discover available labels:
249
251
  # Lambda function logs
250
252
  {faas.name="my-lambda-prod"}
251
253
 
252
- # Search with text filter
253
- {faas.name=~".*-prod"} AND "error"
254
+ # Regex match
255
+ {faas.name=~".*-prod"}
256
+
257
+ # Text filter with boolean operators
258
+ {faas.name=~".*"} AND "error" AND NOT "retry"
259
+ ```
260
+
261
+ #### Pipe Operators
262
+
263
+ Chain after any query with `|`:
264
+
265
+ | Pipe | Syntax | Description |
266
+ |------|--------|-------------|
267
+ | `copy` | `\| copy src AS dst` | Copy field value |
268
+ | `drop` | `\| drop field1, field2` | Remove fields from output |
269
+ | `extract_regexp` | `\| extract_regexp "(?P<name>re)"` | Extract via named capture groups |
270
+ | `join` | `\| join by (field) (...subquery...)` | Join with subquery results |
271
+ | `keep` | `\| keep field1, field2` | Keep only specified fields |
272
+ | `limit` | `\| limit N` | Return at most N results |
273
+ | `math` | `\| math result = f1 + f2` | Arithmetic (+, -, *, /, %) |
274
+ | `rename` | `\| rename src AS dst` | Rename a field |
275
+ | `replace` | `\| replace (field, "old", "new")` | Substring replacement |
276
+ | `replace_regexp` | `\| replace_regexp (field, "re", "repl")` | Regex replacement |
277
+ | `sort` | `\| sort by (field) [asc\|desc]` | Sort results |
278
+ | `stats` | `\| stats <func> as alias [by (fields)]` | Aggregate results |
279
+ | `unpack_json` | `\| unpack_json` | Extract fields from JSON body |
280
+
281
+ #### Stats Functions
282
+
283
+ Used with the `| stats` pipe:
284
+
285
+ | Function | Description |
286
+ |----------|-------------|
287
+ | `avg(field)` | Arithmetic mean |
288
+ | `count()` | Total matching entries |
289
+ | `count_empty(field)` | Entries where field is empty |
290
+ | `count_uniq(field)` | Distinct values |
291
+ | `max(field)` | Maximum value |
292
+ | `median(field)` | Median (50th percentile) |
293
+ | `min(field)` | Minimum value |
294
+ | `quantile(p, field)` | p-th quantile (e.g., `quantile(0.95, duration)`) |
295
+ | `sum(field)` | Sum of values |
296
+
297
+ #### Example Log Queries
298
+
299
+ ```logsql
300
+ # Count errors per Lambda function
301
+ {faas.name=~".*"} AND "error" | stats count() as errors by (faas.name)
302
+
303
+ # Top 10 slowest requests
304
+ {service_name="my-service"} | sort by (duration) desc | limit 10
305
+
306
+ # Extract and aggregate from JSON logs
307
+ {service_name="api"} | unpack_json | stats avg(response_time) as avg_rt by (endpoint)
308
+ ```
309
+
310
+ ### Traces
311
+
312
+ Trace queries use the same pipe syntax as logs: `{stream_selector} | pipe1 | pipe2`
313
+
314
+ **Important notes:**
315
+ - `query`, `env`, `service` are REQUIRED parameters
316
+ - Duration is in **milliseconds** (not seconds like metrics)
317
+ - `p95` is NOT a valid stats function — use `quantile(0.95, duration)`
318
+ - Service names are case-sensitive (e.g., `"Kratos-Prod"` not `"kratos"`)
319
+
320
+ #### Example Trace Queries
321
+
322
+ ```
323
+ # P95 latency for a service
324
+ {service="Kratos-Prod", span_kind="server"} | stats quantile(0.95, duration) as p95_ms
325
+
326
+ # Error count by endpoint
327
+ {service="Kratos-Prod", status_code="ERROR"} | stats count() as errors by (http_route)
328
+
329
+ # Slowest spans
330
+ {service="Kratos-Prod"} | sort by (duration) desc | limit 20
254
331
  ```
255
332
 
256
- ## Example Queries
333
+ ## Example Natural Language Queries
257
334
 
258
335
  ### Logs
259
336
  ```
260
337
  "Show me logs from webhook-lambda-prod"
261
338
  "Find all logs containing 'timeout' in the last hour"
262
- "Query logs from Lambda functions in production"
339
+ "Count errors per Lambda function in the last 24h"
263
340
  ```
264
341
 
265
342
  ### Metrics
@@ -271,7 +348,7 @@ Log labels vary by source. Use `*` query first to discover available labels:
271
348
 
272
349
  ### Traces
273
350
  ```
274
- "Find slow traces (>2s) from the order-service"
351
+ "Find the P95 latency for Kratos-Prod using trace stats"
275
352
  "Show me traces with errors in the production environment"
276
353
  "Get the full waterfall for trace ID abc123"
277
354
  ```
package/dist/index.js CHANGED
@@ -25,6 +25,90 @@ const server = new McpServer({
25
25
  // ============================================
26
26
  // LOGS APIs
27
27
  // ============================================
28
+ // Convert RFC3339 string or Unix timestamp (s/ms/μs/ns) to nanoseconds.
29
+ // CubeAPM's prod UI embeds time in queries as `_time:[ns_start, ns_end)`,
30
+ // so we mirror that wire format rather than relying on start/end form params.
31
+ function toNanoseconds(t) {
32
+ if (/^\d+$/.test(t)) {
33
+ const n = BigInt(t);
34
+ if (n < 10000000000n)
35
+ return (n * 1000000000n).toString(); // seconds
36
+ if (n < 10000000000000n)
37
+ return (n * 1000000n).toString(); // milliseconds
38
+ if (n < 10000000000000000n)
39
+ return (n * 1000n).toString(); // microseconds
40
+ return n.toString(); // nanoseconds
41
+ }
42
+ const ms = new Date(t).getTime();
43
+ if (Number.isNaN(ms))
44
+ throw new Error(`Invalid timestamp: ${t}`);
45
+ return (BigInt(ms) * 1000000n).toString();
46
+ }
47
+ // Find the index of the first top-level pipe operator in a LogsQL query,
48
+ // ignoring `|` inside quoted strings (regex alternation like `=~"a|b"`),
49
+ // brace groups `{...}`, or parens `(...)`. Returns -1 if no pipe found.
50
+ function findFirstTopLevelPipe(query) {
51
+ let inDouble = false;
52
+ let inSingle = false;
53
+ let braceDepth = 0;
54
+ let parenDepth = 0;
55
+ for (let i = 0; i < query.length; i++) {
56
+ const c = query[i];
57
+ const prev = i > 0 ? query[i - 1] : "";
58
+ // Honour backslash escapes inside strings (e.g. `"shopify\\-prod"`).
59
+ if (prev === "\\")
60
+ continue;
61
+ if (inDouble) {
62
+ if (c === '"')
63
+ inDouble = false;
64
+ continue;
65
+ }
66
+ if (inSingle) {
67
+ if (c === "'")
68
+ inSingle = false;
69
+ continue;
70
+ }
71
+ if (c === '"') {
72
+ inDouble = true;
73
+ continue;
74
+ }
75
+ if (c === "'") {
76
+ inSingle = true;
77
+ continue;
78
+ }
79
+ if (c === "{") {
80
+ braceDepth++;
81
+ continue;
82
+ }
83
+ if (c === "}") {
84
+ braceDepth--;
85
+ continue;
86
+ }
87
+ if (c === "(") {
88
+ parenDepth++;
89
+ continue;
90
+ }
91
+ if (c === ")") {
92
+ parenDepth--;
93
+ continue;
94
+ }
95
+ if (c === "|" && braceDepth === 0 && parenDepth === 0)
96
+ return i;
97
+ }
98
+ return -1;
99
+ }
100
+ // Inject `_time:[start_ns,end_ns)` before the first top-level pipe operator
101
+ // (or at end if none). Quote/brace/paren aware to handle regex alternations
102
+ // like `=~"shopify-latest|shopify-prod"` without splitting mid-string.
103
+ function injectTimeFilter(query, startNs, endNs) {
104
+ const timeFilter = `_time:[${startNs},${endNs})`;
105
+ const pipeIdx = findFirstTopLevelPipe(query);
106
+ if (pipeIdx === -1)
107
+ return `${query.trim()} ${timeFilter}`.trim();
108
+ const head = query.slice(0, pipeIdx).trim();
109
+ const tail = query.slice(pipeIdx);
110
+ return `${head} ${timeFilter} ${tail}`;
111
+ }
28
112
  server.tool("query_logs", `Query logs from CubeAPM using LogsQL syntax (VictoriaLogs compatible). Returns log entries matching the query.
29
113
 
30
114
  IMPORTANT - Log Label Discovery:
@@ -41,20 +125,49 @@ LogsQL Query Syntax:
41
125
  - Negation: {faas.name!="unwanted-service"}
42
126
  - Combine filters: {env="UNSET", faas.name=~".*-lambda.*"}
43
127
 
128
+ PIPE OPERATORS — chain after query with "|":
129
+ | copy src_field AS dst_field — copy field value
130
+ | drop field1, field2 — remove fields from output
131
+ | extract_regexp "(?P<name>regex)" — extract fields via named capture groups
132
+ | join by (field) (...subquery...) — join with subquery results
133
+ | keep field1, field2 — keep only specified fields
134
+ | limit N — return at most N results
135
+ | math result = field1 + field2 — compute derived fields (+, -, *, /, %)
136
+ | rename src AS dst — rename a field
137
+ | replace (field, "old", "new") — replace substring in field
138
+ | replace_regexp (field, "re", "replacement") — regex replacement
139
+ | sort by (field) [asc|desc] — sort results
140
+ | stats <func> as alias [by (group_fields)] — aggregate results
141
+ | unpack_json — extract fields from JSON log body
142
+
143
+ STATS FUNCTIONS (use with | stats pipe):
144
+ avg(field) — arithmetic mean
145
+ count() — total number of matching entries
146
+ count_empty(field) — count entries where field is empty
147
+ count_uniq(field) — count distinct values
148
+ max(field) — maximum value
149
+ median(field) — median (50th percentile)
150
+ min(field) — minimum value
151
+ quantile(p, field) — p-th quantile (e.g., quantile(0.95, duration))
152
+ sum(field) — sum of values
153
+
44
154
  Example queries:
45
155
  - All logs (discover structure): *
46
156
  - Lambda logs: {faas.name="webhook-lambda-prod"}
47
157
  - Search errors: {faas.name=~".*"} AND "error"
48
- - By environment: {env="production"}`, {
158
+ - By environment: {env="production"}
159
+ - Count errors per function: {faas.name=~".*"} AND "error" | stats count() as error_count by (faas.name)
160
+ - Top 10 slowest: {service_name="my-service"} | sort by (duration) desc | limit 10`, {
49
161
  query: z.string().describe("The log search query including stream filters (LogsQL syntax)"),
50
162
  start: z.string().describe("Start time in RFC3339 format (e.g., 2024-01-01T00:00:00Z) or Unix timestamp in seconds"),
51
163
  end: z.string().describe("End time in RFC3339 format or Unix timestamp in seconds"),
52
164
  limit: z.number().optional().default(100).describe("Maximum log entries to return (default: 100)"),
53
165
  }, async ({ query, start, end, limit }) => {
166
+ const startNs = toNanoseconds(start);
167
+ const endNs = toNanoseconds(end);
168
+ const finalQuery = injectTimeFilter(query, startNs, endNs);
54
169
  const params = new URLSearchParams({
55
- query,
56
- start,
57
- end,
170
+ query: finalQuery,
58
171
  limit: String(limit),
59
172
  });
60
173
  const response = await fetch(`${queryBaseUrl}/api/logs/select/logsql/query`, {
@@ -206,15 +319,45 @@ IMPORTANT - Required Parameters:
206
319
  - Use env="UNSET" if environment is not configured
207
320
  - Service names are case-sensitive (e.g., "Kratos-Prod" not "kratos")
208
321
 
209
- Optional filters:
322
+ TRACE QUERY SYNTAX:
323
+ The query parameter supports pipe syntax similar to LogsQL:
324
+ {stream_selector} | pipe1 | pipe2
325
+
326
+ Stream selectors filter spans:
327
+ {service="Kratos-Prod"}
328
+ {service="Kratos-Prod", span_kind="server"}
329
+ {http_code=~"5.."}
330
+
331
+ PIPE OPERATORS — same as LogsQL pipes:
332
+ | copy, | drop, | extract_regexp, | join, | keep, | limit,
333
+ | math, | rename, | replace, | replace_regexp, | sort,
334
+ | stats <func> as alias [by (group_fields)], | unpack_json
335
+
336
+ STATS FUNCTIONS (for aggregate trace analysis):
337
+ avg(field) — arithmetic mean
338
+ count() — total matching spans
339
+ count_uniq(field) — distinct values
340
+ max(field) — maximum value
341
+ median(field) — median value
342
+ min(field) — minimum value
343
+ quantile(p, field) — p-th quantile (e.g., quantile(0.95, duration))
344
+ sum(field) — sum of values
345
+
346
+ IMPORTANT: Duration in traces is in MILLISECONDS.
347
+ IMPORTANT: "p95" is NOT a valid function — use quantile(0.95, duration).
348
+
349
+ Example queries:
350
+ Wildcard: query="*"
351
+ P95 latency: query='{service="Kratos-Prod", span_kind="server"} | stats quantile(0.95, duration) as p95_latency'
352
+ Error count by endpoint: query='{service="Kratos-Prod", status_code="ERROR"} | stats count() as errors by (http_route)'
353
+ Slow spans: query='{service="Kratos-Prod"} | sort by (duration) desc | limit 20'
354
+
355
+ Optional filters (applied in addition to query):
210
356
  - spanKind: server, client, consumer, producer
211
357
  - sortBy: duration (to find slow traces)
212
358
 
213
359
  To discover available service names, first query metrics:
214
- count by (service) (cube_apm_calls_total{env="UNSET"})
215
-
216
- Example: Find slow server spans in Shopify-Prod
217
- query="*", env="UNSET", service="Shopify-Prod", spanKind="server", sortBy="duration"`, {
360
+ count by (service) (cube_apm_calls_total{env="UNSET"})`, {
218
361
  query: z.string().default("*").describe("The traces search query (use * for wildcard)"),
219
362
  env: z.string().default("UNSET").describe("Environment name (use UNSET if not configured)"),
220
363
  service: z.string().describe("Service name to filter by (REQUIRED, case-sensitive)"),
@@ -224,13 +367,16 @@ Example: Find slow server spans in Shopify-Prod
224
367
  spanKind: z.string().optional().describe("Filter by span kind: server, client, consumer, producer"),
225
368
  sortBy: z.string().optional().describe("Sort results by: duration"),
226
369
  }, async ({ query, env, service, start, end, limit, spanKind, sortBy }) => {
370
+ // Generate a random index value for the API call
371
+ const index = Math.random().toString(36).substring(2, 15);
227
372
  const params = new URLSearchParams({
228
373
  query,
229
374
  env,
230
375
  service,
231
376
  start,
232
377
  end,
233
- limit: String(limit)
378
+ limit: String(limit),
379
+ index
234
380
  });
235
381
  if (spanKind)
236
382
  params.append("spanKind", spanKind);
@@ -446,34 +592,122 @@ server.resource("query-patterns", "cubeapm://query-patterns", {
446
592
  {
447
593
  uri: "cubeapm://query-patterns",
448
594
  mimeType: "text/markdown",
449
- text: `# CubeAPM Query Patterns
595
+ text: `# CubeAPM Query Patterns — Full Reference
450
596
 
451
- ## Metrics Naming Conventions
597
+ ## Metrics (PromQL / MetricsQL)
598
+
599
+ ### Naming Conventions
452
600
  - Prefix: \`cube_apm_*\` (e.g., cube_apm_calls_total, cube_apm_latency_bucket)
453
601
  - Service label: \`service\` (NOT "server" or "service_name")
454
602
  - Common labels: env, service, span_kind, status_code, http_code
455
603
 
456
- ## Histogram Queries (Percentiles)
457
- CubeAPM uses VictoriaMetrics-style histograms with \`vmrange\` label:
604
+ ### Histogram Queries (Percentiles)
605
+ CubeAPM uses VictoriaMetrics-style histograms with \`vmrange\` label (NOT Prometheus \`le\` buckets):
458
606
 
459
607
  \`\`\`promql
460
- # P95 Latency
608
+ # Correct — use histogram_quantiles() with vmrange
461
609
  histogram_quantiles("phi", 0.95, sum by (vmrange, service) (
462
610
  increase(cube_apm_latency_bucket{service="MyService", span_kind="server"}[5m])
463
611
  ))
612
+
613
+ # ❌ Wrong — standard Prometheus syntax won't work
614
+ histogram_quantile(0.95, sum by (le) (rate(http_request_duration_bucket[5m])))
464
615
  \`\`\`
465
616
 
466
- Note: Latency values are in SECONDS (0.05 = 50ms)
617
+ Latency values are in SECONDS (0.05 = 50ms).
618
+
619
+ ---
467
620
 
468
621
  ## Logs (LogsQL)
469
- - Use \`*\` to discover available labels
470
- - Lambda logs: \`{faas.name="my-function"}\`
471
- - Service logs: \`{service_name="my-service"}\`
622
+
623
+ ### Stream Selectors
624
+ - \`*\` wildcard, discover all labels
625
+ - \`{faas.name="my-function"}\` — exact match
626
+ - \`{faas.name=~".*-prod"}\` — regex match
627
+ - \`{faas.name!="unwanted"}\` — negation
628
+ - \`{env="production", faas.name=~".*"}\` — combine filters
629
+
630
+ ### Text Filters
631
+ - \`{stream} AND "error"\` — text search
632
+ - \`{stream} AND "timeout" AND NOT "retry"\` — boolean operators
633
+
634
+ ### Pipe Operators
635
+ Chain after query with \`|\`:
636
+
637
+ | Pipe | Syntax | Description |
638
+ |------|--------|-------------|
639
+ | copy | \`copy src AS dst\` | Copy field value |
640
+ | drop | \`drop field1, field2\` | Remove fields from output |
641
+ | extract_regexp | \`extract_regexp "(?P<name>re)"\` | Extract via named capture groups |
642
+ | join | \`join by (field) (...subquery...)\` | Join with subquery |
643
+ | keep | \`keep field1, field2\` | Keep only specified fields |
644
+ | limit | \`limit N\` | Return at most N results |
645
+ | math | \`math result = f1 + f2\` | Arithmetic (+, -, *, /, %) |
646
+ | rename | \`rename src AS dst\` | Rename a field |
647
+ | replace | \`replace (field, "old", "new")\` | Substring replacement |
648
+ | replace_regexp | \`replace_regexp (field, "re", "repl")\` | Regex replacement |
649
+ | sort | \`sort by (field) [asc/desc]\` | Sort results |
650
+ | stats | \`stats <func> as alias [by (fields)]\` | Aggregate results |
651
+ | unpack_json | \`unpack_json\` | Extract fields from JSON body |
652
+
653
+ ### Stats Functions
654
+ Used with \`| stats\` pipe:
655
+
656
+ | Function | Description |
657
+ |----------|-------------|
658
+ | \`avg(field)\` | Arithmetic mean |
659
+ | \`count()\` | Total matching entries |
660
+ | \`count_empty(field)\` | Entries where field is empty |
661
+ | \`count_uniq(field)\` | Distinct values |
662
+ | \`max(field)\` | Maximum value |
663
+ | \`median(field)\` | Median (50th percentile) |
664
+ | \`min(field)\` | Minimum value |
665
+ | \`quantile(p, field)\` | p-th quantile (e.g., quantile(0.95, duration)) |
666
+ | \`sum(field)\` | Sum of values |
667
+
668
+ ### Example Log Queries
669
+ \`\`\`logsql
670
+ # Discover labels
671
+ *
672
+
673
+ # Count errors per Lambda function
674
+ {faas.name=~".*"} AND "error" | stats count() as errors by (faas.name)
675
+
676
+ # Top 10 slowest requests
677
+ {service_name="my-service"} | sort by (duration) desc | limit 10
678
+ \`\`\`
679
+
680
+ ---
472
681
 
473
682
  ## Traces
683
+
684
+ ### Query Syntax
685
+ Trace queries use the same pipe syntax:
686
+ \`{stream_selector} | pipe1 | pipe2\`
687
+
688
+ ### Stream Selectors
689
+ - \`{service="Kratos-Prod"}\`
690
+ - \`{service="Kratos-Prod", span_kind="server"}\`
691
+ - \`{http_code=~"5.."}\`
692
+
693
+ ### Important Notes
474
694
  - query, env, service are REQUIRED parameters
475
- - Use \`sortBy=duration\` to find slow traces
695
+ - Duration is in MILLISECONDS (not seconds like metrics)
696
+ - \`p95\` is NOT a valid function — use \`quantile(0.95, duration)\`
697
+ - Use \`sortBy=duration\` param to find slow traces
476
698
  - Filter by spanKind: server, client, consumer, producer
699
+
700
+ ### Example Trace Queries
701
+ \`\`\`
702
+ # P95 latency for a service
703
+ {service="Kratos-Prod", span_kind="server"} | stats quantile(0.95, duration) as p95_ms
704
+
705
+ # Error count by endpoint
706
+ {service="Kratos-Prod", status_code="ERROR"} | stats count() as errors by (http_route)
707
+
708
+ # Slowest spans
709
+ {service="Kratos-Prod"} | sort by (duration) desc | limit 20
710
+ \`\`\`
477
711
  `,
478
712
  },
479
713
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cubeapm-mcp",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "description": "MCP server for CubeAPM - Query traces, metrics, and logs from your observability platform using AI assistants",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",