@thotischner/observability-mcp 3.0.0 → 3.1.0

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 (93) hide show
  1. package/dist/analysis/history.d.ts +36 -2
  2. package/dist/analysis/history.js +60 -2
  3. package/dist/analysis/history.test.js +46 -0
  4. package/dist/audit/sinks/s3.d.ts +61 -0
  5. package/dist/audit/sinks/s3.js +179 -0
  6. package/dist/audit/sinks/s3.test.d.ts +1 -0
  7. package/dist/audit/sinks/s3.test.js +175 -0
  8. package/dist/auth/csrf.d.ts +6 -0
  9. package/dist/auth/csrf.js +4 -0
  10. package/dist/auth/csrf.test.js +22 -0
  11. package/dist/auth/lockout.d.ts +72 -0
  12. package/dist/auth/lockout.js +134 -0
  13. package/dist/auth/lockout.test.d.ts +1 -0
  14. package/dist/auth/lockout.test.js +133 -0
  15. package/dist/auth/middleware.d.ts +5 -0
  16. package/dist/auth/middleware.js +6 -1
  17. package/dist/auth/middleware.test.js +31 -0
  18. package/dist/auth/password-policy.d.ts +52 -0
  19. package/dist/auth/password-policy.js +125 -0
  20. package/dist/auth/password-policy.test.d.ts +1 -0
  21. package/dist/auth/password-policy.test.js +111 -0
  22. package/dist/auth/policy/batch-dry-run.js +15 -0
  23. package/dist/auth/revocation.d.ts +93 -0
  24. package/dist/auth/revocation.js +193 -0
  25. package/dist/auth/revocation.test.d.ts +1 -0
  26. package/dist/auth/revocation.test.js +136 -0
  27. package/dist/auth/session.d.ts +7 -0
  28. package/dist/auth/session.js +6 -0
  29. package/dist/auth/session.test.js +21 -0
  30. package/dist/connectors/interface.d.ts +5 -1
  31. package/dist/connectors/loader.d.ts +8 -0
  32. package/dist/connectors/loader.js +49 -0
  33. package/dist/connectors/loki.d.ts +45 -1
  34. package/dist/connectors/loki.js +141 -8
  35. package/dist/connectors/loki.test.js +171 -1
  36. package/dist/connectors/manifest-hooks.test.d.ts +1 -0
  37. package/dist/connectors/manifest-hooks.test.js +206 -0
  38. package/dist/federation/registry.d.ts +27 -5
  39. package/dist/federation/registry.js +49 -4
  40. package/dist/federation/registry.test.js +79 -3
  41. package/dist/federation/upstream.d.ts +32 -6
  42. package/dist/federation/upstream.js +60 -12
  43. package/dist/federation/upstream.test.d.ts +1 -0
  44. package/dist/federation/upstream.test.js +118 -0
  45. package/dist/index.js +522 -67
  46. package/dist/metrics/self.d.ts +1 -0
  47. package/dist/metrics/self.js +8 -0
  48. package/dist/openapi.js +39 -0
  49. package/dist/openapi.test.js +1 -0
  50. package/dist/policy/redact.js +1 -1
  51. package/dist/postmortem/store.d.ts +34 -0
  52. package/dist/postmortem/store.js +113 -0
  53. package/dist/postmortem/store.test.d.ts +1 -0
  54. package/dist/postmortem/store.test.js +118 -0
  55. package/dist/scim/compliance.test.d.ts +1 -0
  56. package/dist/scim/compliance.test.js +169 -0
  57. package/dist/scim/factory.test.d.ts +1 -0
  58. package/dist/scim/factory.test.js +54 -0
  59. package/dist/scim/patch-ops.test.d.ts +1 -0
  60. package/dist/scim/patch-ops.test.js +100 -0
  61. package/dist/scim/redis-store.d.ts +38 -0
  62. package/dist/scim/redis-store.js +178 -0
  63. package/dist/scim/redis-store.test.d.ts +1 -0
  64. package/dist/scim/redis-store.test.js +138 -0
  65. package/dist/scim/routes.d.ts +27 -2
  66. package/dist/scim/routes.js +161 -15
  67. package/dist/scim/store.d.ts +40 -1
  68. package/dist/scim/store.js +23 -5
  69. package/dist/sdk/hook-wrappers.d.ts +39 -0
  70. package/dist/sdk/hook-wrappers.js +113 -0
  71. package/dist/sdk/hook-wrappers.test.d.ts +1 -0
  72. package/dist/sdk/hook-wrappers.test.js +204 -0
  73. package/dist/sdk/index.d.ts +13 -0
  74. package/dist/security/csp.d.ts +64 -0
  75. package/dist/security/csp.js +135 -0
  76. package/dist/security/csp.test.d.ts +1 -0
  77. package/dist/security/csp.test.js +97 -0
  78. package/dist/tools/detect-anomalies.d.ts +12 -1
  79. package/dist/tools/detect-anomalies.js +22 -2
  80. package/dist/tools/query-logs.d.ts +40 -0
  81. package/dist/tools/query-logs.js +69 -3
  82. package/dist/tools/topology.js +23 -5
  83. package/dist/tools/topology.test.js +45 -0
  84. package/dist/tools/validation.d.ts +13 -0
  85. package/dist/tools/validation.js +74 -0
  86. package/dist/tools/validation.test.js +54 -1
  87. package/dist/transport/transportSessionMap.d.ts +70 -0
  88. package/dist/transport/transportSessionMap.js +128 -0
  89. package/dist/transport/transportSessionMap.test.d.ts +1 -0
  90. package/dist/transport/transportSessionMap.test.js +111 -0
  91. package/dist/types.d.ts +48 -0
  92. package/dist/ui/index.html +898 -116
  93. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
1
  import type { ObservabilityConnector } from "./interface.js";
2
2
  import type { ConnectorFactory, ConnectorManifest } from "../sdk/index.js";
3
+ import type { HookRegistry } from "../sdk/hooks.js";
3
4
  export interface LoadedConnector {
4
5
  /** Connector type id, e.g. "prometheus". Matches `source.type` in sources.yaml. */
5
6
  name: string;
@@ -26,11 +27,18 @@ export declare class PluginLoader {
26
27
  private verify;
27
28
  private trustRootPath?;
28
29
  private trustRoot?;
30
+ /** Optional HookRegistry — when set, the loader auto-registers
31
+ * every entry in `manifest.hooks[]` after the plugin loads, and
32
+ * unregisters them when a same-name plugin replaces it. Hooks
33
+ * re-registered by name+kind on hot-reload (HookRegistry.register
34
+ * already deduplicates). */
35
+ private hookRegistry?;
29
36
  constructor(opts?: {
30
37
  pluginsDir?: string;
31
38
  disabled?: string[];
32
39
  verify?: boolean;
33
40
  trustRoot?: string;
41
+ hookRegistry?: HookRegistry;
34
42
  });
35
43
  load(): Promise<void>;
36
44
  list(): LoadedConnector[];
@@ -31,10 +31,17 @@ export class PluginLoader {
31
31
  verify;
32
32
  trustRootPath;
33
33
  trustRoot;
34
+ /** Optional HookRegistry — when set, the loader auto-registers
35
+ * every entry in `manifest.hooks[]` after the plugin loads, and
36
+ * unregisters them when a same-name plugin replaces it. Hooks
37
+ * re-registered by name+kind on hot-reload (HookRegistry.register
38
+ * already deduplicates). */
39
+ hookRegistry;
34
40
  constructor(opts = {}) {
35
41
  this.pluginsDir = opts.pluginsDir
36
42
  ?? process.env.PLUGINS_DIR
37
43
  ?? "/app/plugins";
44
+ this.hookRegistry = opts.hookRegistry;
38
45
  // Per-plugin disable via env: PLUGINS_DISABLED="prometheus,loki"
39
46
  const envDisabled = (process.env.PLUGINS_DISABLED ?? "")
40
47
  .split(",")
@@ -202,6 +209,48 @@ export class PluginLoader {
202
209
  manifest,
203
210
  factory,
204
211
  });
212
+ // Manifest-driven hook auto-registration (Q10). After the
213
+ // entry module loads (and is integrity/sig-verified above), walk
214
+ // manifest.hooks[] and resolve each entry's `module` against the
215
+ // plugin root. Default export is the handler. Errors during
216
+ // individual hook load are logged + skipped — they don't tear
217
+ // down the connector itself.
218
+ if (this.hookRegistry && manifest?.hooks?.length) {
219
+ // Drop any prior registrations for this plugin so a hot-reload
220
+ // doesn't leave stale entries side-by-side with new ones.
221
+ this.hookRegistry.unregisterPlugin(marker.name);
222
+ for (const hookEntry of manifest.hooks) {
223
+ const hookPath = resolve(pluginRoot, hookEntry.module);
224
+ const inside = hookPath.startsWith(resolve(pluginRoot) + "/") || hookPath === resolve(pluginRoot);
225
+ if (!inside) {
226
+ console.warn("Plugin %s hook module %s escapes the plugin root — skipping", sanitizeForLog(marker.name), sanitizeForLog(hookEntry.module));
227
+ continue;
228
+ }
229
+ if (!existsSync(hookPath)) {
230
+ console.warn("Plugin %s hook module %s not found — skipping", sanitizeForLog(marker.name), sanitizeForLog(hookEntry.module));
231
+ continue;
232
+ }
233
+ try {
234
+ const hookMod = await import(pathToFileURL(hookPath).href);
235
+ const handler = hookMod.default ?? hookMod.handler;
236
+ if (typeof handler !== "function") {
237
+ console.warn("Plugin %s hook module %s has no default export — skipping", sanitizeForLog(marker.name), sanitizeForLog(hookEntry.module));
238
+ continue;
239
+ }
240
+ this.hookRegistry.register({
241
+ pluginName: marker.name,
242
+ kind: hookEntry.kind,
243
+ priority: hookEntry.priority,
244
+ mode: hookEntry.mode,
245
+ handler: handler,
246
+ });
247
+ console.log('Plugin "%s": registered %s hook from %s', sanitizeForLog(marker.name), sanitizeForLog(hookEntry.kind), sanitizeForLog(hookEntry.module));
248
+ }
249
+ catch (err) {
250
+ console.warn("Plugin %s hook %s/%s failed to load: %s", sanitizeForLog(marker.name), sanitizeForLog(hookEntry.kind), sanitizeForLog(hookEntry.module), sanitizeForLog(err instanceof Error ? err.message : String(err)));
251
+ }
252
+ }
253
+ }
205
254
  console.log('Connector plugin "%s" loaded from %s', sanitizeForLog(marker.name), sanitizeForLog(pluginRoot));
206
255
  }
207
256
  register(entry) {
@@ -1,5 +1,48 @@
1
1
  import type { ObservabilityConnector } from "./interface.js";
2
- import type { SourceConfig, ConnectorHealth, ServiceInfo, MetricDefinition, LogQuery, LogResult, SignalType } from "../types.js";
2
+ import type { SourceConfig, ConnectorHealth, ServiceInfo, MetricDefinition, LogQuery, LogResult, LogAggregateQuery, LogAggregateResult, SignalType } from "../types.js";
3
+ /** Escape a value for a double-quoted LogQL string literal. Backslash and
4
+ * quote first (breakout chars), then control chars — a raw newline/tab in
5
+ * a Go-style `"..."` literal is a parse error, so emit the escape sequence. */
6
+ export declare function escapeLogQLValue(value: string): string;
7
+ /**
8
+ * Compile a `labels` equality map into LogQL label-filter expressions that
9
+ * run AFTER `| json`, e.g. `{...} | json | method="GET" | status="200"`.
10
+ * Placed after the json parse so fields the pipeline extracts (not just
11
+ * stream labels) are filterable. Keys are sorted for deterministic output.
12
+ * Reusable, side-effect-free unit — also the basis for the Q-LOG2
13
+ * aggregation path and a future named-LogQL catalog. Callers validate the
14
+ * map (see validateLogLabels); this only escapes values.
15
+ */
16
+ export declare function logqlLabelFilters(labels: Record<string, string> | undefined): string;
17
+ /**
18
+ * Derive a log level from an HTTP status code when the line carries no
19
+ * explicit level: 5xx → error, 4xx → warn. Returns undefined otherwise so
20
+ * the caller keeps its existing fallback chain.
21
+ */
22
+ export declare function levelFromStatus(status: unknown): "error" | "warn" | undefined;
23
+ /** Parse a `<n><m|h|d>` duration into seconds. Returns null when malformed. */
24
+ export declare function parseDurationSeconds(duration: string): number | null;
25
+ /** Pick a bucket size (seconds) that yields ~60 points across the window,
26
+ * floored at 60s, so a count_over_time range query isn't absurdly dense. */
27
+ export declare function defaultBucketSeconds(durationSeconds: number): number;
28
+ export interface AggregateLogQL {
29
+ logql: string;
30
+ /** instant (vector) for sum/topk; range (matrix) for count_over_time. */
31
+ mode: "instant" | "range";
32
+ /** Step for the range query, e.g. "300s". Only set when mode === "range". */
33
+ step?: string;
34
+ }
35
+ /**
36
+ * Wrap a stream+pipeline expression (`{sel} | json | …`) in a LogQL metric
37
+ * aggregation. Pure + side-effect-free so it's unit-testable without a
38
+ * backend. `by` labels are assumed pre-validated (label-name shape).
39
+ */
40
+ export declare function buildAggregateLogQL(streamPipeline: string, agg: {
41
+ op: "count_over_time" | "sum" | "topk";
42
+ by?: string[];
43
+ k?: number;
44
+ step?: string;
45
+ }, duration: string): AggregateLogQL;
3
46
  export declare class LokiConnector implements ObservabilityConnector {
4
47
  readonly type = "loki";
5
48
  readonly signalType: SignalType;
@@ -17,6 +60,7 @@ export declare class LokiConnector implements ObservabilityConnector {
17
60
  disconnect(): Promise<void>;
18
61
  listServices(): Promise<ServiceInfo[]>;
19
62
  queryLogs(params: LogQuery): Promise<LogResult>;
63
+ queryLogAggregate(params: LogAggregateQuery): Promise<LogAggregateResult>;
20
64
  private getLabelValues;
21
65
  private resolveServiceSelector;
22
66
  private parseLine;
@@ -1,6 +1,84 @@
1
1
  import { buildTlsAgent } from "./tls.js";
2
2
  const DEFAULT_SERVICE_LABELS = ["service_name", "service", "job", "app", "container"];
3
3
  const LABEL_CACHE_TTL_MS = 60_000;
4
+ /** Escape a value for a double-quoted LogQL string literal. Backslash and
5
+ * quote first (breakout chars), then control chars — a raw newline/tab in
6
+ * a Go-style `"..."` literal is a parse error, so emit the escape sequence. */
7
+ export function escapeLogQLValue(value) {
8
+ return value
9
+ .replace(/\\/g, "\\\\")
10
+ .replace(/"/g, '\\"')
11
+ .replace(/\n/g, "\\n")
12
+ .replace(/\r/g, "\\r")
13
+ .replace(/\t/g, "\\t");
14
+ }
15
+ /**
16
+ * Compile a `labels` equality map into LogQL label-filter expressions that
17
+ * run AFTER `| json`, e.g. `{...} | json | method="GET" | status="200"`.
18
+ * Placed after the json parse so fields the pipeline extracts (not just
19
+ * stream labels) are filterable. Keys are sorted for deterministic output.
20
+ * Reusable, side-effect-free unit — also the basis for the Q-LOG2
21
+ * aggregation path and a future named-LogQL catalog. Callers validate the
22
+ * map (see validateLogLabels); this only escapes values.
23
+ */
24
+ export function logqlLabelFilters(labels) {
25
+ if (!labels)
26
+ return "";
27
+ return Object.keys(labels)
28
+ .sort()
29
+ .map((k) => ` | ${k}="${escapeLogQLValue(labels[k])}"`)
30
+ .join("");
31
+ }
32
+ /**
33
+ * Derive a log level from an HTTP status code when the line carries no
34
+ * explicit level: 5xx → error, 4xx → warn. Returns undefined otherwise so
35
+ * the caller keeps its existing fallback chain.
36
+ */
37
+ export function levelFromStatus(status) {
38
+ const n = typeof status === "number" ? status : parseInt(String(status ?? ""), 10);
39
+ if (!Number.isFinite(n))
40
+ return undefined;
41
+ if (n >= 500 && n <= 599)
42
+ return "error";
43
+ if (n >= 400 && n <= 499)
44
+ return "warn";
45
+ return undefined;
46
+ }
47
+ /** Parse a `<n><m|h|d>` duration into seconds. Returns null when malformed. */
48
+ export function parseDurationSeconds(duration) {
49
+ const m = /^(\d+)([mhd])$/.exec(duration);
50
+ if (!m)
51
+ return null;
52
+ const v = parseInt(m[1], 10);
53
+ return m[2] === "m" ? v * 60 : m[2] === "h" ? v * 3600 : v * 86400;
54
+ }
55
+ /** Pick a bucket size (seconds) that yields ~60 points across the window,
56
+ * floored at 60s, so a count_over_time range query isn't absurdly dense. */
57
+ export function defaultBucketSeconds(durationSeconds) {
58
+ return Math.max(60, Math.floor(durationSeconds / 60));
59
+ }
60
+ /**
61
+ * Wrap a stream+pipeline expression (`{sel} | json | …`) in a LogQL metric
62
+ * aggregation. Pure + side-effect-free so it's unit-testable without a
63
+ * backend. `by` labels are assumed pre-validated (label-name shape).
64
+ */
65
+ export function buildAggregateLogQL(streamPipeline, agg, duration) {
66
+ const durSec = parseDurationSeconds(duration) ?? 3600;
67
+ const byClause = agg.by && agg.by.length ? ` by (${agg.by.join(", ")})` : "";
68
+ if (agg.op === "count_over_time") {
69
+ const stepSec = (agg.step && parseDurationSeconds(agg.step)) || defaultBucketSeconds(durSec);
70
+ const inner = `count_over_time(${streamPipeline} [${stepSec}s])`;
71
+ const logql = byClause ? `sum${byClause} (${inner})` : inner;
72
+ return { logql, mode: "range", step: `${stepSec}s` };
73
+ }
74
+ // sum / topk: count over the whole window, then aggregate → instant vector.
75
+ const totals = `sum${byClause} (count_over_time(${streamPipeline} [${durSec}s]))`;
76
+ if (agg.op === "topk") {
77
+ const k = agg.k && agg.k > 0 ? Math.floor(agg.k) : 10;
78
+ return { logql: `topk(${k}, ${totals})`, mode: "instant" };
79
+ }
80
+ return { logql: totals, mode: "instant" };
81
+ }
4
82
  export class LokiConnector {
5
83
  type = "loki";
6
84
  signalType = "logs";
@@ -94,14 +172,13 @@ export class LokiConnector {
94
172
  // matches the real stream.
95
173
  const { label: matchedLabel, value: rawValue } = await this.resolveServiceSelector(params.service);
96
174
  const service = this.escapeLogQLValue(rawValue);
97
- let logql = `{${matchedLabel}="${service}"}`;
175
+ let logql = `{${matchedLabel}="${service}"} | json`;
98
176
  if (params.level) {
99
- const level = this.escapeLogQLValue(params.level);
100
- logql += ` | json | level="${level}"`;
101
- }
102
- else {
103
- logql += ` | json`;
177
+ logql += ` | level="${this.escapeLogQLValue(params.level)}"`;
104
178
  }
179
+ // Structured equality filters (method/status/url/environment/…) — run
180
+ // after `| json` so backend-extracted fields are selectable.
181
+ logql += logqlLabelFilters(params.labels);
105
182
  if (params.query) {
106
183
  const query = this.escapeLogQLRegex(params.query);
107
184
  logql += ` |~ \`${query}\``;
@@ -114,9 +191,16 @@ export class LokiConnector {
114
191
  const labels = stream.stream;
115
192
  for (const [ts, line] of stream.values) {
116
193
  const parsed = this.parseLine(line);
194
+ // Prefer an explicit level; otherwise derive one from an HTTP
195
+ // status field (5xx→error, 4xx→warn) so structured access logs
196
+ // that carry `status` but no `level` are still filterable/triaged.
197
+ const level = parsed.level ||
198
+ labels.level ||
199
+ levelFromStatus(parsed.status ?? labels.status) ||
200
+ "unknown";
117
201
  entries.push({
118
202
  timestamp: new Date(parseInt(ts) / 1_000_000).toISOString(),
119
- level: parsed.level || labels.level || "unknown",
203
+ level,
120
204
  message: parsed.msg || line,
121
205
  labels,
122
206
  });
@@ -140,6 +224,54 @@ export class LokiConnector {
140
224
  },
141
225
  };
142
226
  }
227
+ async queryLogAggregate(params) {
228
+ const { start, end } = this.parseTimeRange(params.duration);
229
+ const { label: matchedLabel, value: rawValue } = await this.resolveServiceSelector(params.service);
230
+ const service = this.escapeLogQLValue(rawValue);
231
+ // Same stream + pipeline prefix as queryLogs (reuses the Q-LOG1 unit),
232
+ // minus the level filter (aggregation groups, it doesn't level-filter).
233
+ let pipeline = `{${matchedLabel}="${service}"} | json`;
234
+ pipeline += logqlLabelFilters(params.labels);
235
+ if (params.query) {
236
+ pipeline += ` |~ \`${this.escapeLogQLRegex(params.query)}\``;
237
+ }
238
+ const { logql, mode, step } = buildAggregateLogQL(pipeline, { op: params.op, by: params.by, k: params.k, step: params.step }, params.duration);
239
+ const by = params.by ?? [];
240
+ const series = [];
241
+ if (mode === "instant") {
242
+ const url = `/loki/api/v1/query?query=${encodeURIComponent(logql)}&time=${end}000000000`;
243
+ const data = await this.apiGet(url);
244
+ for (const r of data?.data?.result || []) {
245
+ const v = Array.isArray(r.value) ? Number(r.value[1]) : NaN;
246
+ series.push({ labels: r.metric || {}, value: Number.isFinite(v) ? v : 0 });
247
+ }
248
+ // topk is already ordered by Loki; sort sum desc for a stable, useful view.
249
+ series.sort((a, b) => (b.value ?? 0) - (a.value ?? 0));
250
+ }
251
+ else {
252
+ // `step` from the builder is `<n>s`; the query_range step param wants seconds.
253
+ const stepSec = step ? parseInt(step, 10) || 60 : 60;
254
+ const url = `/loki/api/v1/query_range?query=${encodeURIComponent(logql)}` +
255
+ `&start=${start}000000000&end=${end}000000000&step=${stepSec}`;
256
+ const data = await this.apiGet(url);
257
+ for (const r of data?.data?.result || []) {
258
+ const points = (r.values || []).map(([ts, val]) => ({
259
+ t: Math.round(Number(ts) * 1000),
260
+ value: Number(val),
261
+ }));
262
+ series.push({ labels: r.metric || {}, points });
263
+ }
264
+ }
265
+ return {
266
+ source: this.name,
267
+ op: params.op,
268
+ by,
269
+ step: mode === "range" ? step : undefined,
270
+ mode,
271
+ series,
272
+ note: "Aggregate mode: `limit` does not apply (results are grouped counts, not raw rows).",
273
+ };
274
+ }
143
275
  // --- Private helpers ---
144
276
  async getLabelValues(label) {
145
277
  const cached = this.labelValuesCache.get(label);
@@ -204,7 +336,8 @@ export class LokiConnector {
204
336
  return { start: now - seconds, end: now };
205
337
  }
206
338
  escapeLogQLValue(value) {
207
- return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
339
+ // Delegate to the canonical module-level escaper (single source of truth).
340
+ return escapeLogQLValue(value);
208
341
  }
209
342
  escapeLogQLRegex(value) {
210
343
  // Escape backslash first (so we don't double-escape sequences we add),
@@ -1,7 +1,177 @@
1
1
  import { describe, it } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { LokiConnector } from "./loki.js";
3
+ import { LokiConnector, logqlLabelFilters, levelFromStatus, escapeLogQLValue, buildAggregateLogQL, parseDurationSeconds, defaultBucketSeconds, } from "./loki.js";
4
4
  const proto = LokiConnector.prototype;
5
+ function jsonRes(obj) {
6
+ return { ok: true, status: 200, statusText: "OK", json: async () => obj, text: async () => "" };
7
+ }
8
+ describe("Q-LOG1: logqlLabelFilters", () => {
9
+ it("returns empty string for undefined / empty", () => {
10
+ assert.equal(logqlLabelFilters(undefined), "");
11
+ assert.equal(logqlLabelFilters({}), "");
12
+ });
13
+ it("compiles a single filter", () => {
14
+ assert.equal(logqlLabelFilters({ method: "GET" }), ' | method="GET"');
15
+ });
16
+ it("compiles multiple filters, keys sorted for determinism", () => {
17
+ assert.equal(logqlLabelFilters({ status: "200", method: "GET", url: "/" }), ' | method="GET" | status="200" | url="/"');
18
+ });
19
+ it("escapes double quotes and backslashes in values", () => {
20
+ assert.equal(logqlLabelFilters({ path: 'a"b\\c' }), ' | path="a\\"b\\\\c"');
21
+ });
22
+ });
23
+ describe("Q-LOG1: levelFromStatus", () => {
24
+ it("maps 5xx → error", () => {
25
+ assert.equal(levelFromStatus(500), "error");
26
+ assert.equal(levelFromStatus("503"), "error");
27
+ assert.equal(levelFromStatus(599), "error");
28
+ });
29
+ it("maps 4xx → warn", () => {
30
+ assert.equal(levelFromStatus(404), "warn");
31
+ assert.equal(levelFromStatus("400"), "warn");
32
+ });
33
+ it("returns undefined for 2xx/3xx and non-numeric", () => {
34
+ assert.equal(levelFromStatus(200), undefined);
35
+ assert.equal(levelFromStatus(301), undefined);
36
+ assert.equal(levelFromStatus("abc"), undefined);
37
+ assert.equal(levelFromStatus(undefined), undefined);
38
+ });
39
+ });
40
+ describe("Q-LOG1: escapeLogQLValue", () => {
41
+ it("escapes backslash then quote", () => {
42
+ assert.equal(escapeLogQLValue('he said "hi"\\'), 'he said \\"hi\\"\\\\');
43
+ });
44
+ it("escapes control chars (newline/return/tab) into LogQL escape sequences", () => {
45
+ assert.equal(escapeLogQLValue("a\nb\rc\td"), "a\\nb\\rc\\td");
46
+ });
47
+ });
48
+ describe("Q-LOG1: queryLogs LogQL assembly", () => {
49
+ async function captureQuery(params) {
50
+ const conn = new LokiConnector();
51
+ await conn.connect({ name: "loki", type: "loki", url: "http://loki:3100", enabled: true });
52
+ let captured = "";
53
+ const orig = globalThis.fetch;
54
+ globalThis.fetch = (async (url) => {
55
+ const u = String(url);
56
+ if (u.includes("/label/") && u.includes("/values"))
57
+ return jsonRes({ data: ["payment"] });
58
+ if (u.includes("/query_range")) {
59
+ captured = decodeURIComponent((u.match(/query=([^&]+)/) || [])[1] || "");
60
+ return jsonRes({ data: { result: [] } });
61
+ }
62
+ return jsonRes({ data: [] });
63
+ });
64
+ try {
65
+ await conn.queryLogs({ service: "payment", duration: "5m", ...params });
66
+ }
67
+ finally {
68
+ globalThis.fetch = orig;
69
+ }
70
+ return captured;
71
+ }
72
+ it("AND's label filters after | json, with level and line filter", async () => {
73
+ const q = await captureQuery({ level: "error", labels: { method: "GET", status: "200" }, query: "timeout" });
74
+ assert.equal(q, '{service_name="payment"} | json | level="error" | method="GET" | status="200" |~ `timeout`');
75
+ });
76
+ it("works with labels only (no level/query)", async () => {
77
+ const q = await captureQuery({ labels: { environment: "prod" } });
78
+ assert.equal(q, '{service_name="payment"} | json | environment="prod"');
79
+ });
80
+ it("plain query (no labels) is unchanged from prior behaviour", async () => {
81
+ const q = await captureQuery({});
82
+ assert.equal(q, '{service_name="payment"} | json');
83
+ });
84
+ });
85
+ describe("Q-LOG2: parseDurationSeconds / defaultBucketSeconds", () => {
86
+ it("parses m/h/d", () => {
87
+ assert.equal(parseDurationSeconds("5m"), 300);
88
+ assert.equal(parseDurationSeconds("2h"), 7200);
89
+ assert.equal(parseDurationSeconds("1d"), 86400);
90
+ assert.equal(parseDurationSeconds("bad"), null);
91
+ });
92
+ it("buckets to ~60 points, floored at 60s", () => {
93
+ assert.equal(defaultBucketSeconds(3600), 60); // 1h → 60s
94
+ assert.equal(defaultBucketSeconds(86400), 1440); // 24h → 1440s
95
+ assert.equal(defaultBucketSeconds(60), 60); // tiny window floors at 60s
96
+ });
97
+ });
98
+ describe("Q-LOG2: buildAggregateLogQL", () => {
99
+ const PIPE = '{service_name="app"} | json | method="GET"';
100
+ it("count_over_time with by → sum by + range mode + step", () => {
101
+ const r = buildAggregateLogQL(PIPE, { op: "count_over_time", by: ["url"], step: "15m" }, "1h");
102
+ assert.equal(r.mode, "range");
103
+ assert.equal(r.step, "900s");
104
+ assert.equal(r.logql, `sum by (url) (count_over_time(${PIPE} [900s]))`);
105
+ });
106
+ it("count_over_time without by → bare count_over_time, default step", () => {
107
+ const r = buildAggregateLogQL(PIPE, { op: "count_over_time" }, "1h");
108
+ assert.equal(r.mode, "range");
109
+ assert.equal(r.step, "60s");
110
+ assert.equal(r.logql, `count_over_time(${PIPE} [60s])`);
111
+ });
112
+ it("sum → instant total per group over the whole window", () => {
113
+ const r = buildAggregateLogQL(PIPE, { op: "sum", by: ["status"] }, "1h");
114
+ assert.equal(r.mode, "instant");
115
+ assert.equal(r.logql, `sum by (status) (count_over_time(${PIPE} [3600s]))`);
116
+ });
117
+ it("topk → instant topk(k, sum by) with default k=10", () => {
118
+ const r = buildAggregateLogQL(PIPE, { op: "topk", by: ["url"] }, "1h");
119
+ assert.equal(r.mode, "instant");
120
+ assert.equal(r.logql, `topk(10, sum by (url) (count_over_time(${PIPE} [3600s])))`);
121
+ });
122
+ it("topk honours explicit k", () => {
123
+ const r = buildAggregateLogQL(PIPE, { op: "topk", by: ["url"], k: 3 }, "30m");
124
+ assert.equal(r.logql, `topk(3, sum by (url) (count_over_time(${PIPE} [1800s])))`);
125
+ });
126
+ });
127
+ describe("Q-LOG2: queryLogAggregate", () => {
128
+ async function run(agg) {
129
+ const conn = new LokiConnector();
130
+ await conn.connect({ name: "loki", type: "loki", url: "http://loki:3100", enabled: true });
131
+ let capturedUrl = "";
132
+ const orig = globalThis.fetch;
133
+ globalThis.fetch = (async (url) => {
134
+ const u = String(url);
135
+ if (u.includes("/label/") && u.includes("/values"))
136
+ return jsonRes({ data: ["app"] });
137
+ if (u.includes("/query_range")) {
138
+ capturedUrl = u;
139
+ return jsonRes({ data: { resultType: "matrix", result: [
140
+ { metric: { url: "/" }, values: [[1000, "3"], [1060, "5"]] },
141
+ ] } });
142
+ }
143
+ if (u.includes("/query")) {
144
+ capturedUrl = u;
145
+ return jsonRes({ data: { resultType: "vector", result: [
146
+ { metric: { url: "/a" }, value: [2000, "7"] },
147
+ { metric: { url: "/b" }, value: [2000, "12"] },
148
+ ] } });
149
+ }
150
+ return jsonRes({ data: [] });
151
+ });
152
+ try {
153
+ return await conn.queryLogAggregate({ service: "app", duration: "1h", ...agg });
154
+ }
155
+ finally {
156
+ globalThis.fetch = orig;
157
+ }
158
+ }
159
+ it("topk → instant vector parsed + sorted desc, note set", async () => {
160
+ const res = await run({ op: "topk", by: ["url"], k: 2 });
161
+ assert.equal(res.mode, "instant");
162
+ assert.equal(res.op, "topk");
163
+ assert.deepEqual(res.by, ["url"]);
164
+ assert.deepEqual(res.series.map((s) => [s.labels.url, s.value]), [["/b", 12], ["/a", 7]]);
165
+ assert.match(res.note, /limit/);
166
+ });
167
+ it("count_over_time → range matrix parsed into points", async () => {
168
+ const res = await run({ op: "count_over_time", by: ["url"], step: "1m" });
169
+ assert.equal(res.mode, "range");
170
+ assert.equal(res.step, "60s");
171
+ assert.equal(res.series.length, 1);
172
+ assert.deepEqual(res.series[0].points, [{ t: 1000000, value: 3 }, { t: 1060000, value: 5 }]);
173
+ });
174
+ });
5
175
  describe("LokiConnector", () => {
6
176
  describe("parseLine", () => {
7
177
  it("parses valid JSON", () => {
@@ -0,0 +1 @@
1
+ export {};