@thotischner/observability-mcp 3.0.1 → 3.1.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 (46) 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/auth/csrf.d.ts +6 -0
  5. package/dist/auth/csrf.js +4 -0
  6. package/dist/auth/csrf.test.js +22 -0
  7. package/dist/auth/lockout.d.ts +72 -0
  8. package/dist/auth/lockout.js +134 -0
  9. package/dist/auth/lockout.test.d.ts +1 -0
  10. package/dist/auth/lockout.test.js +133 -0
  11. package/dist/auth/middleware.d.ts +5 -0
  12. package/dist/auth/middleware.js +6 -1
  13. package/dist/auth/middleware.test.js +31 -0
  14. package/dist/auth/password-policy.d.ts +52 -0
  15. package/dist/auth/password-policy.js +125 -0
  16. package/dist/auth/password-policy.test.d.ts +1 -0
  17. package/dist/auth/password-policy.test.js +111 -0
  18. package/dist/auth/revocation.d.ts +93 -0
  19. package/dist/auth/revocation.js +193 -0
  20. package/dist/auth/revocation.test.d.ts +1 -0
  21. package/dist/auth/revocation.test.js +136 -0
  22. package/dist/auth/session.d.ts +7 -0
  23. package/dist/auth/session.js +6 -0
  24. package/dist/auth/session.test.js +21 -0
  25. package/dist/conformance/mcp-2025-11-25.test.js +14 -0
  26. package/dist/connectors/interface.d.ts +5 -1
  27. package/dist/connectors/loki.d.ts +45 -1
  28. package/dist/connectors/loki.js +141 -8
  29. package/dist/connectors/loki.test.js +171 -1
  30. package/dist/index.js +244 -4
  31. package/dist/openapi.js +39 -0
  32. package/dist/openapi.test.js +1 -0
  33. package/dist/security/csp.d.ts +64 -0
  34. package/dist/security/csp.js +135 -0
  35. package/dist/security/csp.test.d.ts +1 -0
  36. package/dist/security/csp.test.js +97 -0
  37. package/dist/tools/query-logs-schema.test.d.ts +1 -0
  38. package/dist/tools/query-logs-schema.test.js +38 -0
  39. package/dist/tools/query-logs.d.ts +40 -0
  40. package/dist/tools/query-logs.js +69 -3
  41. package/dist/tools/validation.d.ts +13 -0
  42. package/dist/tools/validation.js +74 -0
  43. package/dist/tools/validation.test.js +54 -1
  44. package/dist/types.d.ts +48 -0
  45. package/dist/ui/index.html +42 -15
  46. package/package.json +1 -1
@@ -0,0 +1,136 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { RevocationStore } from "./revocation.js";
7
+ function tmpFile(name = "revocations.jsonl") {
8
+ const dir = mkdtempSync(join(tmpdir(), "omcp-revoke-"));
9
+ return join(dir, name);
10
+ }
11
+ test("memory store revokes a single session by sid", async () => {
12
+ const store = await RevocationStore.create();
13
+ assert.equal(store.persistent, false);
14
+ assert.equal(store.isRevoked({ sub: "alice", iat: 1000, sid: "s1" }), false);
15
+ await store.revokeSession("s1");
16
+ assert.equal(store.isRevoked({ sub: "alice", iat: 1000, sid: "s1" }), true);
17
+ // A different session of the same subject is untouched.
18
+ assert.equal(store.isRevoked({ sub: "alice", iat: 1000, sid: "s2" }), false);
19
+ });
20
+ test("session without a sid is never caught by a session-kind revocation", async () => {
21
+ const store = await RevocationStore.create();
22
+ await store.revokeSession("s1");
23
+ assert.equal(store.isRevoked({ sub: "alice", iat: 1000 }), false);
24
+ });
25
+ test("subject revocation catches sessions issued at or before the cutoff", async () => {
26
+ let clock = 5000;
27
+ const store = await RevocationStore.create({ now: () => clock });
28
+ await store.revokeSubject("bob");
29
+ // Issued before the cutoff → revoked.
30
+ assert.equal(store.isRevoked({ sub: "bob", iat: 4999, sid: "old" }), true);
31
+ // Issued in the same second → revoked (intent: kill what exists now).
32
+ assert.equal(store.isRevoked({ sub: "bob", iat: 5000, sid: "same" }), true);
33
+ // Issued after the cutoff (fresh login) → valid again.
34
+ assert.equal(store.isRevoked({ sub: "bob", iat: 5001, sid: "new" }), false);
35
+ // A different subject is unaffected.
36
+ assert.equal(store.isRevoked({ sub: "carol", iat: 1, sid: "x" }), false);
37
+ });
38
+ test("re-revoking a subject only widens the cutoff window", async () => {
39
+ let clock = 100;
40
+ const store = await RevocationStore.create({ now: () => clock });
41
+ await store.revokeSubject("dave"); // cutoff 100
42
+ clock = 200;
43
+ await store.revokeSubject("dave"); // cutoff 200
44
+ assert.equal(store.isRevoked({ sub: "dave", iat: 150, sid: "mid" }), true);
45
+ assert.equal(store.isRevoked({ sub: "dave", iat: 201, sid: "after" }), false);
46
+ });
47
+ test("entries persist to disk and reload into a fresh store", async () => {
48
+ const path = tmpFile();
49
+ const store = await RevocationStore.create({ path, now: () => 7000 });
50
+ await store.revokeSession("sid-a", { reason: "stolen laptop", by: "admin" });
51
+ await store.revokeSubject("eve", { reason: "offboarded" });
52
+ assert.equal(store.size, 2);
53
+ const reloaded = await RevocationStore.create({ path });
54
+ assert.equal(reloaded.size, 2);
55
+ assert.equal(reloaded.isRevoked({ sub: "x", iat: 1, sid: "sid-a" }), true);
56
+ assert.equal(reloaded.isRevoked({ sub: "eve", iat: 6999, sid: "z" }), true);
57
+ assert.equal(reloaded.isRevoked({ sub: "eve", iat: 7001, sid: "z" }), false);
58
+ });
59
+ test("persisted file is JSONL and carries the metadata", async () => {
60
+ const path = tmpFile();
61
+ const store = await RevocationStore.create({ path, now: () => 42 });
62
+ await store.revokeSession("sid-meta", { reason: "test", by: "root" });
63
+ const lines = readFileSync(path, "utf8").trim().split("\n");
64
+ assert.equal(lines.length, 1);
65
+ const entry = JSON.parse(lines[0]);
66
+ assert.deepEqual(entry, {
67
+ kind: "session",
68
+ value: "sid-meta",
69
+ revokedAt: 42,
70
+ reason: "test",
71
+ by: "root",
72
+ });
73
+ });
74
+ test("malformed and partial lines are skipped on load", async () => {
75
+ const path = tmpFile();
76
+ writeFileSync(path, [
77
+ JSON.stringify({ kind: "session", value: "good", revokedAt: 1 }),
78
+ "not json at all",
79
+ JSON.stringify({ kind: "bogus", value: "x", revokedAt: 1 }), // bad kind
80
+ JSON.stringify({ kind: "subject", value: "", revokedAt: 1 }), // empty value
81
+ JSON.stringify({ kind: "subject", value: "u", revokedAt: "nope" }), // bad ts
82
+ "", // blank
83
+ JSON.stringify({ kind: "subject", value: "frank", revokedAt: 500 }),
84
+ ].join("\n"));
85
+ const store = await RevocationStore.create({ path });
86
+ // Only the two well-formed entries survived.
87
+ assert.equal(store.size, 2);
88
+ assert.equal(store.isRevoked({ sub: "x", iat: 1, sid: "good" }), true);
89
+ assert.equal(store.isRevoked({ sub: "frank", iat: 400, sid: "y" }), true);
90
+ });
91
+ test("missing file is treated as an empty blocklist", async () => {
92
+ const path = join(mkdtempSync(join(tmpdir(), "omcp-revoke-")), "does-not-exist.jsonl");
93
+ const store = await RevocationStore.create({ path });
94
+ assert.equal(store.size, 0);
95
+ assert.equal(store.isRevoked({ sub: "a", iat: 1, sid: "s" }), false);
96
+ // First write creates the file.
97
+ await store.revokeSession("s");
98
+ assert.equal(readFileSync(path, "utf8").trim().split("\n").length, 1);
99
+ });
100
+ test("file under a non-existent directory is created on first write", async () => {
101
+ const dir = mkdtempSync(join(tmpdir(), "omcp-revoke-"));
102
+ const path = join(dir, "nested", "deep", "revocations.jsonl");
103
+ const store = await RevocationStore.create({ path });
104
+ await store.revokeSession("s");
105
+ assert.equal(readFileSync(path, "utf8").trim().split("\n").length, 1);
106
+ });
107
+ test("list() returns a defensive copy in file order", async () => {
108
+ const store = await RevocationStore.create({ now: () => 9 });
109
+ await store.revokeSession("a");
110
+ await store.revokeSubject("b");
111
+ const list = store.list();
112
+ assert.equal(list.length, 2);
113
+ assert.equal(list[0].kind, "session");
114
+ assert.equal(list[1].kind, "subject");
115
+ // Mutating the returned array/objects must not corrupt the store.
116
+ list[0].value = "tampered";
117
+ list.push({ kind: "session", value: "ghost", revokedAt: 0 });
118
+ assert.equal(store.list().length, 2);
119
+ assert.equal(store.list()[0].value, "a");
120
+ });
121
+ test("concurrent revokes all land on disk without interleaving", async () => {
122
+ const path = tmpFile();
123
+ const store = await RevocationStore.create({ path, now: () => 1 });
124
+ await Promise.all(Array.from({ length: 20 }, (_, i) => store.revokeSession(`s${i}`)));
125
+ const lines = readFileSync(path, "utf8").trim().split("\n");
126
+ assert.equal(lines.length, 20);
127
+ // Every line is independently parseable (no torn writes).
128
+ for (const line of lines) {
129
+ assert.doesNotThrow(() => JSON.parse(line));
130
+ }
131
+ });
132
+ test("reason/by are omitted from the entry when not supplied", async () => {
133
+ const store = await RevocationStore.create({ now: () => 3 });
134
+ const entry = await store.revokeSession("s");
135
+ assert.deepEqual(entry, { kind: "session", value: "s", revokedAt: 3 });
136
+ });
@@ -25,6 +25,13 @@ export interface SessionPayload {
25
25
  tenant?: string;
26
26
  /** Optional list of role identifiers — used by later phases for RBAC. */
27
27
  roles?: string[];
28
+ /** Per-session random identifier. Minted at issue time. Lets the
29
+ * revocation blocklist (see ./revocation.ts) drop a single session
30
+ * without rotating the signing secret. Optional for backward compat:
31
+ * cookies issued before this field existed still verify and simply
32
+ * can't be revoked individually (a subject-wide revocation still
33
+ * catches them via `iat`). */
34
+ sid?: string;
28
35
  /** Issued-at, seconds since epoch. */
29
36
  iat: number;
30
37
  /** Hard expiry, seconds since epoch. */
@@ -37,6 +37,10 @@ export function issueSession(identity, cfg, now = Math.floor(Date.now() / 1000))
37
37
  email: identity.email,
38
38
  tenant: identity.tenant,
39
39
  roles: identity.roles,
40
+ // 16 random bytes ≈ 128 bits — collision-free across any realistic
41
+ // session population, and enough entropy that a sid can't be guessed
42
+ // to forge a revocation target for another user's session.
43
+ sid: randomBytes(16).toString("base64url"),
40
44
  iat: now,
41
45
  exp: now + ttl,
42
46
  };
@@ -93,6 +97,8 @@ function isSessionPayload(v) {
93
97
  return false;
94
98
  if (o.roles !== undefined && !(Array.isArray(o.roles) && o.roles.every((r) => typeof r === "string")))
95
99
  return false;
100
+ if (o.sid !== undefined && typeof o.sid !== "string")
101
+ return false;
96
102
  if (o.email !== undefined && typeof o.email !== "string")
97
103
  return false;
98
104
  if (o.tenant !== undefined && typeof o.tenant !== "string")
@@ -1,5 +1,6 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
+ import { createHmac } from "node:crypto";
3
4
  import { issueSession, verifySession, setCookieHeader, clearCookieHeader, readCookie, generateSecret, DEFAULT_COOKIE_NAME, } from "./session.js";
4
5
  const secret = "a".repeat(48);
5
6
  test("issueSession + verifySession — round-trips identity", () => {
@@ -12,6 +13,26 @@ test("issueSession + verifySession — round-trips identity", () => {
12
13
  assert.equal(verified.sub, "alice");
13
14
  assert.deepEqual(verified.roles, ["operator"]);
14
15
  });
16
+ test("issueSession mints a unique sid that round-trips through verify", () => {
17
+ const now = 1_700_000_000;
18
+ const a = issueSession({ sub: "alice", name: "Alice" }, { secret }, now);
19
+ const b = issueSession({ sub: "alice", name: "Alice" }, { secret }, now);
20
+ assert.ok(a.payload.sid, "expected a sid");
21
+ assert.notEqual(a.payload.sid, b.payload.sid, "each session gets its own sid");
22
+ const verified = verifySession(a.cookie, { secret }, now + 1);
23
+ assert.ok(verified);
24
+ assert.equal(verified.sid, a.payload.sid);
25
+ });
26
+ test("verifySession — a legacy cookie without a sid still verifies", () => {
27
+ const now = 1_700_000_000;
28
+ // Hand-craft a payload lacking sid (pre-Q17 shape) and sign it.
29
+ const payload = { sub: "alice", name: "Alice", iat: now, exp: now + 3600 };
30
+ const payloadStr = Buffer.from(JSON.stringify(payload)).toString("base64url");
31
+ const sig = createHmac("sha256", secret).update(payloadStr).digest("base64url");
32
+ const verified = verifySession(`${payloadStr}.${sig}`, { secret }, now + 1);
33
+ assert.ok(verified, "legacy cookie should still verify");
34
+ assert.equal(verified.sid, undefined);
35
+ });
15
36
  test("issueSession + verifySession — round-trips email when present", () => {
16
37
  const now = 1_700_000_000;
17
38
  const { cookie } = issueSession({ sub: "alice", name: "Alice", email: "alice@example.test", roles: ["operator"] }, { secret }, now);
@@ -113,6 +113,20 @@ test("MCP 2025-11-25: tools/list returns a Tool[] each with name + inputSchema",
113
113
  assert.ok(t.inputSchema && typeof t.inputSchema === "object", `tool ${t.name} missing inputSchema`);
114
114
  }
115
115
  });
116
+ test("MCP 2025-11-25: query_logs advertises labels + aggregate params (issue #415)", opts, async () => {
117
+ // Regression guard for the v3.1.0 ship gap: the labels/aggregate handler
118
+ // code existed but the inline MCP schema in createMcpServer never declared
119
+ // them, so a live tools/list omitted them and the SDK stripped them from
120
+ // calls — a silent no-op. Assert the live server advertises both.
121
+ const session = await newSession();
122
+ const { response } = await jsonRpc("tools/list", {}, { id: 2, session });
123
+ const r = response.result;
124
+ const queryLogs = r.tools?.find((t) => t.name === "query_logs");
125
+ assert.ok(queryLogs, "query_logs tool must be advertised");
126
+ const props = queryLogs.inputSchema?.properties ?? {};
127
+ assert.ok("labels" in props, "query_logs must advertise a `labels` param (issue #415 #1)");
128
+ assert.ok("aggregate" in props, "query_logs must advertise an `aggregate` param (issue #415 #2)");
129
+ });
116
130
  test("MCP 2025-11-25: tools/call dispatches and returns CallToolResult", opts, async () => {
117
131
  const session = await newSession();
118
132
  const { response } = await jsonRpc("tools/call", { name: "list_sources", arguments: {} }, { id: 3, session });
@@ -1,4 +1,4 @@
1
- import type { SignalType, ConnectorHealth, ServiceInfo, MetricInfo, MetricQuery, MetricResult, LogQuery, LogResult, TraceQuery, TraceResult, SourceConfig, MetricDefinition, Resource, Edge, TopologySnapshot, TopologyChangeListener } from "../types.js";
1
+ import type { SignalType, ConnectorHealth, ServiceInfo, MetricInfo, MetricQuery, MetricResult, LogQuery, LogResult, LogAggregateQuery, LogAggregateResult, TraceQuery, TraceResult, SourceConfig, MetricDefinition, Resource, Edge, TopologySnapshot, TopologyChangeListener } from "../types.js";
2
2
  export interface ObservabilityConnector {
3
3
  readonly name: string;
4
4
  readonly type: string;
@@ -14,6 +14,10 @@ export interface ObservabilityConnector {
14
14
  listAvailableMetrics?(service: string): Promise<MetricInfo[]>;
15
15
  queryMetrics?(params: MetricQuery): Promise<MetricResult>;
16
16
  queryLogs?(params: LogQuery): Promise<LogResult>;
17
+ /** Optional server-side log aggregation (count/sum/topk). Backends that
18
+ * can push aggregation down (Loki → LogQL metric queries) implement it;
19
+ * the query_logs tool routes here when an `aggregate` arg is present. */
20
+ queryLogAggregate?(params: LogAggregateQuery): Promise<LogAggregateResult>;
17
21
  /** Optional traces capability — Tempo / Jaeger / OTLP backends
18
22
  * implement this. The MCP `query_traces` tool fans out to every
19
23
  * connector that has it. */
@@ -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),