@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.
- 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/audit/sinks/s3.d.ts +61 -0
- package/dist/audit/sinks/s3.js +179 -0
- package/dist/audit/sinks/s3.test.d.ts +1 -0
- package/dist/audit/sinks/s3.test.js +175 -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/policy/batch-dry-run.js +15 -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/connectors/interface.d.ts +5 -1
- package/dist/connectors/loader.d.ts +8 -0
- package/dist/connectors/loader.js +49 -0
- 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/connectors/manifest-hooks.test.d.ts +1 -0
- package/dist/connectors/manifest-hooks.test.js +206 -0
- package/dist/federation/registry.d.ts +27 -5
- package/dist/federation/registry.js +49 -4
- package/dist/federation/registry.test.js +79 -3
- package/dist/federation/upstream.d.ts +32 -6
- package/dist/federation/upstream.js +60 -12
- package/dist/federation/upstream.test.d.ts +1 -0
- package/dist/federation/upstream.test.js +118 -0
- package/dist/index.js +522 -67
- package/dist/metrics/self.d.ts +1 -0
- package/dist/metrics/self.js +8 -0
- package/dist/openapi.js +39 -0
- package/dist/openapi.test.js +1 -0
- package/dist/policy/redact.js +1 -1
- package/dist/postmortem/store.d.ts +34 -0
- package/dist/postmortem/store.js +113 -0
- package/dist/postmortem/store.test.d.ts +1 -0
- package/dist/postmortem/store.test.js +118 -0
- package/dist/scim/compliance.test.d.ts +1 -0
- package/dist/scim/compliance.test.js +169 -0
- package/dist/scim/factory.test.d.ts +1 -0
- package/dist/scim/factory.test.js +54 -0
- package/dist/scim/patch-ops.test.d.ts +1 -0
- package/dist/scim/patch-ops.test.js +100 -0
- package/dist/scim/redis-store.d.ts +38 -0
- package/dist/scim/redis-store.js +178 -0
- package/dist/scim/redis-store.test.d.ts +1 -0
- package/dist/scim/redis-store.test.js +138 -0
- package/dist/scim/routes.d.ts +27 -2
- package/dist/scim/routes.js +161 -15
- package/dist/scim/store.d.ts +40 -1
- package/dist/scim/store.js +23 -5
- package/dist/sdk/hook-wrappers.d.ts +39 -0
- package/dist/sdk/hook-wrappers.js +113 -0
- package/dist/sdk/hook-wrappers.test.d.ts +1 -0
- package/dist/sdk/hook-wrappers.test.js +204 -0
- package/dist/sdk/index.d.ts +13 -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/detect-anomalies.d.ts +12 -1
- package/dist/tools/detect-anomalies.js +22 -2
- package/dist/tools/query-logs.d.ts +40 -0
- package/dist/tools/query-logs.js +69 -3
- package/dist/tools/topology.js +23 -5
- package/dist/tools/topology.test.js +45 -0
- 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/transport/transportSessionMap.d.ts +70 -0
- package/dist/transport/transportSessionMap.js +128 -0
- package/dist/transport/transportSessionMap.test.d.ts +1 -0
- package/dist/transport/transportSessionMap.test.js +111 -0
- package/dist/types.d.ts +48 -0
- package/dist/ui/index.html +898 -116
- package/package.json +1 -1
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);
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { SessionStore } from "./sessionStore.js";
|
|
2
|
+
export interface TransportSessionMeta {
|
|
3
|
+
/** Stable id of the replica that owns the underlying SDK
|
|
4
|
+
* Transport object. Set on creation. */
|
|
5
|
+
ownerReplica: string;
|
|
6
|
+
/** Optional virtual-server product slug. Undefined for the
|
|
7
|
+
* root /mcp surface. */
|
|
8
|
+
product?: string;
|
|
9
|
+
/** Epoch ms — bumped on every successful request. */
|
|
10
|
+
lastActive: number;
|
|
11
|
+
}
|
|
12
|
+
export interface TransportSessionMap {
|
|
13
|
+
/** Stable backend identifier (used in /api/info diagnostics). */
|
|
14
|
+
readonly backend: string;
|
|
15
|
+
/** True iff there's a metadata entry — does NOT imply a local
|
|
16
|
+
* Transport exists. */
|
|
17
|
+
has(sessionId: string): Promise<boolean>;
|
|
18
|
+
get(sessionId: string): Promise<TransportSessionMeta | undefined>;
|
|
19
|
+
set(sessionId: string, meta: TransportSessionMeta, ttlSeconds?: number): Promise<void>;
|
|
20
|
+
/** Convenience: bump lastActive while preserving the rest. */
|
|
21
|
+
touch(sessionId: string, ttlSeconds?: number): Promise<void>;
|
|
22
|
+
delete(sessionId: string): Promise<void>;
|
|
23
|
+
/** Return every session id this map knows about. Used for the
|
|
24
|
+
* cleanup tick. Implementations MAY cap; in-memory returns the
|
|
25
|
+
* full set, Redis pages via SCAN under the prefix. */
|
|
26
|
+
keys(): Promise<string[]>;
|
|
27
|
+
/** Evict entries with lastActive older than `maxIdleMs`. Returns
|
|
28
|
+
* the evicted ids (caller logs / records metrics). */
|
|
29
|
+
cleanup(maxIdleMs: number): Promise<string[]>;
|
|
30
|
+
}
|
|
31
|
+
export declare class InMemoryTransportSessionMap implements TransportSessionMap {
|
|
32
|
+
readonly backend = "memory";
|
|
33
|
+
private readonly map;
|
|
34
|
+
has(id: string): Promise<boolean>;
|
|
35
|
+
get(id: string): Promise<TransportSessionMeta | undefined>;
|
|
36
|
+
set(id: string, meta: TransportSessionMeta): Promise<void>;
|
|
37
|
+
touch(id: string): Promise<void>;
|
|
38
|
+
delete(id: string): Promise<void>;
|
|
39
|
+
keys(): Promise<string[]>;
|
|
40
|
+
cleanup(maxIdleMs: number): Promise<string[]>;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Wraps an existing SessionStore so the per-session metadata lives
|
|
44
|
+
* wherever the SessionStore decided — InMemorySessionStore (no
|
|
45
|
+
* cross-replica visibility, identical to InMemoryTransportSessionMap
|
|
46
|
+
* but useful for tests / when a future backend is plugged in) or
|
|
47
|
+
* RedisSessionStore (cross-replica safe).
|
|
48
|
+
*
|
|
49
|
+
* Each entry stored at `<KEY_PREFIX><sessionId>` as JSON.
|
|
50
|
+
*/
|
|
51
|
+
export declare class SessionStoreBackedTransportSessionMap implements TransportSessionMap {
|
|
52
|
+
readonly backend: string;
|
|
53
|
+
private readonly store;
|
|
54
|
+
private readonly defaultTtlSeconds;
|
|
55
|
+
constructor(store: SessionStore, defaultTtlSeconds?: number);
|
|
56
|
+
has(id: string): Promise<boolean>;
|
|
57
|
+
get(id: string): Promise<TransportSessionMeta | undefined>;
|
|
58
|
+
set(id: string, meta: TransportSessionMeta, ttlSeconds?: number): Promise<void>;
|
|
59
|
+
touch(id: string, ttlSeconds?: number): Promise<void>;
|
|
60
|
+
delete(id: string): Promise<void>;
|
|
61
|
+
keys(): Promise<string[]>;
|
|
62
|
+
cleanup(maxIdleMs: number): Promise<string[]>;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Pick the right implementation. When `sessionStore` is the
|
|
66
|
+
* in-memory default, return InMemoryTransportSessionMap so we
|
|
67
|
+
* avoid the (synchronous → async) layering tax. Otherwise wrap
|
|
68
|
+
* the supplied store.
|
|
69
|
+
*/
|
|
70
|
+
export declare function createTransportSessionMap(sessionStore?: SessionStore): TransportSessionMap;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Multi-replica-safe MCP transport session metadata.
|
|
2
|
+
//
|
|
3
|
+
// The actual SDK `StreamableHTTPServerTransport` object MUST live in
|
|
4
|
+
// the replica that originated the session — it owns the open HTTP
|
|
5
|
+
// response handles. What CAN be shared across replicas is the
|
|
6
|
+
// per-session metadata (last-active timestamp, virtual-server
|
|
7
|
+
// product slug, owner-replica id). This module promotes that map
|
|
8
|
+
// from a process-local `Map` to a small `TransportSessionMap` KV
|
|
9
|
+
// surface backed by either in-memory state (default) or the existing
|
|
10
|
+
// SessionStore Redis backend (opt-in via OMCP_REDIS_URL).
|
|
11
|
+
//
|
|
12
|
+
// Multi-replica behaviour:
|
|
13
|
+
// - Replica A creates session S. Writes metadata {ownerReplica:A,
|
|
14
|
+
// lastActive, product} to the shared map.
|
|
15
|
+
// - Subsequent request lands on Replica B with the same S header.
|
|
16
|
+
// B consults the shared map, sees ownerReplica:A, replies 410
|
|
17
|
+
// so the load balancer rehashes or the client retries.
|
|
18
|
+
// - This keeps the gateway functional even when sticky ingress
|
|
19
|
+
// drops the affinity — the worst case is a single 410 retry,
|
|
20
|
+
// not a silent NEW transport (which today races against the
|
|
21
|
+
// real one).
|
|
22
|
+
//
|
|
23
|
+
// The TTL on each entry mirrors SESSION_TTL_MS so the map doesn't
|
|
24
|
+
// grow unbounded if a replica disappears without graceful
|
|
25
|
+
// shutdown — TTL-based eviction is the safety net.
|
|
26
|
+
// ---------------------------------------------------------------- in-memory
|
|
27
|
+
export class InMemoryTransportSessionMap {
|
|
28
|
+
backend = "memory";
|
|
29
|
+
map = new Map();
|
|
30
|
+
async has(id) { return this.map.has(id); }
|
|
31
|
+
async get(id) {
|
|
32
|
+
return this.map.get(id);
|
|
33
|
+
}
|
|
34
|
+
async set(id, meta) {
|
|
35
|
+
this.map.set(id, meta);
|
|
36
|
+
}
|
|
37
|
+
async touch(id) {
|
|
38
|
+
const cur = this.map.get(id);
|
|
39
|
+
if (!cur)
|
|
40
|
+
return;
|
|
41
|
+
cur.lastActive = Date.now();
|
|
42
|
+
}
|
|
43
|
+
async delete(id) { this.map.delete(id); }
|
|
44
|
+
async keys() { return [...this.map.keys()]; }
|
|
45
|
+
async cleanup(maxIdleMs) {
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
const evicted = [];
|
|
48
|
+
for (const [id, meta] of this.map) {
|
|
49
|
+
if (now - meta.lastActive > maxIdleMs) {
|
|
50
|
+
this.map.delete(id);
|
|
51
|
+
evicted.push(id);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return evicted;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// ---------------------------------------------------------------- SessionStore-backed
|
|
58
|
+
const KEY_PREFIX = "transport:";
|
|
59
|
+
/**
|
|
60
|
+
* Wraps an existing SessionStore so the per-session metadata lives
|
|
61
|
+
* wherever the SessionStore decided — InMemorySessionStore (no
|
|
62
|
+
* cross-replica visibility, identical to InMemoryTransportSessionMap
|
|
63
|
+
* but useful for tests / when a future backend is plugged in) or
|
|
64
|
+
* RedisSessionStore (cross-replica safe).
|
|
65
|
+
*
|
|
66
|
+
* Each entry stored at `<KEY_PREFIX><sessionId>` as JSON.
|
|
67
|
+
*/
|
|
68
|
+
export class SessionStoreBackedTransportSessionMap {
|
|
69
|
+
backend;
|
|
70
|
+
store;
|
|
71
|
+
defaultTtlSeconds;
|
|
72
|
+
constructor(store, defaultTtlSeconds = 30 * 60) {
|
|
73
|
+
this.store = store;
|
|
74
|
+
this.backend = `session-store:${store.backend}`;
|
|
75
|
+
this.defaultTtlSeconds = defaultTtlSeconds;
|
|
76
|
+
}
|
|
77
|
+
async has(id) {
|
|
78
|
+
return (await this.store.get(KEY_PREFIX + id)) !== undefined;
|
|
79
|
+
}
|
|
80
|
+
async get(id) {
|
|
81
|
+
return (await this.store.get(KEY_PREFIX + id)) ?? undefined;
|
|
82
|
+
}
|
|
83
|
+
async set(id, meta, ttlSeconds) {
|
|
84
|
+
await this.store.setEx(KEY_PREFIX + id, ttlSeconds ?? this.defaultTtlSeconds, meta);
|
|
85
|
+
}
|
|
86
|
+
async touch(id, ttlSeconds) {
|
|
87
|
+
const cur = await this.get(id);
|
|
88
|
+
if (!cur)
|
|
89
|
+
return;
|
|
90
|
+
cur.lastActive = Date.now();
|
|
91
|
+
await this.set(id, cur, ttlSeconds);
|
|
92
|
+
}
|
|
93
|
+
async delete(id) {
|
|
94
|
+
await this.store.del(KEY_PREFIX + id);
|
|
95
|
+
}
|
|
96
|
+
async keys() {
|
|
97
|
+
const raw = await this.store.keys(KEY_PREFIX);
|
|
98
|
+
return raw.map((k) => k.slice(KEY_PREFIX.length));
|
|
99
|
+
}
|
|
100
|
+
async cleanup(maxIdleMs) {
|
|
101
|
+
const now = Date.now();
|
|
102
|
+
const ids = await this.keys();
|
|
103
|
+
const evicted = [];
|
|
104
|
+
for (const id of ids) {
|
|
105
|
+
const meta = await this.get(id);
|
|
106
|
+
if (!meta)
|
|
107
|
+
continue;
|
|
108
|
+
if (now - meta.lastActive > maxIdleMs) {
|
|
109
|
+
await this.delete(id);
|
|
110
|
+
evicted.push(id);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return evicted;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// ---------------------------------------------------------------- factory
|
|
117
|
+
/**
|
|
118
|
+
* Pick the right implementation. When `sessionStore` is the
|
|
119
|
+
* in-memory default, return InMemoryTransportSessionMap so we
|
|
120
|
+
* avoid the (synchronous → async) layering tax. Otherwise wrap
|
|
121
|
+
* the supplied store.
|
|
122
|
+
*/
|
|
123
|
+
export function createTransportSessionMap(sessionStore) {
|
|
124
|
+
if (!sessionStore || sessionStore.backend === "memory") {
|
|
125
|
+
return new InMemoryTransportSessionMap();
|
|
126
|
+
}
|
|
127
|
+
return new SessionStoreBackedTransportSessionMap(sessionStore);
|
|
128
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { InMemoryTransportSessionMap, SessionStoreBackedTransportSessionMap, createTransportSessionMap, } from "./transportSessionMap.js";
|
|
4
|
+
import { InMemorySessionStore } from "./sessionStore.js";
|
|
5
|
+
function meta(p = {}) {
|
|
6
|
+
return {
|
|
7
|
+
ownerReplica: p.ownerReplica ?? "replica-A",
|
|
8
|
+
product: p.product,
|
|
9
|
+
lastActive: p.lastActive ?? Date.now(),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
test("InMemoryTransportSessionMap: get/set/has/delete round-trip", async () => {
|
|
13
|
+
const m = new InMemoryTransportSessionMap();
|
|
14
|
+
assert.equal(await m.has("s1"), false);
|
|
15
|
+
await m.set("s1", meta({ product: "checkout" }));
|
|
16
|
+
assert.equal(await m.has("s1"), true);
|
|
17
|
+
const got = await m.get("s1");
|
|
18
|
+
assert.equal(got?.ownerReplica, "replica-A");
|
|
19
|
+
assert.equal(got?.product, "checkout");
|
|
20
|
+
await m.delete("s1");
|
|
21
|
+
assert.equal(await m.has("s1"), false);
|
|
22
|
+
});
|
|
23
|
+
test("InMemoryTransportSessionMap: touch bumps lastActive", async () => {
|
|
24
|
+
const m = new InMemoryTransportSessionMap();
|
|
25
|
+
const before = Date.now() - 10_000;
|
|
26
|
+
await m.set("s1", meta({ lastActive: before }));
|
|
27
|
+
await new Promise((r) => setTimeout(r, 2));
|
|
28
|
+
await m.touch("s1");
|
|
29
|
+
const got = await m.get("s1");
|
|
30
|
+
assert.ok((got?.lastActive ?? 0) > before);
|
|
31
|
+
});
|
|
32
|
+
test("InMemoryTransportSessionMap: keys + cleanup", async () => {
|
|
33
|
+
const m = new InMemoryTransportSessionMap();
|
|
34
|
+
await m.set("fresh", meta({ lastActive: Date.now() }));
|
|
35
|
+
await m.set("stale", meta({ lastActive: Date.now() - 10_000 }));
|
|
36
|
+
const all = await m.keys();
|
|
37
|
+
assert.equal(all.length, 2);
|
|
38
|
+
const evicted = await m.cleanup(5_000);
|
|
39
|
+
assert.deepEqual(evicted, ["stale"]);
|
|
40
|
+
assert.equal(await m.has("stale"), false);
|
|
41
|
+
assert.equal(await m.has("fresh"), true);
|
|
42
|
+
});
|
|
43
|
+
test("SessionStoreBackedTransportSessionMap: round-trip via SessionStore", async () => {
|
|
44
|
+
const store = new InMemorySessionStore();
|
|
45
|
+
const m = new SessionStoreBackedTransportSessionMap(store);
|
|
46
|
+
await m.set("s1", meta({ product: "p" }));
|
|
47
|
+
assert.equal(await m.has("s1"), true);
|
|
48
|
+
const got = await m.get("s1");
|
|
49
|
+
assert.equal(got?.product, "p");
|
|
50
|
+
await m.delete("s1");
|
|
51
|
+
assert.equal(await m.has("s1"), false);
|
|
52
|
+
});
|
|
53
|
+
test("SessionStoreBackedTransportSessionMap: keys excludes the prefix in returned ids", async () => {
|
|
54
|
+
const store = new InMemorySessionStore();
|
|
55
|
+
const m = new SessionStoreBackedTransportSessionMap(store);
|
|
56
|
+
await m.set("alpha", meta());
|
|
57
|
+
await m.set("beta", meta());
|
|
58
|
+
const ids = (await m.keys()).sort();
|
|
59
|
+
assert.deepEqual(ids, ["alpha", "beta"]);
|
|
60
|
+
// Unrelated keys on the same SessionStore must not pollute the map.
|
|
61
|
+
await store.set("scim:user:1", { foo: "bar" });
|
|
62
|
+
const idsAfter = (await m.keys()).sort();
|
|
63
|
+
assert.deepEqual(idsAfter, ["alpha", "beta"]);
|
|
64
|
+
});
|
|
65
|
+
test("SessionStoreBackedTransportSessionMap: cleanup evicts stale entries", async () => {
|
|
66
|
+
const store = new InMemorySessionStore();
|
|
67
|
+
const m = new SessionStoreBackedTransportSessionMap(store);
|
|
68
|
+
await m.set("fresh", meta({ lastActive: Date.now() }));
|
|
69
|
+
await m.set("stale", meta({ lastActive: Date.now() - 60_000 }));
|
|
70
|
+
const evicted = await m.cleanup(30_000);
|
|
71
|
+
assert.deepEqual(evicted, ["stale"]);
|
|
72
|
+
});
|
|
73
|
+
test("SessionStoreBackedTransportSessionMap: touch is no-op on missing id", async () => {
|
|
74
|
+
const store = new InMemorySessionStore();
|
|
75
|
+
const m = new SessionStoreBackedTransportSessionMap(store);
|
|
76
|
+
await m.touch("ghost");
|
|
77
|
+
assert.equal(await m.has("ghost"), false);
|
|
78
|
+
});
|
|
79
|
+
test("SessionStoreBackedTransportSessionMap: backend tag exposes underlying store name", () => {
|
|
80
|
+
const store = new InMemorySessionStore();
|
|
81
|
+
const m = new SessionStoreBackedTransportSessionMap(store);
|
|
82
|
+
assert.equal(m.backend, "session-store:memory");
|
|
83
|
+
});
|
|
84
|
+
test("createTransportSessionMap: memory backend returns in-memory impl", () => {
|
|
85
|
+
const store = new InMemorySessionStore();
|
|
86
|
+
const m = createTransportSessionMap(store);
|
|
87
|
+
assert.equal(m.backend, "memory");
|
|
88
|
+
});
|
|
89
|
+
test("createTransportSessionMap: non-memory backend returns wrapper", () => {
|
|
90
|
+
// Fake a non-memory backend by stubbing the backend tag.
|
|
91
|
+
const fake = {
|
|
92
|
+
backend: "redis",
|
|
93
|
+
async get() { return undefined; },
|
|
94
|
+
async set() { },
|
|
95
|
+
async setEx() { },
|
|
96
|
+
async del() { },
|
|
97
|
+
async keys() { return []; },
|
|
98
|
+
async close() { },
|
|
99
|
+
};
|
|
100
|
+
const m = createTransportSessionMap(fake);
|
|
101
|
+
assert.equal(m.backend, "session-store:redis");
|
|
102
|
+
});
|
|
103
|
+
test("createTransportSessionMap: undefined sessionStore → memory impl (back-compat)", () => {
|
|
104
|
+
const m = createTransportSessionMap();
|
|
105
|
+
assert.equal(m.backend, "memory");
|
|
106
|
+
});
|
|
107
|
+
test("InMemoryTransportSessionMap.touch: ghost id is no-op", async () => {
|
|
108
|
+
const m = new InMemoryTransportSessionMap();
|
|
109
|
+
await m.touch("ghost");
|
|
110
|
+
assert.equal(await m.has("ghost"), false);
|
|
111
|
+
});
|
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;
|