@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.
- package/dist/analysis/history.d.ts +36 -2
- package/dist/analysis/history.js +60 -2
- package/dist/analysis/history.test.js +46 -0
- package/dist/auth/csrf.d.ts +6 -0
- package/dist/auth/csrf.js +4 -0
- package/dist/auth/csrf.test.js +22 -0
- package/dist/auth/lockout.d.ts +72 -0
- package/dist/auth/lockout.js +134 -0
- package/dist/auth/lockout.test.d.ts +1 -0
- package/dist/auth/lockout.test.js +133 -0
- package/dist/auth/middleware.d.ts +5 -0
- package/dist/auth/middleware.js +6 -1
- package/dist/auth/middleware.test.js +31 -0
- package/dist/auth/password-policy.d.ts +52 -0
- package/dist/auth/password-policy.js +125 -0
- package/dist/auth/password-policy.test.d.ts +1 -0
- package/dist/auth/password-policy.test.js +111 -0
- package/dist/auth/revocation.d.ts +93 -0
- package/dist/auth/revocation.js +193 -0
- package/dist/auth/revocation.test.d.ts +1 -0
- package/dist/auth/revocation.test.js +136 -0
- package/dist/auth/session.d.ts +7 -0
- package/dist/auth/session.js +6 -0
- package/dist/auth/session.test.js +21 -0
- package/dist/conformance/mcp-2025-11-25.test.js +14 -0
- package/dist/connectors/interface.d.ts +5 -1
- package/dist/connectors/loki.d.ts +45 -1
- package/dist/connectors/loki.js +141 -8
- package/dist/connectors/loki.test.js +171 -1
- package/dist/index.js +244 -4
- package/dist/openapi.js +39 -0
- package/dist/openapi.test.js +1 -0
- package/dist/security/csp.d.ts +64 -0
- package/dist/security/csp.js +135 -0
- package/dist/security/csp.test.d.ts +1 -0
- package/dist/security/csp.test.js +97 -0
- package/dist/tools/query-logs-schema.test.d.ts +1 -0
- package/dist/tools/query-logs-schema.test.js +38 -0
- package/dist/tools/query-logs.d.ts +40 -0
- package/dist/tools/query-logs.js +69 -3
- package/dist/tools/validation.d.ts +13 -0
- package/dist/tools/validation.js +74 -0
- package/dist/tools/validation.test.js +54 -1
- package/dist/types.d.ts +48 -0
- package/dist/ui/index.html +42 -15
- package/package.json +1 -1
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-Security-Policy for the management-plane Web UI.
|
|
3
|
+
*
|
|
4
|
+
* Two policies ship together, by design:
|
|
5
|
+
*
|
|
6
|
+
* - **Enforced** (`Content-Security-Policy`): a real, non-breaking policy.
|
|
7
|
+
* It locks down everything the UI doesn't need — no remote scripts
|
|
8
|
+
* (`script-src 'self'`), no plugins (`object-src 'none'`), no `<base>`
|
|
9
|
+
* hijack (`base-uri 'self'`), no framing (`frame-ancestors 'none'`),
|
|
10
|
+
* and same-origin-only XHR via `connect-src 'self'`. It keeps
|
|
11
|
+
* `'unsafe-inline'` for `script-src` because the single-file UI uses
|
|
12
|
+
* ~200 inline event-handler attributes (`onclick=`, …) that a nonce
|
|
13
|
+
* cannot cover — a nonce in `script-src` would *disable* `'unsafe-inline'`
|
|
14
|
+
* in CSP3 and break every button. So the enforced policy is a genuine
|
|
15
|
+
* improvement over no CSP without regressing the UI.
|
|
16
|
+
*
|
|
17
|
+
* - **Report-Only** (`Content-Security-Policy-Report-Only`): the strict
|
|
18
|
+
* target policy — `script-src 'self' 'nonce-…'`, no `'unsafe-inline'`.
|
|
19
|
+
* The two legitimate inline `<script>` blocks carry the per-request
|
|
20
|
+
* nonce, so this policy flags ONLY the inline event-handler debt. It
|
|
21
|
+
* blocks nothing; it just reports, giving an actionable migration list
|
|
22
|
+
* (move the handlers to addEventListener) before a future slice can
|
|
23
|
+
* promote the strict policy to enforced.
|
|
24
|
+
*
|
|
25
|
+
* It is **opt-in** (`OMCP_CSP_STRICT_REPORT=true`): with ~200 inline
|
|
26
|
+
* handlers it would otherwise emit a `[Report Only]` console message
|
|
27
|
+
* per handler on every page load — noise an operator with devtools
|
|
28
|
+
* open shouldn't eat by default. Enable it when you're actively
|
|
29
|
+
* working the migration. The enforced policy + reporting endpoint are
|
|
30
|
+
* always on regardless.
|
|
31
|
+
*
|
|
32
|
+
* Both policies report to `/api/csp-violations` via the modern Reporting
|
|
33
|
+
* API (`Reporting-Endpoints` + `report-to`) and the legacy `report-uri`.
|
|
34
|
+
*/
|
|
35
|
+
import { randomBytes } from "node:crypto";
|
|
36
|
+
/** Placeholder substituted with the per-request nonce when serving the UI HTML. */
|
|
37
|
+
export const CSP_NONCE_PLACEHOLDER = "__CSP_NONCE__";
|
|
38
|
+
/** The named reporting group used in the Report-To / Reporting-Endpoints headers. */
|
|
39
|
+
export const CSP_REPORT_GROUP = "omcp-csp";
|
|
40
|
+
/** Where violation reports are POSTed. */
|
|
41
|
+
export const CSP_REPORT_PATH = "/api/csp-violations";
|
|
42
|
+
/** Fresh base64 nonce (128 bits). */
|
|
43
|
+
export function generateNonce() {
|
|
44
|
+
return randomBytes(16).toString("base64");
|
|
45
|
+
}
|
|
46
|
+
/** The enforced policy — non-breaking, keeps the UI working. */
|
|
47
|
+
export function enforcedCsp() {
|
|
48
|
+
return [
|
|
49
|
+
"default-src 'self'",
|
|
50
|
+
"base-uri 'self'",
|
|
51
|
+
"object-src 'none'",
|
|
52
|
+
"frame-ancestors 'none'",
|
|
53
|
+
"form-action 'self'",
|
|
54
|
+
"script-src 'self' 'unsafe-inline'",
|
|
55
|
+
"style-src 'self' 'unsafe-inline'",
|
|
56
|
+
"img-src 'self' data:",
|
|
57
|
+
"font-src 'self' data:",
|
|
58
|
+
"connect-src 'self'",
|
|
59
|
+
`report-uri ${CSP_REPORT_PATH}`,
|
|
60
|
+
`report-to ${CSP_REPORT_GROUP}`,
|
|
61
|
+
].join("; ");
|
|
62
|
+
}
|
|
63
|
+
/** The strict target policy, run in report-only mode against the nonce. */
|
|
64
|
+
export function reportOnlyCsp(nonce) {
|
|
65
|
+
return [
|
|
66
|
+
"default-src 'self'",
|
|
67
|
+
"base-uri 'self'",
|
|
68
|
+
"object-src 'none'",
|
|
69
|
+
"frame-ancestors 'none'",
|
|
70
|
+
"form-action 'self'",
|
|
71
|
+
`script-src 'self' 'nonce-${nonce}'`,
|
|
72
|
+
"style-src 'self' 'unsafe-inline'",
|
|
73
|
+
"img-src 'self' data:",
|
|
74
|
+
"font-src 'self' data:",
|
|
75
|
+
"connect-src 'self'",
|
|
76
|
+
`report-uri ${CSP_REPORT_PATH}`,
|
|
77
|
+
`report-to ${CSP_REPORT_GROUP}`,
|
|
78
|
+
].join("; ");
|
|
79
|
+
}
|
|
80
|
+
/** Whether the strict Report-Only policy is enabled. Default off — see
|
|
81
|
+
* the module header for why (console noise from ~200 inline handlers). */
|
|
82
|
+
export function cspStrictReportFromEnv(env = process.env) {
|
|
83
|
+
const v = env.OMCP_CSP_STRICT_REPORT?.trim().toLowerCase();
|
|
84
|
+
return v === "1" || v === "true" || v === "yes";
|
|
85
|
+
}
|
|
86
|
+
/** Value for the modern `Reporting-Endpoints` header. */
|
|
87
|
+
export function reportingEndpointsHeader() {
|
|
88
|
+
return `${CSP_REPORT_GROUP}="${CSP_REPORT_PATH}"`;
|
|
89
|
+
}
|
|
90
|
+
/** Value for the legacy `Report-To` header (Reporting API v0). */
|
|
91
|
+
export function reportToHeader() {
|
|
92
|
+
return JSON.stringify({
|
|
93
|
+
group: CSP_REPORT_GROUP,
|
|
94
|
+
max_age: 10886400,
|
|
95
|
+
endpoints: [{ url: CSP_REPORT_PATH }],
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Normalise a posted CSP violation (either the legacy
|
|
100
|
+
* `application/csp-report` `{ "csp-report": {...} }` envelope or a modern
|
|
101
|
+
* Reporting-API `application/reports+json` array element) into a compact,
|
|
102
|
+
* log-safe summary. Returns null when the body isn't a recognisable report.
|
|
103
|
+
*/
|
|
104
|
+
export function summariseViolation(body) {
|
|
105
|
+
if (!body || typeof body !== "object")
|
|
106
|
+
return null;
|
|
107
|
+
// Reporting API delivers an array of { type, body: {...} }.
|
|
108
|
+
if (Array.isArray(body)) {
|
|
109
|
+
for (const item of body) {
|
|
110
|
+
const s = summariseViolation(item);
|
|
111
|
+
if (s)
|
|
112
|
+
return s;
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
const o = body;
|
|
117
|
+
// Reporting-API single report: { type: "csp-violation", body: {...} }.
|
|
118
|
+
const report = (o["csp-report"] ?? o.body ?? o);
|
|
119
|
+
if (!report || typeof report !== "object")
|
|
120
|
+
return null;
|
|
121
|
+
const pick = (...keys) => {
|
|
122
|
+
for (const k of keys) {
|
|
123
|
+
const v = report[k];
|
|
124
|
+
if (typeof v === "string" && v)
|
|
125
|
+
return v.slice(0, 256);
|
|
126
|
+
}
|
|
127
|
+
return "";
|
|
128
|
+
};
|
|
129
|
+
const directive = pick("effective-directive", "effectiveDirective", "violated-directive", "violatedDirective");
|
|
130
|
+
const blockedUri = pick("blocked-uri", "blockedURL", "blockedURI");
|
|
131
|
+
const documentUri = pick("document-uri", "documentURL", "documentURI");
|
|
132
|
+
if (!directive && !blockedUri && !documentUri)
|
|
133
|
+
return null;
|
|
134
|
+
return { directive, blockedUri, documentUri };
|
|
135
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { generateNonce, enforcedCsp, reportOnlyCsp, reportingEndpointsHeader, reportToHeader, summariseViolation, cspStrictReportFromEnv, CSP_NONCE_PLACEHOLDER, CSP_REPORT_GROUP, CSP_REPORT_PATH, } from "./csp.js";
|
|
4
|
+
test("generateNonce returns a fresh base64 value each call", () => {
|
|
5
|
+
const a = generateNonce();
|
|
6
|
+
const b = generateNonce();
|
|
7
|
+
assert.notEqual(a, b);
|
|
8
|
+
assert.match(a, /^[A-Za-z0-9+/]+=*$/);
|
|
9
|
+
// 16 bytes → 24 base64 chars (with padding).
|
|
10
|
+
assert.ok(a.length >= 22);
|
|
11
|
+
});
|
|
12
|
+
test("enforced policy keeps the UI working but locks the rest down", () => {
|
|
13
|
+
const csp = enforcedCsp();
|
|
14
|
+
// Inline handlers survive: unsafe-inline present, NO nonce (which would disable it).
|
|
15
|
+
assert.match(csp, /script-src 'self' 'unsafe-inline'/);
|
|
16
|
+
assert.ok(!csp.includes("nonce-"), "enforced policy must not carry a nonce");
|
|
17
|
+
// Hard locks.
|
|
18
|
+
assert.match(csp, /object-src 'none'/);
|
|
19
|
+
assert.match(csp, /base-uri 'self'/);
|
|
20
|
+
assert.match(csp, /frame-ancestors 'none'/);
|
|
21
|
+
assert.match(csp, /default-src 'self'/);
|
|
22
|
+
assert.match(csp, /connect-src 'self'/);
|
|
23
|
+
// Reporting wired both ways.
|
|
24
|
+
assert.match(csp, new RegExp(`report-uri ${CSP_REPORT_PATH}`));
|
|
25
|
+
assert.match(csp, new RegExp(`report-to ${CSP_REPORT_GROUP}`));
|
|
26
|
+
});
|
|
27
|
+
test("report-only policy is strict and nonce-bound, no unsafe-inline on scripts", () => {
|
|
28
|
+
const nonce = generateNonce();
|
|
29
|
+
const csp = reportOnlyCsp(nonce);
|
|
30
|
+
assert.match(csp, new RegExp(`script-src 'self' 'nonce-${nonce.replace(/[+/]/g, "\\$&")}'`));
|
|
31
|
+
// Strict: the script directive must NOT allow unsafe-inline.
|
|
32
|
+
const scriptDirective = csp.split(";").find((d) => d.trim().startsWith("script-src"));
|
|
33
|
+
assert.ok(!scriptDirective.includes("unsafe-inline"));
|
|
34
|
+
assert.match(csp, /object-src 'none'/);
|
|
35
|
+
});
|
|
36
|
+
test("reporting headers name the same group + endpoint", () => {
|
|
37
|
+
assert.equal(reportingEndpointsHeader(), `${CSP_REPORT_GROUP}="${CSP_REPORT_PATH}"`);
|
|
38
|
+
const parsed = JSON.parse(reportToHeader());
|
|
39
|
+
assert.equal(parsed.group, CSP_REPORT_GROUP);
|
|
40
|
+
assert.equal(parsed.endpoints[0].url, CSP_REPORT_PATH);
|
|
41
|
+
assert.ok(parsed.max_age > 0);
|
|
42
|
+
});
|
|
43
|
+
test("the nonce placeholder is a stable token", () => {
|
|
44
|
+
assert.equal(CSP_NONCE_PLACEHOLDER, "__CSP_NONCE__");
|
|
45
|
+
});
|
|
46
|
+
test("strict report-only is opt-in (default off)", () => {
|
|
47
|
+
assert.equal(cspStrictReportFromEnv({}), false);
|
|
48
|
+
assert.equal(cspStrictReportFromEnv({ OMCP_CSP_STRICT_REPORT: "true" }), true);
|
|
49
|
+
assert.equal(cspStrictReportFromEnv({ OMCP_CSP_STRICT_REPORT: "1" }), true);
|
|
50
|
+
assert.equal(cspStrictReportFromEnv({ OMCP_CSP_STRICT_REPORT: "no" }), false);
|
|
51
|
+
assert.equal(cspStrictReportFromEnv({ OMCP_CSP_STRICT_REPORT: "false" }), false);
|
|
52
|
+
});
|
|
53
|
+
test("summariseViolation parses the legacy csp-report envelope", () => {
|
|
54
|
+
const s = summariseViolation({
|
|
55
|
+
"csp-report": {
|
|
56
|
+
"effective-directive": "script-src-attr",
|
|
57
|
+
"blocked-uri": "inline",
|
|
58
|
+
"document-uri": "https://gw.example/",
|
|
59
|
+
"extra": "ignored",
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
assert.deepEqual(s, {
|
|
63
|
+
directive: "script-src-attr",
|
|
64
|
+
blockedUri: "inline",
|
|
65
|
+
documentUri: "https://gw.example/",
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
test("summariseViolation parses a modern Reporting-API array", () => {
|
|
69
|
+
const s = summariseViolation([
|
|
70
|
+
{
|
|
71
|
+
type: "csp-violation",
|
|
72
|
+
body: {
|
|
73
|
+
effectiveDirective: "script-src-elem",
|
|
74
|
+
blockedURL: "https://evil.example/x.js",
|
|
75
|
+
documentURL: "https://gw.example/",
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
]);
|
|
79
|
+
assert.equal(s?.directive, "script-src-elem");
|
|
80
|
+
assert.equal(s?.blockedUri, "https://evil.example/x.js");
|
|
81
|
+
});
|
|
82
|
+
test("summariseViolation falls back to violated-directive", () => {
|
|
83
|
+
const s = summariseViolation({ "csp-report": { "violated-directive": "img-src", "blocked-uri": "data" } });
|
|
84
|
+
assert.equal(s?.directive, "img-src");
|
|
85
|
+
});
|
|
86
|
+
test("summariseViolation returns null for junk", () => {
|
|
87
|
+
assert.equal(summariseViolation(null), null);
|
|
88
|
+
assert.equal(summariseViolation("nope"), null);
|
|
89
|
+
assert.equal(summariseViolation({}), null);
|
|
90
|
+
assert.equal(summariseViolation({ random: "field" }), null);
|
|
91
|
+
});
|
|
92
|
+
test("summariseViolation truncates over-long fields", () => {
|
|
93
|
+
const long = "a".repeat(5000);
|
|
94
|
+
const s = summariseViolation({ "csp-report": { "blocked-uri": long } });
|
|
95
|
+
assert.ok(s);
|
|
96
|
+
assert.ok((s.blockedUri).length <= 256);
|
|
97
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
// Regression guard for issue #415: the query_logs handler (query-logs.ts)
|
|
7
|
+
// reads `labels` and `aggregate` from its args, and validateLogLabels /
|
|
8
|
+
// validateLogAggregate enforce them — but the MCP-facing input schema is
|
|
9
|
+
// declared INLINE in createMcpServer's registerTool("query_logs", …) block
|
|
10
|
+
// in index.ts. In v3.1.0 that inline schema was never updated to advertise
|
|
11
|
+
// `labels`/`aggregate`, so the MCP SDK stripped those keys before they
|
|
12
|
+
// reached the handler: the params were unreachable over MCP and passing
|
|
13
|
+
// them was a silent no-op. The handler unit tests passed because they call
|
|
14
|
+
// the handler directly, bypassing the SDK schema layer.
|
|
15
|
+
//
|
|
16
|
+
// This test parses index.ts and asserts the query_logs registration block
|
|
17
|
+
// declares both fields as schema entries, so the SDK validates and forwards
|
|
18
|
+
// them. The live equivalent (real tools/list handshake) lives in the
|
|
19
|
+
// conformance suite; this is the fast, server-less guard.
|
|
20
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const INDEX_TS = join(here, "..", "index.ts");
|
|
22
|
+
function registerToolBlock(src, tool) {
|
|
23
|
+
const start = src.indexOf(`registerTool(\n "${tool}"`);
|
|
24
|
+
assert.notEqual(start, -1, `registerTool("${tool}", …) not found in index.ts`);
|
|
25
|
+
// The block ends at the next registerTool( call (or EOF).
|
|
26
|
+
const next = src.indexOf("registerTool(", start + 1);
|
|
27
|
+
return src.slice(start, next === -1 ? undefined : next);
|
|
28
|
+
}
|
|
29
|
+
test("query_logs MCP schema advertises `labels` (issue #415 #1)", () => {
|
|
30
|
+
const block = registerToolBlock(readFileSync(INDEX_TS, "utf8"), "query_logs");
|
|
31
|
+
assert.match(block, /\blabels:\s*z\b/, "query_logs registration in index.ts must declare a `labels` schema field " +
|
|
32
|
+
"so the MCP SDK forwards it to the handler (else it is silently stripped).");
|
|
33
|
+
});
|
|
34
|
+
test("query_logs MCP schema advertises `aggregate` (issue #415 #2)", () => {
|
|
35
|
+
const block = registerToolBlock(readFileSync(INDEX_TS, "utf8"), "query_logs");
|
|
36
|
+
assert.match(block, /\baggregate:\s*z\b/, "query_logs registration in index.ts must declare an `aggregate` schema field " +
|
|
37
|
+
"so the MCP SDK forwards it to the handler (else it is silently stripped).");
|
|
38
|
+
});
|
|
@@ -22,10 +22,43 @@ export declare const queryLogsDefinition: {
|
|
|
22
22
|
type: string;
|
|
23
23
|
description: string;
|
|
24
24
|
};
|
|
25
|
+
labels: {
|
|
26
|
+
type: string;
|
|
27
|
+
additionalProperties: {
|
|
28
|
+
type: string;
|
|
29
|
+
};
|
|
30
|
+
description: string;
|
|
31
|
+
};
|
|
25
32
|
limit: {
|
|
26
33
|
type: string;
|
|
27
34
|
description: string;
|
|
28
35
|
};
|
|
36
|
+
aggregate: {
|
|
37
|
+
type: string;
|
|
38
|
+
description: string;
|
|
39
|
+
properties: {
|
|
40
|
+
op: {
|
|
41
|
+
type: string;
|
|
42
|
+
enum: string[];
|
|
43
|
+
};
|
|
44
|
+
by: {
|
|
45
|
+
type: string;
|
|
46
|
+
items: {
|
|
47
|
+
type: string;
|
|
48
|
+
};
|
|
49
|
+
description: string;
|
|
50
|
+
};
|
|
51
|
+
k: {
|
|
52
|
+
type: string;
|
|
53
|
+
description: string;
|
|
54
|
+
};
|
|
55
|
+
step: {
|
|
56
|
+
type: string;
|
|
57
|
+
description: string;
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
required: string[];
|
|
61
|
+
};
|
|
29
62
|
};
|
|
30
63
|
required: string[];
|
|
31
64
|
};
|
|
@@ -36,6 +69,13 @@ export declare function queryLogsHandler(registry: ConnectorRegistry, args: {
|
|
|
36
69
|
duration?: string;
|
|
37
70
|
level?: string;
|
|
38
71
|
limit?: number;
|
|
72
|
+
labels?: Record<string, string>;
|
|
73
|
+
aggregate?: {
|
|
74
|
+
op: "count_over_time" | "sum" | "topk";
|
|
75
|
+
by?: string[];
|
|
76
|
+
k?: number;
|
|
77
|
+
step?: string;
|
|
78
|
+
};
|
|
39
79
|
}, ctx?: RequestContext): Promise<{
|
|
40
80
|
content: {
|
|
41
81
|
type: "text";
|
package/dist/tools/query-logs.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { defaultContext } from "../context.js";
|
|
2
|
-
import { validateDuration, validateServiceName, errorResponse } from "./validation.js";
|
|
2
|
+
import { validateDuration, validateServiceName, validateLogLabels, validateLogAggregate, errorResponse } from "./validation.js";
|
|
3
3
|
export const queryLogsDefinition = {
|
|
4
4
|
name: "query_logs",
|
|
5
|
-
description: "Query logs for a service over a given timeframe. Returns log entries with a summary including error/warning counts and top error patterns.
|
|
5
|
+
description: "Query logs for a service over a given timeframe. Returns log entries with a summary including error/warning counts and top error patterns. Filter by log level, a free-text/regex search, OR structured `labels` (exact-match on backend-extracted fields like method/status/url/environment — far more reliable than regex on structured JSON logs).",
|
|
6
6
|
inputSchema: {
|
|
7
7
|
type: "object",
|
|
8
8
|
properties: {
|
|
@@ -22,9 +22,25 @@ export const queryLogsDefinition = {
|
|
|
22
22
|
type: "string",
|
|
23
23
|
description: "Filter by log level: 'error', 'warn', 'info', 'debug'",
|
|
24
24
|
},
|
|
25
|
+
labels: {
|
|
26
|
+
type: "object",
|
|
27
|
+
additionalProperties: { type: "string" },
|
|
28
|
+
description: "Structured equality filters on backend-extracted fields, AND'd together, e.g. {\"method\":\"GET\",\"url\":\"/\",\"status\":\"200\",\"environment\":\"prod\"}. Prefer this over `query` for structured JSON logs — the literal text rarely appears verbatim. Label names must be [a-zA-Z_][a-zA-Z0-9_]* (max 20).",
|
|
29
|
+
},
|
|
25
30
|
limit: {
|
|
26
31
|
type: "number",
|
|
27
|
-
description: "Maximum number of log entries to return. Default: 100",
|
|
32
|
+
description: "Maximum number of log entries to return. Default: 100. Ignored when `aggregate` is set.",
|
|
33
|
+
},
|
|
34
|
+
aggregate: {
|
|
35
|
+
type: "object",
|
|
36
|
+
description: "Server-side aggregation — returns grouped counts, not raw rows, so you get a number instead of a haystack. op: 'count_over_time' (time series of counts per bucket), 'sum' (total per group over the window), 'topk' (top-k groups by total). Example: {\"op\":\"topk\",\"by\":[\"url\"],\"k\":10} for the busiest paths. Honours `labels`/`query` filters.",
|
|
37
|
+
properties: {
|
|
38
|
+
op: { type: "string", enum: ["count_over_time", "sum", "topk"] },
|
|
39
|
+
by: { type: "array", items: { type: "string" }, description: "Group-by label names (required for topk)." },
|
|
40
|
+
k: { type: "number", description: "Top-k count (default 10)." },
|
|
41
|
+
step: { type: "string", description: "Bucket size for count_over_time, e.g. '15m'. Defaults to ~1/60th of the window." },
|
|
42
|
+
},
|
|
43
|
+
required: ["op"],
|
|
28
44
|
},
|
|
29
45
|
},
|
|
30
46
|
required: ["service"],
|
|
@@ -38,6 +54,12 @@ export async function queryLogsHandler(registry, args, ctx = defaultContext()) {
|
|
|
38
54
|
const durationErr = validateDuration(duration);
|
|
39
55
|
if (durationErr)
|
|
40
56
|
return errorResponse(durationErr);
|
|
57
|
+
const labelsErr = validateLogLabels(args.labels);
|
|
58
|
+
if (labelsErr)
|
|
59
|
+
return errorResponse(labelsErr);
|
|
60
|
+
const aggErr = validateLogAggregate(args.aggregate);
|
|
61
|
+
if (aggErr)
|
|
62
|
+
return errorResponse(aggErr);
|
|
41
63
|
const connectors = registry.getByTenant(ctx.tenant).filter((c) => c.signalType === "logs");
|
|
42
64
|
if (connectors.length === 0) {
|
|
43
65
|
return {
|
|
@@ -47,6 +69,49 @@ export async function queryLogsHandler(registry, args, ctx = defaultContext()) {
|
|
|
47
69
|
isError: true,
|
|
48
70
|
};
|
|
49
71
|
}
|
|
72
|
+
// Aggregate mode (Q-LOG2): route to the connector's queryLogAggregate.
|
|
73
|
+
if (args.aggregate) {
|
|
74
|
+
const aggResults = [];
|
|
75
|
+
const aggErrors = [];
|
|
76
|
+
let capable = 0;
|
|
77
|
+
for (const connector of connectors) {
|
|
78
|
+
if (!connector.queryLogAggregate)
|
|
79
|
+
continue;
|
|
80
|
+
capable++;
|
|
81
|
+
try {
|
|
82
|
+
const q = {
|
|
83
|
+
service: args.service,
|
|
84
|
+
duration,
|
|
85
|
+
labels: args.labels,
|
|
86
|
+
query: args.query,
|
|
87
|
+
op: args.aggregate.op,
|
|
88
|
+
by: args.aggregate.by,
|
|
89
|
+
k: args.aggregate.k,
|
|
90
|
+
step: args.aggregate.step,
|
|
91
|
+
};
|
|
92
|
+
aggResults.push(await connector.queryLogAggregate(q));
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
96
|
+
console.error(`Log aggregate failed on ${connector.name}:`, msg);
|
|
97
|
+
aggErrors.push(`${connector.name}: ${msg}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (capable === 0) {
|
|
101
|
+
return errorResponse("No log backend supports aggregation (queryLogAggregate).");
|
|
102
|
+
}
|
|
103
|
+
if (aggResults.length === 0) {
|
|
104
|
+
return {
|
|
105
|
+
content: [{ type: "text", text: JSON.stringify({ error: aggErrors.length ? `Aggregate failed: ${aggErrors.join("; ")}` : "No data returned", service: args.service, duration }) }],
|
|
106
|
+
isError: aggErrors.length > 0,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
content: [
|
|
111
|
+
{ type: "text", text: JSON.stringify(aggResults.length === 1 ? aggResults[0] : aggResults, null, 2) },
|
|
112
|
+
],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
50
115
|
const results = [];
|
|
51
116
|
const errors = [];
|
|
52
117
|
for (const connector of connectors) {
|
|
@@ -59,6 +124,7 @@ export async function queryLogsHandler(registry, args, ctx = defaultContext()) {
|
|
|
59
124
|
duration,
|
|
60
125
|
level: args.level,
|
|
61
126
|
limit: args.limit,
|
|
127
|
+
labels: args.labels,
|
|
62
128
|
});
|
|
63
129
|
results.push(result);
|
|
64
130
|
}
|
|
@@ -8,6 +8,19 @@ export declare function validateMetricName(metric: string, registry: ConnectorRe
|
|
|
8
8
|
*/
|
|
9
9
|
export declare function sanitizeLabelValue(value: string): string | null;
|
|
10
10
|
export declare function validateServiceName(service: string): string | null;
|
|
11
|
+
/**
|
|
12
|
+
* Validate a structured `labels` filter map for query_logs. Fail-closed:
|
|
13
|
+
* any bad key/value rejects the whole request rather than silently
|
|
14
|
+
* dropping a filter (a dropped filter could widen results past what the
|
|
15
|
+
* caller intended). Bounds the map size + value length so a crafted input
|
|
16
|
+
* can't build a pathological query.
|
|
17
|
+
*/
|
|
18
|
+
export declare function validateLogLabels(labels: unknown): string | null;
|
|
19
|
+
/**
|
|
20
|
+
* Validate the query_logs `aggregate` spec. Fail-closed, like the labels
|
|
21
|
+
* validator. Returns an error string or null.
|
|
22
|
+
*/
|
|
23
|
+
export declare function validateLogAggregate(aggregate: unknown): string | null;
|
|
11
24
|
export declare function errorResponse(message: string): {
|
|
12
25
|
content: {
|
|
13
26
|
type: "text";
|
package/dist/tools/validation.js
CHANGED
|
@@ -41,6 +41,80 @@ export function validateServiceName(service) {
|
|
|
41
41
|
}
|
|
42
42
|
return null;
|
|
43
43
|
}
|
|
44
|
+
/** A Prometheus/Loki label name: letter/underscore, then word chars. */
|
|
45
|
+
const LABEL_NAME_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
46
|
+
/**
|
|
47
|
+
* Validate a structured `labels` filter map for query_logs. Fail-closed:
|
|
48
|
+
* any bad key/value rejects the whole request rather than silently
|
|
49
|
+
* dropping a filter (a dropped filter could widen results past what the
|
|
50
|
+
* caller intended). Bounds the map size + value length so a crafted input
|
|
51
|
+
* can't build a pathological query.
|
|
52
|
+
*/
|
|
53
|
+
export function validateLogLabels(labels) {
|
|
54
|
+
if (labels === undefined)
|
|
55
|
+
return null;
|
|
56
|
+
if (typeof labels !== "object" || labels === null || Array.isArray(labels)) {
|
|
57
|
+
return "Invalid labels: must be an object mapping label names to string values.";
|
|
58
|
+
}
|
|
59
|
+
const entries = Object.entries(labels);
|
|
60
|
+
if (entries.length > 20) {
|
|
61
|
+
return "Too many labels (max 20).";
|
|
62
|
+
}
|
|
63
|
+
for (const [k, v] of entries) {
|
|
64
|
+
if (!LABEL_NAME_RE.test(k)) {
|
|
65
|
+
return `Invalid label name "${k}". Must match [a-zA-Z_][a-zA-Z0-9_]* (no dots, dashes, or quotes).`;
|
|
66
|
+
}
|
|
67
|
+
if (typeof v !== "string") {
|
|
68
|
+
return `Invalid value for label "${k}": must be a string.`;
|
|
69
|
+
}
|
|
70
|
+
if (v.length > 1024) {
|
|
71
|
+
return `Value for label "${k}" too long (max 1024 chars).`;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const AGGREGATE_OPS = new Set(["count_over_time", "sum", "topk"]);
|
|
77
|
+
/**
|
|
78
|
+
* Validate the query_logs `aggregate` spec. Fail-closed, like the labels
|
|
79
|
+
* validator. Returns an error string or null.
|
|
80
|
+
*/
|
|
81
|
+
export function validateLogAggregate(aggregate) {
|
|
82
|
+
if (aggregate === undefined)
|
|
83
|
+
return null;
|
|
84
|
+
if (typeof aggregate !== "object" || aggregate === null || Array.isArray(aggregate)) {
|
|
85
|
+
return "Invalid aggregate: must be an object with an `op`.";
|
|
86
|
+
}
|
|
87
|
+
const a = aggregate;
|
|
88
|
+
if (typeof a.op !== "string" || !AGGREGATE_OPS.has(a.op)) {
|
|
89
|
+
return `Invalid aggregate.op. Must be one of: ${[...AGGREGATE_OPS].join(", ")}.`;
|
|
90
|
+
}
|
|
91
|
+
if (a.by !== undefined) {
|
|
92
|
+
if (!Array.isArray(a.by) || !a.by.every((x) => typeof x === "string")) {
|
|
93
|
+
return "aggregate.by must be an array of label-name strings.";
|
|
94
|
+
}
|
|
95
|
+
if (a.by.length > 10)
|
|
96
|
+
return "aggregate.by has too many labels (max 10).";
|
|
97
|
+
for (const name of a.by) {
|
|
98
|
+
if (!LABEL_NAME_RE.test(name)) {
|
|
99
|
+
return `Invalid aggregate.by label "${name}". Must match [a-zA-Z_][a-zA-Z0-9_]*.`;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (a.k !== undefined) {
|
|
104
|
+
if (typeof a.k !== "number" || !Number.isFinite(a.k) || a.k <= 0 || a.k > 1000) {
|
|
105
|
+
return "aggregate.k must be a positive integer (max 1000).";
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (a.step !== undefined) {
|
|
109
|
+
if (typeof a.step !== "string" || validateDuration(a.step)) {
|
|
110
|
+
return "aggregate.step must be a duration like '15m', '1h'.";
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (a.op === "topk" && (a.by === undefined || a.by.length === 0)) {
|
|
114
|
+
return "aggregate.op 'topk' requires at least one `by` label to rank.";
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
44
118
|
export function errorResponse(message) {
|
|
45
119
|
return {
|
|
46
120
|
content: [{ type: "text", text: JSON.stringify({ error: message }) }],
|
|
@@ -1,6 +1,59 @@
|
|
|
1
1
|
import { describe, it } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { validateDuration, validateServiceName, sanitizeLabelValue, errorResponse } from "./validation.js";
|
|
3
|
+
import { validateDuration, validateServiceName, sanitizeLabelValue, validateLogLabels, validateLogAggregate, errorResponse } from "./validation.js";
|
|
4
|
+
describe("validateLogAggregate (Q-LOG2)", () => {
|
|
5
|
+
it("accepts undefined and valid specs", () => {
|
|
6
|
+
assert.equal(validateLogAggregate(undefined), null);
|
|
7
|
+
assert.equal(validateLogAggregate({ op: "count_over_time" }), null);
|
|
8
|
+
assert.equal(validateLogAggregate({ op: "sum", by: ["url", "status"] }), null);
|
|
9
|
+
assert.equal(validateLogAggregate({ op: "topk", by: ["url"], k: 5, step: "15m" }), null);
|
|
10
|
+
});
|
|
11
|
+
it("rejects a bad/missing op", () => {
|
|
12
|
+
assert.ok(validateLogAggregate({}));
|
|
13
|
+
assert.ok(validateLogAggregate({ op: "median" }));
|
|
14
|
+
assert.ok(validateLogAggregate("nope"));
|
|
15
|
+
});
|
|
16
|
+
it("rejects bad by labels", () => {
|
|
17
|
+
assert.ok(validateLogAggregate({ op: "sum", by: ["a.b"] }));
|
|
18
|
+
assert.ok(validateLogAggregate({ op: "sum", by: "url" }));
|
|
19
|
+
});
|
|
20
|
+
it("rejects bad k and step", () => {
|
|
21
|
+
assert.ok(validateLogAggregate({ op: "topk", by: ["url"], k: 0 }));
|
|
22
|
+
assert.ok(validateLogAggregate({ op: "topk", by: ["url"], k: 99999 }));
|
|
23
|
+
assert.ok(validateLogAggregate({ op: "count_over_time", step: "soon" }));
|
|
24
|
+
});
|
|
25
|
+
it("requires a by label for topk", () => {
|
|
26
|
+
assert.ok(validateLogAggregate({ op: "topk" }));
|
|
27
|
+
assert.ok(validateLogAggregate({ op: "topk", by: [] }));
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
describe("validateLogLabels (Q-LOG1)", () => {
|
|
31
|
+
it("accepts undefined (no filter) and a valid map", () => {
|
|
32
|
+
assert.equal(validateLogLabels(undefined), null);
|
|
33
|
+
assert.equal(validateLogLabels({ method: "GET", status: "200", environment: "prod" }), null);
|
|
34
|
+
});
|
|
35
|
+
it("rejects non-object inputs", () => {
|
|
36
|
+
assert.ok(validateLogLabels("nope"));
|
|
37
|
+
assert.ok(validateLogLabels(["a"]));
|
|
38
|
+
assert.ok(validateLogLabels(42));
|
|
39
|
+
});
|
|
40
|
+
it("rejects label names with dots, dashes, or quotes (injection-safe, fail-closed)", () => {
|
|
41
|
+
assert.ok(validateLogLabels({ "a.b": "x" }));
|
|
42
|
+
assert.ok(validateLogLabels({ "a-b": "x" }));
|
|
43
|
+
assert.ok(validateLogLabels({ 'a"x': "y" }));
|
|
44
|
+
assert.ok(validateLogLabels({ "1abc": "x" })); // can't start with a digit
|
|
45
|
+
});
|
|
46
|
+
it("rejects non-string and over-long values", () => {
|
|
47
|
+
assert.ok(validateLogLabels({ method: 123 }));
|
|
48
|
+
assert.ok(validateLogLabels({ method: "x".repeat(1025) }));
|
|
49
|
+
});
|
|
50
|
+
it("rejects too many labels", () => {
|
|
51
|
+
const many = {};
|
|
52
|
+
for (let i = 0; i < 21; i++)
|
|
53
|
+
many[`k${i}`] = "v";
|
|
54
|
+
assert.ok(validateLogLabels(many));
|
|
55
|
+
});
|
|
56
|
+
});
|
|
4
57
|
describe("validateDuration", () => {
|
|
5
58
|
it("accepts valid durations", () => {
|
|
6
59
|
assert.equal(validateDuration("5m"), null);
|
package/dist/types.d.ts
CHANGED
|
@@ -109,6 +109,54 @@ export interface LogQuery {
|
|
|
109
109
|
duration: string;
|
|
110
110
|
limit?: number;
|
|
111
111
|
level?: string;
|
|
112
|
+
/** Structured label/field equality filters, AND'd together. For Loki
|
|
113
|
+
* these compile to LogQL label-filter expressions after `| json`, so
|
|
114
|
+
* fields the backend already extracts (method, status, url, ip,
|
|
115
|
+
* environment, …) become first-class selectors instead of brittle
|
|
116
|
+
* free-text regex. */
|
|
117
|
+
labels?: Record<string, string>;
|
|
118
|
+
}
|
|
119
|
+
/** Server-side log aggregation (Q-LOG2). Pushes count/group/topk down to
|
|
120
|
+
* the backend's metric-query path so an agent gets a *number*, not a
|
|
121
|
+
* *haystack*. */
|
|
122
|
+
export interface LogAggregateQuery {
|
|
123
|
+
service: string;
|
|
124
|
+
duration: string;
|
|
125
|
+
/** Same structured filters as LogQuery, applied before aggregation. */
|
|
126
|
+
labels?: Record<string, string>;
|
|
127
|
+
/** Optional line filter applied before aggregation. */
|
|
128
|
+
query?: string;
|
|
129
|
+
/** count_over_time → time series of counts per bucket; sum → total per
|
|
130
|
+
* group over the window; topk → the top-k groups by total. */
|
|
131
|
+
op: "count_over_time" | "sum" | "topk";
|
|
132
|
+
/** Group-by label names. */
|
|
133
|
+
by?: string[];
|
|
134
|
+
/** k for topk (default 10). */
|
|
135
|
+
k?: number;
|
|
136
|
+
/** Bucket size for count_over_time, e.g. "15m". Defaults to the window. */
|
|
137
|
+
step?: string;
|
|
138
|
+
}
|
|
139
|
+
export interface LogAggregateSeries {
|
|
140
|
+
/** The group key (the `by` label values). Empty object for an ungrouped total. */
|
|
141
|
+
labels: Record<string, string>;
|
|
142
|
+
/** Single value — present for instant ops (sum / topk). */
|
|
143
|
+
value?: number;
|
|
144
|
+
/** Time series — present for count_over_time. */
|
|
145
|
+
points?: Array<{
|
|
146
|
+
t: number;
|
|
147
|
+
value: number;
|
|
148
|
+
}>;
|
|
149
|
+
}
|
|
150
|
+
export interface LogAggregateResult {
|
|
151
|
+
source: string;
|
|
152
|
+
op: string;
|
|
153
|
+
by: string[];
|
|
154
|
+
step?: string;
|
|
155
|
+
/** "instant" (vector) for sum/topk, "range" (matrix) for count_over_time. */
|
|
156
|
+
mode: "instant" | "range";
|
|
157
|
+
series: LogAggregateSeries[];
|
|
158
|
+
/** Operator-facing notes, e.g. that `limit` is ignored in aggregate mode. */
|
|
159
|
+
note?: string;
|
|
112
160
|
}
|
|
113
161
|
export interface DataPoint {
|
|
114
162
|
timestamp: string;
|