trazio-telemetry 0.0.0-stage → 0.3.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/README.md +78 -2
- package/package.json +57 -4
- package/src/cli.js +18 -0
- package/src/index.d.ts +167 -0
- package/src/index.js +607 -0
- package/src/integrations.d.ts +9 -0
- package/src/integrations.js +20 -0
- package/src/semantic-query.js +144 -0
package/README.md
CHANGED
|
@@ -1,3 +1,79 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Trazio Telemetry SDK for Node.js
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Version 0.3.0 renames the package from `hyperlake-telemetry`; the API is unchanged.
|
|
4
|
+
See [SDK integration and release status](https://github.com/cerebrixos-org/sovereign-telemetry-platform/blob/main/sovereign-telemetry-platform/sdk/README.md).
|
|
5
|
+
|
|
6
|
+
`trazio-telemetry` sends OTLP/HTTP JSON, attaches application events and
|
|
7
|
+
immutable artifacts, manages tenant-editable pipeline settings, and performs
|
|
8
|
+
scoped event, artifact, video, Run, and evidence-graph investigations. Existing OpenTelemetry users
|
|
9
|
+
should keep their native instrumentation and use this SDK only where run
|
|
10
|
+
context, stable event identity, evaluations, or artifact attachment is needed.
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install trazio-telemetry
|
|
14
|
+
npx trazio-telemetry doctor
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
import { Client, Privacy } from "trazio-telemetry";
|
|
19
|
+
|
|
20
|
+
const client = new Client({
|
|
21
|
+
baseUrl: "https://us.hyperlake.cloud",
|
|
22
|
+
protocolToken: process.env.PLATFORM_PROTOCOL_TOKEN,
|
|
23
|
+
tenantJwt: process.env.TENANT_JWT,
|
|
24
|
+
privacy: new Privacy({ captureContent: false }),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const run = client.startRun("support-agent", { goalId: "goal-42" });
|
|
28
|
+
await run.withSpan("lookup-account", { kind: "RETRIEVER" }, async () => {});
|
|
29
|
+
run.evaluate("groundedness", { score: 0.97, label: "pass" });
|
|
30
|
+
run.end();
|
|
31
|
+
await client.flush();
|
|
32
|
+
|
|
33
|
+
const investigation = await client.semanticQuery(
|
|
34
|
+
"Show critical robot video and related faults from the last 2 hours",
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const detection = await client.analyzeLiveFrame("./frame.jpg", {
|
|
38
|
+
streamSessionId: "camera-session-17",
|
|
39
|
+
frameId: "frame-42",
|
|
40
|
+
deviceId: "robot-17",
|
|
41
|
+
});
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`analyzeLiveFrame()` uses the immutable model profile pinned to the JWT's
|
|
45
|
+
pipeline. It requires a short-lived JWT with `live:analyze` and `event:ingest`.
|
|
46
|
+
The response is a factual object-detection result; the attached live Rule Packs
|
|
47
|
+
determine whether it becomes an alert or notification.
|
|
48
|
+
|
|
49
|
+
The SDK derives tenant and pipeline routing from the JWT claims and rejects
|
|
50
|
+
conflicting routing IDs before sending a request. Decoding claims locally is not
|
|
51
|
+
signature verification: the regional service verifies the JWT signature,
|
|
52
|
+
issuer, audience, expiry, permissions, tenant, and pipeline on every request.
|
|
53
|
+
Content capture is disabled by default.
|
|
54
|
+
|
|
55
|
+
## Reliable semantic queries
|
|
56
|
+
|
|
57
|
+
`semanticQuery()` first creates a versioned, bounded query plan. The plan can
|
|
58
|
+
target events, artifact/vector search, and the evidence graph, but it cannot set
|
|
59
|
+
a tenant, pipeline, URL, token, SQL fragment, or authorization header. The SDK
|
|
60
|
+
then executes each target against the immutable JWT scope and returns the plan,
|
|
61
|
+
its SHA-256 hash, retrieval method, evidence, and scope provenance.
|
|
62
|
+
|
|
63
|
+
The built-in `DeterministicSemanticPlanner` recognizes bounded time ranges,
|
|
64
|
+
severity, common source families, media intent, and graph intent without an LLM.
|
|
65
|
+
For richer language, pass `ModelSemanticPlanner({ generateObject, model })`.
|
|
66
|
+
Model output is contract-validated; invalid or unsafe output falls back to the
|
|
67
|
+
deterministic plan. This makes a model an intent parser, never an authority or
|
|
68
|
+
direct query executor.
|
|
69
|
+
|
|
70
|
+
Artifact and video retrieval uses the platform's configured embedding model and
|
|
71
|
+
vector index. Event retrieval is currently structured plus lexical over a
|
|
72
|
+
bounded recent window. Graph retrieval uses recorded scoped edges. The response
|
|
73
|
+
keeps those signals separate so vector similarity is not represented as proof.
|
|
74
|
+
|
|
75
|
+
Other helpers include `listEvents`, `searchEvents`, `searchArtifacts`,
|
|
76
|
+
`correlationGraph`, `getArtifactSession`, `downloadArtifact`, `getTelemetry`,
|
|
77
|
+
`openVideo`, `timeline`, `listRuns`, `getRun`, `getTenantConfig`,
|
|
78
|
+
`updateTenantConfig`, protocol-token rotation, and tenant Rule Pack validation,
|
|
79
|
+
dry-run, and publication.
|
package/package.json
CHANGED
|
@@ -1,6 +1,59 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trazio-telemetry",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Scoped telemetry, artifact, investigation, and semantic-query SDK",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"types": "src/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./src/index.d.ts",
|
|
11
|
+
"import": "./src/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./integrations": {
|
|
14
|
+
"types": "./src/integrations.d.ts",
|
|
15
|
+
"import": "./src/integrations.js"
|
|
16
|
+
},
|
|
17
|
+
"./semantic-query": {
|
|
18
|
+
"types": "./src/index.d.ts",
|
|
19
|
+
"import": "./src/semantic-query.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"bin": {
|
|
23
|
+
"trazio-telemetry": "src/cli.js"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"src/",
|
|
27
|
+
"README.md"
|
|
28
|
+
],
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "node --test test/*.test.js",
|
|
31
|
+
"check": "node --check src/index.js && node --check src/integrations.js && node --check src/semantic-query.js && node --check src/cli.js",
|
|
32
|
+
"prepublishOnly": "npm run check && npm test"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=20"
|
|
36
|
+
},
|
|
37
|
+
"license": "Proprietary",
|
|
38
|
+
"author": "Trazio <vc@hyperlake.cloud>",
|
|
39
|
+
"homepage": "https://hyperlake.cloud",
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "git+https://github.com/cerebrixos-org/sovereign-telemetry-platform.git",
|
|
43
|
+
"directory": "sovereign-telemetry-platform/sdk/typescript"
|
|
44
|
+
},
|
|
45
|
+
"keywords": [
|
|
46
|
+
"opentelemetry",
|
|
47
|
+
"otlp",
|
|
48
|
+
"openinference",
|
|
49
|
+
"agents",
|
|
50
|
+
"temporal",
|
|
51
|
+
"langflow",
|
|
52
|
+
"semantic-search",
|
|
53
|
+
"mcp"
|
|
54
|
+
],
|
|
55
|
+
"publishConfig": {
|
|
56
|
+
"access": "public",
|
|
57
|
+
"registry": "https://registry.npmjs.org/"
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const required = ["PLATFORM_BASE_URL", "PLATFORM_TENANT_ID", "PLATFORM_PIPELINE_ID"];
|
|
3
|
+
const missing = required.filter((name) => !process.env[name]);
|
|
4
|
+
const hasWrite = Boolean(process.env.PLATFORM_PROTOCOL_TOKEN);
|
|
5
|
+
const hasTenant = Boolean(process.env.TENANT_JWT);
|
|
6
|
+
|
|
7
|
+
console.log(JSON.stringify({
|
|
8
|
+
ok: missing.length === 0 && (hasWrite || hasTenant),
|
|
9
|
+
endpoint: process.env.PLATFORM_BASE_URL ?? null,
|
|
10
|
+
tenant_id_configured: Boolean(process.env.PLATFORM_TENANT_ID),
|
|
11
|
+
pipeline_id_configured: Boolean(process.env.PLATFORM_PIPELINE_ID),
|
|
12
|
+
protocol_token_configured: hasWrite,
|
|
13
|
+
tenant_jwt_configured: hasTenant,
|
|
14
|
+
content_capture_default: "disabled",
|
|
15
|
+
missing,
|
|
16
|
+
}, null, 2));
|
|
17
|
+
|
|
18
|
+
if (missing.length || (!hasWrite && !hasTenant)) process.exitCode = 1;
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
export interface PrivacyOptions {
|
|
2
|
+
captureContent?: boolean;
|
|
3
|
+
redactedKeys?: string[];
|
|
4
|
+
maxStringLength?: number;
|
|
5
|
+
replacement?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface OperationsSnapshot {
|
|
8
|
+
schema_version: "1.0";
|
|
9
|
+
tenant_id: string;
|
|
10
|
+
pipeline_id: string;
|
|
11
|
+
region: string;
|
|
12
|
+
revision: string;
|
|
13
|
+
generated_at: string;
|
|
14
|
+
consistency: "eventually_consistent";
|
|
15
|
+
update_mode: "replace_snapshot";
|
|
16
|
+
refresh_after_ms: number;
|
|
17
|
+
coverage: { partial: boolean; counts_are_sampled: boolean; maximum_per_dataset: number };
|
|
18
|
+
entities: Record<string, unknown>[];
|
|
19
|
+
incidents: Record<string, unknown>[];
|
|
20
|
+
observations: Record<string, unknown>[];
|
|
21
|
+
totals: Record<string, unknown>;
|
|
22
|
+
rules: Record<string, unknown>;
|
|
23
|
+
pipeline_health: Record<string, unknown>;
|
|
24
|
+
availability: Record<string, string>;
|
|
25
|
+
}
|
|
26
|
+
export class Privacy {
|
|
27
|
+
constructor(options?: PrivacyOptions);
|
|
28
|
+
sanitize(value: unknown, options?: { content?: boolean }): unknown;
|
|
29
|
+
}
|
|
30
|
+
export function stableEventId(...parts: unknown[]): string;
|
|
31
|
+
export type SemanticTarget = "events" | "artifacts" | "graph";
|
|
32
|
+
export type SemanticMode = "hybrid" | "semantic" | "lexical" | "structured";
|
|
33
|
+
export type Severity = "info" | "low" | "medium" | "high" | "critical";
|
|
34
|
+
export interface SemanticQueryPlan {
|
|
35
|
+
version: "1.0";
|
|
36
|
+
question: string;
|
|
37
|
+
mode: SemanticMode;
|
|
38
|
+
targets: SemanticTarget[];
|
|
39
|
+
filters: {
|
|
40
|
+
source_types: string[];
|
|
41
|
+
event_types: string[];
|
|
42
|
+
severities: Severity[];
|
|
43
|
+
keywords: string[];
|
|
44
|
+
device_id: string | null;
|
|
45
|
+
run_id: string | null;
|
|
46
|
+
session_id: string | null;
|
|
47
|
+
relationship: string | null;
|
|
48
|
+
rule_id: string | null;
|
|
49
|
+
from: string | null;
|
|
50
|
+
to: string | null;
|
|
51
|
+
};
|
|
52
|
+
options: { limit: number; max_scan: number; top_k: number; graph_hops: number };
|
|
53
|
+
planner: { kind: string; model: string | null };
|
|
54
|
+
plan_hash: string;
|
|
55
|
+
planner_error?: string;
|
|
56
|
+
}
|
|
57
|
+
export function normalizeSemanticQuery(input: string | Partial<SemanticQueryPlan>, options?: { question?: string; planner?: { kind?: string; model?: string } }): SemanticQueryPlan;
|
|
58
|
+
export class DeterministicSemanticPlanner {
|
|
59
|
+
plan(question: string): SemanticQueryPlan;
|
|
60
|
+
}
|
|
61
|
+
export class ModelSemanticPlanner {
|
|
62
|
+
constructor(options: {
|
|
63
|
+
generateObject: (request: Record<string, unknown>) => Promise<Partial<SemanticQueryPlan>> | Partial<SemanticQueryPlan>;
|
|
64
|
+
model?: string;
|
|
65
|
+
fallback?: { plan(question: string): Promise<SemanticQueryPlan> | SemanticQueryPlan };
|
|
66
|
+
});
|
|
67
|
+
plan(question: string): Promise<SemanticQueryPlan>;
|
|
68
|
+
}
|
|
69
|
+
export class Span {
|
|
70
|
+
readonly traceId: string;
|
|
71
|
+
readonly spanId: string;
|
|
72
|
+
readonly parentSpanId?: string;
|
|
73
|
+
setAttribute(key: string, value: unknown, options?: { content?: boolean }): this;
|
|
74
|
+
addEvent(name: string, attributes?: Record<string, unknown>): this;
|
|
75
|
+
end(options?: { error?: unknown }): void;
|
|
76
|
+
}
|
|
77
|
+
export class Run {
|
|
78
|
+
readonly traceId: string;
|
|
79
|
+
readonly runId: string;
|
|
80
|
+
startSpan(name: string, options?: { kind?: string; attributes?: Record<string, unknown> }): Span;
|
|
81
|
+
withSpan<T>(name: string, options: { kind?: string; attributes?: Record<string, unknown> }, operation: (span: Span) => Promise<T> | T): Promise<T>;
|
|
82
|
+
evaluate(name: string, evaluation: { score: number; label: string; explanation?: string }): void;
|
|
83
|
+
end(options?: { error?: unknown }): void;
|
|
84
|
+
}
|
|
85
|
+
export interface ClientOptions {
|
|
86
|
+
baseUrl: string;
|
|
87
|
+
tenantId: string;
|
|
88
|
+
pipelineId: string;
|
|
89
|
+
protocolToken?: string;
|
|
90
|
+
tenantJwt?: string;
|
|
91
|
+
serviceName?: string;
|
|
92
|
+
serviceVersion?: string;
|
|
93
|
+
environment?: string;
|
|
94
|
+
privacy?: Privacy;
|
|
95
|
+
timeoutMs?: number;
|
|
96
|
+
spoolDirectory?: string;
|
|
97
|
+
maxSpoolFiles?: number;
|
|
98
|
+
autoFlushSpans?: number;
|
|
99
|
+
semanticPlanner?: { plan(question: string): Promise<SemanticQueryPlan> | SemanticQueryPlan };
|
|
100
|
+
}
|
|
101
|
+
export class Client {
|
|
102
|
+
constructor(options: ClientOptions);
|
|
103
|
+
startRun(name: string, options?: { runId?: string; goalId?: string; agentId?: string; attributes?: Record<string, unknown> }): Run;
|
|
104
|
+
startSpan(name: string, options?: { traceId?: string; parentSpanId?: string; attributes?: Record<string, unknown> }): Span;
|
|
105
|
+
flush(): Promise<void>;
|
|
106
|
+
recordEvent(event: Record<string, unknown>, options?: { idempotencyKey?: string }): Promise<Record<string, unknown>>;
|
|
107
|
+
uploadArtifact(path: string, options?: { format?: string; deviceId?: string; goalId?: string; metadata?: Record<string, unknown> }): Promise<Record<string, unknown>>;
|
|
108
|
+
analyzeLiveFrame(path: string, options: { streamSessionId: string; frameId: string; observedAt?: string; deviceId?: string; runId?: string; goalId?: string; contentType?: "image/jpeg" | "image/png" | "image/webp" }): Promise<Record<string, unknown>>;
|
|
109
|
+
listNoticeDeliveries(options?: {transport?: "room"|"moq";limit?:number;cursor?:string}): Promise<Record<string,unknown>>;
|
|
110
|
+
replayNoticeDelivery(input:{notice_id:string;stage:string;reason:string;idempotency_key:string},options?:{transport?:"room"|"moq"}):Promise<Record<string,unknown>>;
|
|
111
|
+
scope(): { tenant_id: string; pipeline_id: string; subject: string | null; cluster_id: string | null; app_id: string | null };
|
|
112
|
+
listEvents(options?: { limit?: number; cursor?: string; includeAi?: boolean }): Promise<Record<string, unknown>>;
|
|
113
|
+
searchEvents(query: string | Partial<SemanticQueryPlan>): Promise<Record<string, unknown>>;
|
|
114
|
+
searchArtifacts(query: string | Partial<SemanticQueryPlan>): Promise<Record<string, unknown>>;
|
|
115
|
+
correlationGraph(options?: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
116
|
+
getArtifactSession(sessionId: string): Promise<Record<string, unknown>>;
|
|
117
|
+
getTelemetry(sessionId: string): Promise<string>;
|
|
118
|
+
downloadArtifact(sessionId: string): Promise<Uint8Array>;
|
|
119
|
+
openVideo(sessionId: string, options?: { title?: string; ttlSeconds?: number; embedOrigin?: string }): Promise<Record<string, unknown>>;
|
|
120
|
+
renewPlayback(sessionId: string, token: string): Promise<Record<string, unknown>>;
|
|
121
|
+
createLiveEmbed(options: {embed_origin: string; ttl_seconds?: number; tracks?: string[]; stream_session_id?: string}): Promise<Record<string, unknown>>;
|
|
122
|
+
renewLiveEmbed(grantId: string): Promise<Record<string, unknown>>;
|
|
123
|
+
revokeLiveEmbed(grantId: string): Promise<Record<string, unknown>>;
|
|
124
|
+
revokePlayback(sessionId: string, token: string): Promise<Record<string, unknown>>;
|
|
125
|
+
requestVideoExport(sessionId: string, options: {startSeconds: number; endSeconds: number; idempotencyKey: string}): Promise<Record<string, unknown>>;
|
|
126
|
+
getVideoExport(sessionId: string, exportId: string): Promise<Record<string, unknown>>;
|
|
127
|
+
downloadVideoExport(sessionId: string, exportId: string): Promise<ArrayBuffer>;
|
|
128
|
+
timeline(viewerUrl: string, options?: { startSeconds?: number; endSeconds?: number; paddingSeconds?: number }): Promise<Record<string, unknown>>;
|
|
129
|
+
listRuns(): Promise<Record<string, unknown>>;
|
|
130
|
+
operationsSnapshot(): Promise<OperationsSnapshot>;
|
|
131
|
+
operationsOverview(): Promise<Record<string, unknown>>;
|
|
132
|
+
getRun(runId: string): Promise<Record<string, unknown>>;
|
|
133
|
+
getTenantConfig(): Promise<Record<string, unknown>>;
|
|
134
|
+
updateTenantConfig(patch: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
135
|
+
rotateProtocolToken(): Promise<Record<string, unknown>>;
|
|
136
|
+
validateRulePack(pack: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
137
|
+
dryRunRulePack(pack: Record<string, unknown>, events: Record<string, unknown>[]): Promise<Record<string, unknown>>;
|
|
138
|
+
publishRulePack(name: string, version: string, pack: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
139
|
+
validateSourceContract(contract: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
140
|
+
dryRunSourceContract(contract: Record<string, unknown>, events: Record<string, unknown>[]): Promise<Record<string, unknown>>;
|
|
141
|
+
getAudit(options?: { cursor?: string; limit?: number }): Promise<Record<string, unknown>>;
|
|
142
|
+
semanticQuery(question: string, options?: { planner?: { plan(question: string): Promise<SemanticQueryPlan> | SemanticQueryPlan } }): Promise<Record<string, unknown>>;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface AdminClientOptions { baseUrl: string; adminToken: string; timeoutMs?: number }
|
|
146
|
+
export class AdminClient {
|
|
147
|
+
constructor(options: AdminClientOptions);
|
|
148
|
+
request(method: string, path: string, body?: unknown): Promise<Record<string, unknown>>;
|
|
149
|
+
createTenant(input: { name: string; description?: string }): Promise<Record<string, unknown>>;
|
|
150
|
+
listPipelines(options?: { cursor?: string; limit?: number }): Promise<Record<string, unknown>>;
|
|
151
|
+
getPipeline(tenantId: string, pipelineId: string): Promise<Record<string, unknown>>;
|
|
152
|
+
createPipeline(tenantId: string, pipelineId: string, config: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
153
|
+
replacePipeline(tenantId: string, pipelineId: string, config: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
154
|
+
updatePipeline(tenantId: string, pipelineId: string, patch: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
155
|
+
deletePipeline(tenantId: string, pipelineId: string): Promise<Record<string, unknown>>;
|
|
156
|
+
validateRulePack(pack: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
157
|
+
dryRunRulePack(pack: Record<string, unknown>, events: Record<string, unknown>[]): Promise<Record<string, unknown>>;
|
|
158
|
+
publishRulePack(owner: string, name: string, version: string, pack: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
159
|
+
publishModelProfile(name: string, version: string, profile: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
160
|
+
getModelProfile(name: string, version: string): Promise<Record<string, unknown>>;
|
|
161
|
+
stats(): Promise<Record<string, unknown>>;
|
|
162
|
+
reindexArtifact(tenantId: string, pipelineId: string, sessionId: string): Promise<Record<string, unknown>>;
|
|
163
|
+
reliabilitySummary(): Promise<Record<string, unknown>>;
|
|
164
|
+
reliabilityList(kind: "receipts" | "outbox" | "quarantine" | "artifacts", options?: { cursor?: string; limit?: number }): Promise<Record<string, unknown>>;
|
|
165
|
+
replay(input: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
166
|
+
reconcile(input?: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
167
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, readdir, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
import { DeterministicSemanticPlanner, normalizeSemanticQuery } from "./semantic-query.js";
|
|
5
|
+
|
|
6
|
+
export { DeterministicSemanticPlanner, ModelSemanticPlanner, normalizeSemanticQuery } from "./semantic-query.js";
|
|
7
|
+
|
|
8
|
+
const sensitiveKey = /(^|[._-])(authorization|cookie|password|passwd|secret|token|api[._-]?key|private[._-]?key)([._-]|$)/i;
|
|
9
|
+
|
|
10
|
+
export function stableEventId(...parts) {
|
|
11
|
+
const canonical = JSON.stringify(sortValue(parts));
|
|
12
|
+
return `evt_${createHash("sha256").update(canonical).digest("hex")}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function sortValue(value) {
|
|
16
|
+
if (Array.isArray(value)) return value.map(sortValue);
|
|
17
|
+
if (value && typeof value === "object") {
|
|
18
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortValue(value[key])]));
|
|
19
|
+
}
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class Privacy {
|
|
24
|
+
constructor({ captureContent = false, redactedKeys = [], maxStringLength = 4096, replacement = "[REDACTED]" } = {}) {
|
|
25
|
+
this.captureContent = captureContent;
|
|
26
|
+
this.redactedKeys = new Set(redactedKeys);
|
|
27
|
+
this.maxStringLength = maxStringLength;
|
|
28
|
+
this.replacement = replacement;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
sanitize(value, { content = false } = {}) {
|
|
32
|
+
if (content && !this.captureContent) return this.replacement;
|
|
33
|
+
return this.#walk(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
#walk(value, key = "") {
|
|
37
|
+
if (this.redactedKeys.has(key) || sensitiveKey.test(key)) return this.replacement;
|
|
38
|
+
if (Array.isArray(value)) return value.map((item) => this.#walk(item, key));
|
|
39
|
+
if (value && typeof value === "object") {
|
|
40
|
+
return Object.fromEntries(Object.entries(value).map(([childKey, child]) => [childKey, this.#walk(child, childKey)]));
|
|
41
|
+
}
|
|
42
|
+
if (typeof value === "string" && value.length > this.maxStringLength) {
|
|
43
|
+
return `${value.slice(0, this.maxStringLength)}…[TRUNCATED]`;
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function anyValue(value) {
|
|
50
|
+
if (value == null) return { stringValue: "" };
|
|
51
|
+
if (typeof value === "boolean") return { boolValue: value };
|
|
52
|
+
if (typeof value === "bigint" || Number.isInteger(value)) return { intValue: String(value) };
|
|
53
|
+
if (typeof value === "number") return { doubleValue: value };
|
|
54
|
+
if (typeof value === "object") return { stringValue: JSON.stringify(sortValue(value)) };
|
|
55
|
+
return { stringValue: String(value) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function attributes(values) {
|
|
59
|
+
return Object.entries(values)
|
|
60
|
+
.filter(([, value]) => value != null)
|
|
61
|
+
.map(([key, value]) => ({ key, value: anyValue(value) }));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function decodeJwt(token) {
|
|
65
|
+
if (!token) return null;
|
|
66
|
+
const parts = String(token).split(".");
|
|
67
|
+
if (parts.length !== 3) throw new TypeError("tenantJwt must be a three-segment JWT");
|
|
68
|
+
try {
|
|
69
|
+
const claims = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
|
|
70
|
+
if (!claims.tenant_id || !claims.pipeline_id) throw new Error("scope missing");
|
|
71
|
+
if (claims.exp && Number(claims.exp) <= Math.floor(Date.now() / 1000)) throw new Error("expired");
|
|
72
|
+
return claims;
|
|
73
|
+
} catch (error) {
|
|
74
|
+
throw new TypeError(`tenantJwt claims are invalid: ${error.message}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function eventTimestamp(row) {
|
|
79
|
+
return row?.event?.timestamps?.observed || row?.event?.timestamps?.received || row?.event?.received_at || row?.metadata?.received_at || null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function sanitizeEvent(row) {
|
|
83
|
+
const event = row?.event || {};
|
|
84
|
+
return {
|
|
85
|
+
event_id: row?.metadata?.event_id || event.event_id || null,
|
|
86
|
+
timestamp: eventTimestamp(row),
|
|
87
|
+
source_type: event.source_type || event.source?.type || "unknown",
|
|
88
|
+
event_type: event.event_type || null,
|
|
89
|
+
severity: row?.rule_evaluation?.severity || event.severity || "info",
|
|
90
|
+
message: String(event.message || event.content?.normalized?.message || "").slice(0, 2_000),
|
|
91
|
+
device_id: event.device_id || null,
|
|
92
|
+
run_id: event.run_id || event.goal_id || event.mission_id || event.recording_id || null,
|
|
93
|
+
rule_evaluation: row?.rule_evaluation || null,
|
|
94
|
+
ai_enrichment: row?.ai_enrichment || null,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function filterEventRows(rows, plan) {
|
|
99
|
+
const filters = plan.filters;
|
|
100
|
+
const words = [...new Set([...(filters.keywords || []), ...(plan.question.toLowerCase().match(/[a-z0-9_.:-]{2,}/gu) || [])])];
|
|
101
|
+
return rows.map((row) => {
|
|
102
|
+
const event = sanitizeEvent(row);
|
|
103
|
+
const haystack = JSON.stringify(event).toLowerCase();
|
|
104
|
+
const matched = words.filter((word) => haystack.includes(word.toLowerCase()));
|
|
105
|
+
return { event, score: words.length ? matched.length / words.length : 1, matched_terms: matched };
|
|
106
|
+
}).filter(({ event, score }) => {
|
|
107
|
+
if (filters.source_types.length && !filters.source_types.some((source) => source.endsWith(".*") ? event.source_type.startsWith(source.slice(0, -1)) : event.source_type === source)) return false;
|
|
108
|
+
if (filters.event_types.length && !filters.event_types.includes(event.event_type)) return false;
|
|
109
|
+
if (filters.severities.length && !filters.severities.includes(event.severity)) return false;
|
|
110
|
+
if (filters.device_id && event.device_id !== filters.device_id) return false;
|
|
111
|
+
if (filters.run_id && event.run_id !== filters.run_id) return false;
|
|
112
|
+
const time = Date.parse(event.timestamp || "");
|
|
113
|
+
if (filters.from && Number.isFinite(time) && time < Date.parse(filters.from)) return false;
|
|
114
|
+
if (filters.to && Number.isFinite(time) && time > Date.parse(filters.to)) return false;
|
|
115
|
+
return score > 0 || words.length === 0;
|
|
116
|
+
}).sort((a, b) => b.score - a.score).slice(0, plan.options.limit);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export class Span {
|
|
120
|
+
constructor(client, name, { traceId, parentSpanId, attributes: values = {} } = {}) {
|
|
121
|
+
this.client = client;
|
|
122
|
+
this.name = name;
|
|
123
|
+
this.traceId = traceId ?? randomBytes(16).toString("hex");
|
|
124
|
+
this.spanId = randomBytes(8).toString("hex");
|
|
125
|
+
this.parentSpanId = parentSpanId;
|
|
126
|
+
this.attributes = client.privacy.sanitize(values);
|
|
127
|
+
this.events = [];
|
|
128
|
+
this.startNs = BigInt(Date.now()) * 1_000_000n;
|
|
129
|
+
this.endNs = undefined;
|
|
130
|
+
this.status = { code: 0, message: "" };
|
|
131
|
+
this.ended = false;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
setAttribute(key, value, { content = false } = {}) {
|
|
135
|
+
this.attributes[key] = this.client.privacy.sanitize(value, { content });
|
|
136
|
+
return this;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
addEvent(name, values = {}) {
|
|
140
|
+
this.events.push({
|
|
141
|
+
timeUnixNano: String(BigInt(Date.now()) * 1_000_000n),
|
|
142
|
+
name,
|
|
143
|
+
attributes: attributes(this.client.privacy.sanitize(values)),
|
|
144
|
+
});
|
|
145
|
+
return this;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
end({ error } = {}) {
|
|
149
|
+
if (this.ended) return;
|
|
150
|
+
if (error) {
|
|
151
|
+
this.status = { code: 2, message: String(error) };
|
|
152
|
+
this.addEvent("exception", { "exception.message": String(error) });
|
|
153
|
+
}
|
|
154
|
+
this.endNs = BigInt(Date.now()) * 1_000_000n;
|
|
155
|
+
this.ended = true;
|
|
156
|
+
this.client._finishSpan(this);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
toOtlp() {
|
|
160
|
+
const span = {
|
|
161
|
+
traceId: this.traceId,
|
|
162
|
+
spanId: this.spanId,
|
|
163
|
+
name: this.name,
|
|
164
|
+
kind: 1,
|
|
165
|
+
startTimeUnixNano: String(this.startNs),
|
|
166
|
+
endTimeUnixNano: String(this.endNs ?? BigInt(Date.now()) * 1_000_000n),
|
|
167
|
+
attributes: attributes(this.attributes),
|
|
168
|
+
events: this.events,
|
|
169
|
+
status: this.status,
|
|
170
|
+
};
|
|
171
|
+
if (this.parentSpanId) span.parentSpanId = this.parentSpanId;
|
|
172
|
+
return span;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export class Run {
|
|
177
|
+
constructor(client, root, goalId) {
|
|
178
|
+
this.client = client;
|
|
179
|
+
this.root = root;
|
|
180
|
+
this.goalId = goalId;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
get traceId() { return this.root.traceId; }
|
|
184
|
+
get runId() { return this.root.attributes["run.id"]; }
|
|
185
|
+
|
|
186
|
+
startSpan(name, { kind = "CHAIN", attributes: values = {} } = {}) {
|
|
187
|
+
return this.client.startSpan(name, {
|
|
188
|
+
traceId: this.traceId,
|
|
189
|
+
parentSpanId: this.root.spanId,
|
|
190
|
+
attributes: {
|
|
191
|
+
"openinference.span.kind": kind.toUpperCase(),
|
|
192
|
+
"run.id": this.runId,
|
|
193
|
+
"goal.id": this.goalId,
|
|
194
|
+
...values,
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async withSpan(name, options, operation) {
|
|
200
|
+
const span = this.startSpan(name, options);
|
|
201
|
+
try {
|
|
202
|
+
return await operation(span);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
span.end({ error });
|
|
205
|
+
throw error;
|
|
206
|
+
} finally {
|
|
207
|
+
span.end();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
evaluate(name, { score, label, explanation = "" }) {
|
|
212
|
+
this.root.addEvent("gen_ai.evaluation", {
|
|
213
|
+
"gen_ai.evaluation.name": name,
|
|
214
|
+
"gen_ai.evaluation.score.value": score,
|
|
215
|
+
"gen_ai.evaluation.score.label": label,
|
|
216
|
+
"gen_ai.evaluation.explanation": explanation,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
end(options) { this.root.end(options); }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export class Client {
|
|
224
|
+
constructor({
|
|
225
|
+
baseUrl,
|
|
226
|
+
tenantId,
|
|
227
|
+
pipelineId,
|
|
228
|
+
protocolToken,
|
|
229
|
+
tenantJwt,
|
|
230
|
+
serviceName = "application",
|
|
231
|
+
serviceVersion,
|
|
232
|
+
environment,
|
|
233
|
+
privacy = new Privacy(),
|
|
234
|
+
timeoutMs = 30_000,
|
|
235
|
+
spoolDirectory,
|
|
236
|
+
maxSpoolFiles = 256,
|
|
237
|
+
autoFlushSpans = 64,
|
|
238
|
+
semanticPlanner = new DeterministicSemanticPlanner(),
|
|
239
|
+
}) {
|
|
240
|
+
if (!baseUrl) throw new TypeError("baseUrl is required");
|
|
241
|
+
const parsedBase = new URL(baseUrl);
|
|
242
|
+
if (parsedBase.protocol !== "https:") throw new TypeError("baseUrl must use HTTPS");
|
|
243
|
+
const claims = decodeJwt(tenantJwt);
|
|
244
|
+
if (claims && tenantId && tenantId !== claims.tenant_id) throw new TypeError("tenantId does not match tenantJwt scope");
|
|
245
|
+
if (claims && pipelineId && pipelineId !== claims.pipeline_id) throw new TypeError("pipelineId does not match tenantJwt scope");
|
|
246
|
+
this.baseUrl = parsedBase.origin;
|
|
247
|
+
this.tenantId = tenantId || claims?.tenant_id;
|
|
248
|
+
this.pipelineId = pipelineId || claims?.pipeline_id;
|
|
249
|
+
if (!this.tenantId || !this.pipelineId) throw new TypeError("tenantId and pipelineId are required when tenantJwt cannot supply scope");
|
|
250
|
+
this.claims = claims;
|
|
251
|
+
this.protocolToken = protocolToken;
|
|
252
|
+
this.tenantJwt = tenantJwt;
|
|
253
|
+
this.serviceName = serviceName;
|
|
254
|
+
this.serviceVersion = serviceVersion;
|
|
255
|
+
this.environment = environment;
|
|
256
|
+
this.privacy = privacy;
|
|
257
|
+
this.timeoutMs = timeoutMs;
|
|
258
|
+
this.spoolDirectory = spoolDirectory;
|
|
259
|
+
this.maxSpoolFiles = maxSpoolFiles;
|
|
260
|
+
this.autoFlushSpans = autoFlushSpans;
|
|
261
|
+
this.semanticPlanner = semanticPlanner;
|
|
262
|
+
this.pending = [];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
scope() {
|
|
266
|
+
return {
|
|
267
|
+
tenant_id: this.tenantId,
|
|
268
|
+
pipeline_id: this.pipelineId,
|
|
269
|
+
subject: this.claims?.sub || null,
|
|
270
|
+
cluster_id: this.claims?.cluster_id || null,
|
|
271
|
+
app_id: this.claims?.app_id || null,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
startRun(name, { runId = randomBytes(16).toString("hex"), goalId, agentId, attributes: values = {} } = {}) {
|
|
276
|
+
const root = this.startSpan(name, { attributes: {
|
|
277
|
+
"openinference.span.kind": "AGENT",
|
|
278
|
+
"gen_ai.operation.name": "invoke_agent",
|
|
279
|
+
"gen_ai.agent.id": agentId,
|
|
280
|
+
"gen_ai.agent.name": name,
|
|
281
|
+
"run.id": runId,
|
|
282
|
+
"goal.id": goalId,
|
|
283
|
+
...values,
|
|
284
|
+
}});
|
|
285
|
+
return new Run(this, root, goalId);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
startSpan(name, options) { return new Span(this, name, options); }
|
|
289
|
+
|
|
290
|
+
_finishSpan(span) {
|
|
291
|
+
this.pending.push(span);
|
|
292
|
+
if (this.pending.length >= this.autoFlushSpans) void this.flush();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
_otlpPayload(spans) {
|
|
296
|
+
return { resourceSpans: [{
|
|
297
|
+
resource: { attributes: attributes({
|
|
298
|
+
"service.name": this.serviceName,
|
|
299
|
+
"service.version": this.serviceVersion,
|
|
300
|
+
"deployment.environment.name": this.environment,
|
|
301
|
+
})},
|
|
302
|
+
scopeSpans: [{
|
|
303
|
+
scope: { name: "trazio-telemetry", version: "0.3.0" },
|
|
304
|
+
spans: spans.map((span) => span.toOtlp()),
|
|
305
|
+
}],
|
|
306
|
+
}]};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async flush() {
|
|
310
|
+
await this.#drainSpool();
|
|
311
|
+
if (!this.pending.length) return;
|
|
312
|
+
const spans = this.pending.splice(0);
|
|
313
|
+
const payload = this._otlpPayload(spans);
|
|
314
|
+
try {
|
|
315
|
+
await this.#request("POST", "/v1/traces", payload, { protocol: true });
|
|
316
|
+
} catch (error) {
|
|
317
|
+
await this.#spool(payload);
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async recordEvent(event, { idempotencyKey } = {}) {
|
|
323
|
+
const key = idempotencyKey ?? stableEventId(this.tenantId, this.pipelineId, event);
|
|
324
|
+
const path = `/v1/ingest/${encodeURIComponent(this.tenantId)}/${encodeURIComponent(this.pipelineId)}`;
|
|
325
|
+
return this.#request("POST", path, this.privacy.sanitize(event), { jwt: true, headers: { "Idempotency-Key": key } });
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async uploadArtifact(path, { format = "auto", deviceId, goalId, metadata = {} } = {}) {
|
|
329
|
+
const base = `/v1/artifacts/${encodeURIComponent(this.tenantId)}/${encodeURIComponent(this.pipelineId)}/sessions`;
|
|
330
|
+
const session = await this.#request("POST", base, {
|
|
331
|
+
filename: basename(path), format, device_id: deviceId, goal_id: goalId,
|
|
332
|
+
metadata: this.privacy.sanitize(metadata),
|
|
333
|
+
}, { jwt: true });
|
|
334
|
+
const content = await readFile(path);
|
|
335
|
+
await this.#request("PUT", `${base}/${encodeURIComponent(session.session_id)}/content`, content, {
|
|
336
|
+
jwt: true, contentType: "application/octet-stream",
|
|
337
|
+
});
|
|
338
|
+
return this.#request("POST", `${base}/${encodeURIComponent(session.session_id)}/complete`, undefined, { jwt: true });
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
listNoticeDeliveries({transport="room",limit=50,cursor}={}) {
|
|
342
|
+
if(!["room","moq"].includes(transport))throw new TypeError("transport must be room or moq");
|
|
343
|
+
const params=new URLSearchParams({limit:String(limit)});if(cursor)params.set("cursor",cursor);
|
|
344
|
+
return this.#request("GET",`/v1/${transport==="room"?"livekit":"live"}/${encodeURIComponent(this.tenantId)}/${encodeURIComponent(this.pipelineId)}/notice-deliveries?${params}`,undefined,{jwt:true});
|
|
345
|
+
}
|
|
346
|
+
replayNoticeDelivery(input,{transport="room"}={}) {
|
|
347
|
+
if(!["room","moq"].includes(transport))throw new TypeError("transport must be room or moq");
|
|
348
|
+
return this.#request("POST",`/v1/${transport==="room"?"livekit":"live"}/${encodeURIComponent(this.tenantId)}/${encodeURIComponent(this.pipelineId)}/notice-replay`,input,{jwt:true});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async analyzeLiveFrame(path, {
|
|
352
|
+
streamSessionId, frameId, observedAt = new Date().toISOString(),
|
|
353
|
+
deviceId, runId, goalId, contentType = "image/jpeg",
|
|
354
|
+
} = {}) {
|
|
355
|
+
if (!streamSessionId || !frameId) throw new TypeError("streamSessionId and frameId are required");
|
|
356
|
+
const content = await readFile(path);
|
|
357
|
+
const livePath = `/v1/live/${encodeURIComponent(this.tenantId)}/${encodeURIComponent(this.pipelineId)}/analyze-frame`;
|
|
358
|
+
return this.#request("POST", livePath, content, {
|
|
359
|
+
jwt: true,
|
|
360
|
+
contentType,
|
|
361
|
+
headers: {
|
|
362
|
+
"X-Stream-Session-Id": String(streamSessionId),
|
|
363
|
+
"X-Frame-Id": String(frameId),
|
|
364
|
+
"X-Observed-At": String(observedAt),
|
|
365
|
+
...(deviceId ? { "X-Device-Id": String(deviceId) } : {}),
|
|
366
|
+
...(runId ? { "X-Run-Id": String(runId) } : {}),
|
|
367
|
+
...(goalId ? { "X-Goal-Id": String(goalId) } : {}),
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
#queryPath(suffix = "") {
|
|
373
|
+
return `/v1/query/${encodeURIComponent(this.tenantId)}/${encodeURIComponent(this.pipelineId)}${suffix}`;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
#artifactPath(suffix = "") {
|
|
377
|
+
return `/v1/artifacts/${encodeURIComponent(this.tenantId)}/${encodeURIComponent(this.pipelineId)}${suffix}`;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
#tenantPath(suffix = "") {
|
|
381
|
+
return `/v1/tenant/pipelines/${encodeURIComponent(this.tenantId)}/${encodeURIComponent(this.pipelineId)}${suffix}`;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async listEvents({ limit = 100, cursor, includeAi = true } = {}) {
|
|
385
|
+
const params = new URLSearchParams({ limit: String(Math.min(Math.max(Number(limit) || 100, 1), 100)), include_ai: String(Boolean(includeAi)) });
|
|
386
|
+
if (cursor) params.set("cursor", cursor);
|
|
387
|
+
return this.#request("GET", `${this.#queryPath()}?${params}`, undefined, { jwt: true });
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async searchEvents(query) {
|
|
391
|
+
const plan = normalizeSemanticQuery(typeof query === "string" ? query : { ...query, targets: ["events"] });
|
|
392
|
+
const rows = [];
|
|
393
|
+
let cursor;
|
|
394
|
+
while (rows.length < plan.options.max_scan) {
|
|
395
|
+
const page = await this.listEvents({ limit: Math.min(100, plan.options.max_scan - rows.length), cursor, includeAi: true });
|
|
396
|
+
rows.push(...(page.results || []));
|
|
397
|
+
cursor = page.cursor;
|
|
398
|
+
if (!cursor || page.list_complete) break;
|
|
399
|
+
}
|
|
400
|
+
const matches = filterEventRows(rows, plan);
|
|
401
|
+
return { scope: this.scope(), plan, scanned: rows.length, count: matches.length, matches, retrieval: "structured_lexical" };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async searchArtifacts(query) {
|
|
405
|
+
const plan = normalizeSemanticQuery(typeof query === "string" ? { question: query, targets: ["artifacts"] } : { ...query, targets: ["artifacts"] });
|
|
406
|
+
const filters = plan.filters;
|
|
407
|
+
return this.#request("POST", this.#artifactPath("/search"), {
|
|
408
|
+
query: plan.question,
|
|
409
|
+
top_k: plan.options.top_k,
|
|
410
|
+
...(filters.session_id ? { session_id: filters.session_id } : {}),
|
|
411
|
+
...(filters.device_id ? { device_id: filters.device_id } : {}),
|
|
412
|
+
...(filters.run_id ? { run_id: filters.run_id } : {}),
|
|
413
|
+
}, { jwt: true });
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
correlationGraph(options = {}) {
|
|
417
|
+
const params = new URLSearchParams();
|
|
418
|
+
for (const key of ["query", "node_id", "event_id", "edge_id", "relationship", "rule_id"])
|
|
419
|
+
if (options[key]) params.set(key, String(options[key]));
|
|
420
|
+
if (options.event_ids?.length) params.set("event_ids", options.event_ids.slice(0, 50).join(","));
|
|
421
|
+
params.set("hops", String(Math.min(Math.max(Number(options.hops) || 1, 0), 3)));
|
|
422
|
+
params.set("limit", String(Math.min(Math.max(Number(options.limit) || 100, 1), 500)));
|
|
423
|
+
return this.#request("GET", `${this.#queryPath("/graph")}?${params}`, undefined, { jwt: true });
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
getArtifactSession(sessionId) {
|
|
427
|
+
return this.#request("GET", this.#artifactPath(`/sessions/${encodeURIComponent(sessionId)}`), undefined, { jwt: true });
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
getTelemetry(sessionId) {
|
|
431
|
+
return this.#request("GET", this.#artifactPath(`/sessions/${encodeURIComponent(sessionId)}/telemetry`), undefined, { jwt: true, responseType: "text" });
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
downloadArtifact(sessionId) {
|
|
435
|
+
return this.#request("GET", this.#artifactPath(`/sessions/${encodeURIComponent(sessionId)}/content`), undefined, { jwt: true, responseType: "buffer" });
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async openVideo(sessionId, { title, ttlSeconds = 900, embedOrigin } = {}) {
|
|
439
|
+
const result = await this.#request("POST", this.#artifactPath(`/sessions/${encodeURIComponent(sessionId)}/playback`), {
|
|
440
|
+
title, ttl_seconds: Math.min(Math.max(Number(ttlSeconds) || 900, 60), 3600),
|
|
441
|
+
...(embedOrigin?{embed_origin:embedOrigin}:{}),
|
|
442
|
+
}, { jwt: true });
|
|
443
|
+
return { ...result, viewer_url: new URL(result.viewer_url, this.baseUrl).toString() };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
renewPlayback(sessionId, token) { return this.#request("POST", this.#artifactPath(`/sessions/${encodeURIComponent(sessionId)}/playback-renew`), {token}, {jwt:true}); }
|
|
447
|
+
createLiveEmbed(options) { return this.#request("POST", `/v1/live/${encodeURIComponent(this.tenantId)}/${encodeURIComponent(this.pipelineId)}/viewer-grants`, options, {jwt:true}); }
|
|
448
|
+
renewLiveEmbed(grantId) { return this.#request("PATCH", `/v1/live/${encodeURIComponent(this.tenantId)}/${encodeURIComponent(this.pipelineId)}/viewer-grants/${encodeURIComponent(grantId)}`, undefined, {jwt:true}); }
|
|
449
|
+
revokeLiveEmbed(grantId) { return this.#request("DELETE", `/v1/live/${encodeURIComponent(this.tenantId)}/${encodeURIComponent(this.pipelineId)}/viewer-grants/${encodeURIComponent(grantId)}`, undefined, {jwt:true}); }
|
|
450
|
+
revokePlayback(sessionId, token) { return this.#request("POST", this.#artifactPath(`/sessions/${encodeURIComponent(sessionId)}/playback-revoke`), {token}, {jwt:true}); }
|
|
451
|
+
requestVideoExport(sessionId, {startSeconds,endSeconds,idempotencyKey}) {
|
|
452
|
+
return this.#request("POST",this.#artifactPath(`/sessions/${encodeURIComponent(sessionId)}/exports`),{start_seconds:startSeconds,end_seconds:endSeconds},{jwt:true,headers:{"Idempotency-Key":idempotencyKey}});
|
|
453
|
+
}
|
|
454
|
+
getVideoExport(sessionId, exportId) { return this.#request("GET",this.#artifactPath(`/sessions/${encodeURIComponent(sessionId)}/exports/${encodeURIComponent(exportId)}`),undefined,{jwt:true}); }
|
|
455
|
+
downloadVideoExport(sessionId, exportId) { return this.#request("GET",this.#artifactPath(`/sessions/${encodeURIComponent(sessionId)}/exports/${encodeURIComponent(exportId)}/content`),undefined,{jwt:true,responseType:"buffer"}); }
|
|
456
|
+
|
|
457
|
+
timeline(viewerUrl, { startSeconds = 0, endSeconds = startSeconds, paddingSeconds = 5 } = {}) {
|
|
458
|
+
const url = new URL(viewerUrl, this.baseUrl);
|
|
459
|
+
if (url.origin !== this.baseUrl || !/^\/v1\/artifacts\/play\/(?:pc_[a-f0-9]{32}\/)?pb_[A-Za-z0-9_-]+$/u.test(url.pathname)) throw new TypeError("viewerUrl must be an opaque URL issued by this regional service");
|
|
460
|
+
const params = new URLSearchParams({ start: String(startSeconds), end: String(endSeconds), padding: String(Math.min(Math.max(Number(paddingSeconds) || 0, 0), 60)) });
|
|
461
|
+
return this.#request("GET", `${url.pathname}/timeline?${params}`, undefined, { publicRequest: true });
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
listRuns() { return this.#request("GET", this.#artifactPath("/runs"), undefined, { jwt: true }); }
|
|
465
|
+
operationsSnapshot() {
|
|
466
|
+
return this.#request("GET", `/v1/operations/${encodeURIComponent(this.tenantId)}/${encodeURIComponent(this.pipelineId)}/snapshot`, undefined, { jwt: true });
|
|
467
|
+
}
|
|
468
|
+
operationsOverview() {
|
|
469
|
+
return this.#request("GET", `/v1/operations/${encodeURIComponent(this.tenantId)}/${encodeURIComponent(this.pipelineId)}/overview`, undefined, { jwt: true });
|
|
470
|
+
}
|
|
471
|
+
getRun(runId) { return this.#request("GET", this.#artifactPath(`/runs/${encodeURIComponent(runId)}`), undefined, { jwt: true }); }
|
|
472
|
+
getTenantConfig() { return this.#request("GET", this.#tenantPath("/config"), undefined, { jwt: true }); }
|
|
473
|
+
updateTenantConfig(patch) { return this.#request("PATCH", this.#tenantPath("/config"), patch, { jwt: true }); }
|
|
474
|
+
rotateProtocolToken() { return this.#request("POST", this.#tenantPath("/protocol-token/rotate"), {}, { jwt: true }); }
|
|
475
|
+
validateRulePack(pack) { return this.#request("POST", this.#tenantPath("/rule-packs/validate"), pack, { jwt: true }); }
|
|
476
|
+
dryRunRulePack(pack, events) { return this.#request("POST", this.#tenantPath("/rule-packs/dry-run"), { pack, events }, { jwt: true }); }
|
|
477
|
+
publishRulePack(name, version, pack) { return this.#request("POST", this.#tenantPath(`/rule-packs/${encodeURIComponent(name)}/${encodeURIComponent(version)}`), pack, { jwt: true }); }
|
|
478
|
+
validateSourceContract(contract) { return this.#request("POST", this.#tenantPath("/contracts/validate"), contract, { jwt: true }); }
|
|
479
|
+
dryRunSourceContract(contract, events) { return this.#request("POST", this.#tenantPath("/contracts/dry-run"), { contract, events }, { jwt: true }); }
|
|
480
|
+
getAudit({ cursor, limit = 100 } = {}) {
|
|
481
|
+
const params = new URLSearchParams({ limit: String(Math.min(Math.max(Number(limit) || 100, 1), 100)) });
|
|
482
|
+
if (cursor) params.set("cursor", cursor);
|
|
483
|
+
return this.#request("GET", `${this.#tenantPath("/audit")}?${params}`, undefined, { jwt: true });
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async semanticQuery(question, { planner = this.semanticPlanner } = {}) {
|
|
487
|
+
const plan = normalizeSemanticQuery(await planner.plan(question), { question });
|
|
488
|
+
const result = { scope: this.scope(), plan, events: null, artifacts: null, graph: null, errors: {} };
|
|
489
|
+
const operations = {
|
|
490
|
+
events: () => this.searchEvents(plan),
|
|
491
|
+
artifacts: () => this.searchArtifacts(plan),
|
|
492
|
+
graph: () => this.correlationGraph({
|
|
493
|
+
query: plan.question, relationship: plan.filters.relationship, rule_id: plan.filters.rule_id,
|
|
494
|
+
hops: plan.options.graph_hops, limit: Math.min(plan.options.limit * 5, 500),
|
|
495
|
+
}),
|
|
496
|
+
};
|
|
497
|
+
await Promise.all(plan.targets.map(async (target) => {
|
|
498
|
+
try { result[target] = await operations[target](); }
|
|
499
|
+
catch (error) { result.errors[target] = { name: error?.name || "Error", message: String(error?.message || "query failed").slice(0, 500) }; }
|
|
500
|
+
}));
|
|
501
|
+
return { ...result, reliability: { scope_source: "jwt_claims_server_verified_per_request", plan_validated: true, raw_records_merged: false } };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
#auth({ protocol, jwt }) {
|
|
505
|
+
const token = protocol ? this.protocolToken : jwt ? this.tenantJwt : undefined;
|
|
506
|
+
if (!token) throw new TypeError(`${protocol ? "protocolToken" : "tenantJwt"} is required for this operation`);
|
|
507
|
+
const headers = { Authorization: `Bearer ${token}` };
|
|
508
|
+
if (protocol) {
|
|
509
|
+
headers["X-Platform-Tenant"] = this.tenantId;
|
|
510
|
+
headers["X-Platform-Pipeline"] = this.pipelineId;
|
|
511
|
+
}
|
|
512
|
+
return headers;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async #request(method, path, payload, { protocol = false, jwt = false, publicRequest = false, headers = {}, contentType = "application/json", responseType = "json" } = {}) {
|
|
516
|
+
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
517
|
+
method,
|
|
518
|
+
redirect: "error",
|
|
519
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
520
|
+
headers: { ...(publicRequest ? {} : this.#auth({ protocol, jwt })), "Content-Type": contentType, ...headers },
|
|
521
|
+
body: payload == null ? undefined : Buffer.isBuffer(payload) ? payload : JSON.stringify(payload),
|
|
522
|
+
});
|
|
523
|
+
if (responseType === "buffer") {
|
|
524
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
525
|
+
if (!response.ok) throw new Error(`HTTP ${response.status} from ${path}`);
|
|
526
|
+
return buffer;
|
|
527
|
+
}
|
|
528
|
+
const text = await response.text();
|
|
529
|
+
if (!response.ok) throw new Error(`HTTP ${response.status} from ${path}: ${text.slice(0, 2048)}`);
|
|
530
|
+
if (responseType === "text") return text;
|
|
531
|
+
return text ? JSON.parse(text) : { status: response.status };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
async #spool(payload) {
|
|
535
|
+
if (!this.spoolDirectory) return;
|
|
536
|
+
await mkdir(this.spoolDirectory, { recursive: true, mode: 0o700 });
|
|
537
|
+
const files = (await readdir(this.spoolDirectory)).filter((name) => name.endsWith(".json")).sort();
|
|
538
|
+
while (files.length >= this.maxSpoolFiles) await unlink(join(this.spoolDirectory, files.shift()));
|
|
539
|
+
await writeFile(join(this.spoolDirectory, `${Date.now()}-${randomBytes(4).toString("hex")}.json`), JSON.stringify(payload), { mode: 0o600 });
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async #drainSpool() {
|
|
543
|
+
if (!this.spoolDirectory) return;
|
|
544
|
+
await mkdir(this.spoolDirectory, { recursive: true, mode: 0o700 });
|
|
545
|
+
const files = (await readdir(this.spoolDirectory)).filter((name) => name.endsWith(".json")).sort();
|
|
546
|
+
for (const name of files) {
|
|
547
|
+
const payload = JSON.parse(await readFile(join(this.spoolDirectory, name), "utf8"));
|
|
548
|
+
await this.#request("POST", "/v1/traces", payload, { protocol: true });
|
|
549
|
+
await unlink(join(this.spoolDirectory, name));
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
export class AdminClient {
|
|
555
|
+
constructor({ baseUrl, adminToken, timeoutMs = 30_000 }) {
|
|
556
|
+
const url = new URL(baseUrl);
|
|
557
|
+
if (url.protocol !== "https:") throw new TypeError("baseUrl must use HTTPS");
|
|
558
|
+
if (!adminToken) throw new TypeError("adminToken is required");
|
|
559
|
+
this.baseUrl = url.origin;
|
|
560
|
+
this.adminToken = adminToken;
|
|
561
|
+
this.timeoutMs = timeoutMs;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async request(method, path, body) {
|
|
565
|
+
if (!String(path).startsWith("/v1/admin/")) throw new TypeError("Admin requests must use /v1/admin paths");
|
|
566
|
+
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
567
|
+
method,
|
|
568
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
569
|
+
headers: { "X-Admin-Token": this.adminToken, Accept: "application/json", ...(body === undefined ? {} : { "Content-Type": "application/json" }) },
|
|
570
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
571
|
+
});
|
|
572
|
+
const text = await response.text();
|
|
573
|
+
let payload;
|
|
574
|
+
try { payload = text ? JSON.parse(text) : { status: response.status }; } catch { payload = { error: text.slice(0, 2_000) }; }
|
|
575
|
+
if (!response.ok) throw new Error(`HTTP ${response.status} from ${path}: ${String(payload?.error || "admin request failed").slice(0, 500)}`);
|
|
576
|
+
return payload;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
createTenant(input) { return this.request("POST", "/v1/admin/customers", input); }
|
|
580
|
+
listPipelines({ cursor, limit = 100 } = {}) {
|
|
581
|
+
const params = new URLSearchParams({ limit: String(Math.min(Math.max(Number(limit) || 100, 1), 100)) });
|
|
582
|
+
if (cursor) params.set("cursor", cursor);
|
|
583
|
+
return this.request("GET", `/v1/admin/pipelines?${params}`);
|
|
584
|
+
}
|
|
585
|
+
pipelinePath(tenantId, pipelineId, suffix = "") { return `/v1/admin/pipelines/${encodeURIComponent(tenantId)}/${encodeURIComponent(pipelineId)}${suffix}`; }
|
|
586
|
+
getPipeline(tenantId, pipelineId) { return this.request("GET", this.pipelinePath(tenantId, pipelineId)); }
|
|
587
|
+
createPipeline(tenantId, pipelineId, config) { return this.request("POST", this.pipelinePath(tenantId, pipelineId), config); }
|
|
588
|
+
replacePipeline(tenantId, pipelineId, config) { return this.request("PUT", this.pipelinePath(tenantId, pipelineId), config); }
|
|
589
|
+
updatePipeline(tenantId, pipelineId, patch) { return this.request("PATCH", this.pipelinePath(tenantId, pipelineId), patch); }
|
|
590
|
+
deletePipeline(tenantId, pipelineId) { return this.request("DELETE", this.pipelinePath(tenantId, pipelineId)); }
|
|
591
|
+
validateRulePack(pack) { return this.request("POST", "/v1/admin/rule-packs/validate", pack); }
|
|
592
|
+
dryRunRulePack(pack, events) { return this.request("POST", "/v1/admin/rule-packs/dry-run", { pack, events }); }
|
|
593
|
+
publishRulePack(owner, name, version, pack) { return this.request("POST", `/v1/admin/rule-packs/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/${encodeURIComponent(version)}`, pack); }
|
|
594
|
+
publishModelProfile(name, version, profile) { return this.request("POST", `/v1/admin/model-profiles/${encodeURIComponent(name)}/${encodeURIComponent(version)}`, profile); }
|
|
595
|
+
getModelProfile(name, version) { return this.request("GET", `/v1/admin/model-profiles/${encodeURIComponent(name)}/${encodeURIComponent(version)}`); }
|
|
596
|
+
stats() { return this.request("GET", "/v1/admin/stats"); }
|
|
597
|
+
reindexArtifact(tenantId, pipelineId, sessionId) { return this.request("POST", `/v1/admin/artifacts/${encodeURIComponent(tenantId)}/${encodeURIComponent(pipelineId)}/${encodeURIComponent(sessionId)}/reindex`, {}); }
|
|
598
|
+
reliabilitySummary() { return this.request("GET", "/v1/admin/reliability/summary"); }
|
|
599
|
+
reliabilityList(kind, { cursor, limit = 100 } = {}) {
|
|
600
|
+
if (!["receipts", "outbox", "quarantine", "artifacts"].includes(kind)) throw new TypeError("Unsupported reliability collection");
|
|
601
|
+
const params = new URLSearchParams({ limit: String(Math.min(Math.max(Number(limit) || 100, 1), 100)) });
|
|
602
|
+
if (cursor) params.set("cursor", cursor);
|
|
603
|
+
return this.request("GET", `/v1/admin/reliability/${kind}?${params}`);
|
|
604
|
+
}
|
|
605
|
+
replay(input) { return this.request("POST", "/v1/admin/reliability/replay", input); }
|
|
606
|
+
reconcile(input = {}) { return this.request("POST", "/v1/admin/reliability/reconcile", input); }
|
|
607
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function openInferenceAttributes(kind: string, values?: Record<string, unknown> & { inputValue?: unknown; outputValue?: unknown }): Record<string, unknown>;
|
|
2
|
+
export function temporalAttributes(values: {
|
|
3
|
+
workflowId: string;
|
|
4
|
+
runId: string;
|
|
5
|
+
workflowType?: string;
|
|
6
|
+
taskQueue?: string;
|
|
7
|
+
activityId?: string;
|
|
8
|
+
attempt?: number;
|
|
9
|
+
}): Record<string, unknown>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export function openInferenceAttributes(kind, { inputValue, outputValue, ...attributes } = {}) {
|
|
2
|
+
return {
|
|
3
|
+
"openinference.span.kind": kind.toUpperCase(),
|
|
4
|
+
...(inputValue === undefined ? {} : { "input.value": inputValue }),
|
|
5
|
+
...(outputValue === undefined ? {} : { "output.value": outputValue }),
|
|
6
|
+
...attributes,
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function temporalAttributes({ workflowId, runId, workflowType, taskQueue, activityId, attempt }) {
|
|
11
|
+
return {
|
|
12
|
+
"temporal.workflow.id": workflowId,
|
|
13
|
+
"temporal.run.id": runId,
|
|
14
|
+
"temporal.workflow.type": workflowType,
|
|
15
|
+
"temporal.task_queue": taskQueue,
|
|
16
|
+
"temporal.activity.id": activityId,
|
|
17
|
+
"temporal.activity.attempt": attempt,
|
|
18
|
+
"run.id": runId,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const SEVERITIES = new Set(["info", "low", "medium", "high", "critical"]);
|
|
4
|
+
const TARGETS = new Set(["events", "artifacts", "graph"]);
|
|
5
|
+
const MODES = new Set(["hybrid", "semantic", "lexical", "structured"]);
|
|
6
|
+
const SOURCE_ALIASES = new Map([
|
|
7
|
+
["kubernetes", "kubernetes.*"], ["k8s", "kubernetes.*"],
|
|
8
|
+
["otel", "otel.*"], ["opentelemetry", "otel.*"],
|
|
9
|
+
["robot", "robot.*"], ["robotics", "robot.*"],
|
|
10
|
+
["desktop", "desktop.*"], ["endpoint", "desktop.security"],
|
|
11
|
+
["argocd", "argocd"], ["mqtt", "mqtt"],
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
function list(value, { allowed, limit = 32 } = {}) {
|
|
15
|
+
if (value == null) return [];
|
|
16
|
+
if (!Array.isArray(value)) throw new TypeError("Semantic query list fields must be arrays");
|
|
17
|
+
const output = [...new Set(value.map((item) => String(item).trim()).filter(Boolean))].slice(0, limit);
|
|
18
|
+
if (allowed && output.some((item) => !allowed.has(item))) throw new TypeError("Semantic query contains an unsupported value");
|
|
19
|
+
return output;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function boundedString(value, name, maximum = 4_000) {
|
|
23
|
+
const text = String(value ?? "").trim();
|
|
24
|
+
if (!text || text.length > maximum) throw new TypeError(`${name} must contain 1-${maximum} characters`);
|
|
25
|
+
return text;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function timestamp(value, name) {
|
|
29
|
+
if (value == null || value === "") return null;
|
|
30
|
+
const parsed = new Date(value);
|
|
31
|
+
if (!Number.isFinite(parsed.getTime())) throw new TypeError(`${name} must be an ISO-8601 timestamp`);
|
|
32
|
+
return parsed.toISOString();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function defaultPlan(question, now = new Date()) {
|
|
36
|
+
const lower = question.toLowerCase();
|
|
37
|
+
const severities = [...SEVERITIES].filter((severity) => new RegExp(`\\b${severity}\\b`, "u").test(lower));
|
|
38
|
+
const sourceTypes = [];
|
|
39
|
+
for (const [alias, source] of SOURCE_ALIASES) {
|
|
40
|
+
if (new RegExp(`\\b${alias}\\b`, "u").test(lower)) sourceTypes.push(source);
|
|
41
|
+
}
|
|
42
|
+
const targets = ["events"];
|
|
43
|
+
if (/\b(video|scene|recording|mcap|image|artifact|file)\b/u.test(lower)) targets.push("artifacts");
|
|
44
|
+
if (/\b(correlat|relationship|related|graph|trace|neighbor)\w*\b/u.test(lower)) targets.push("graph");
|
|
45
|
+
let from = null;
|
|
46
|
+
const recent = lower.match(/\b(?:last|past)\s+(\d{1,4})\s*(minute|hour|day|week)s?\b/u);
|
|
47
|
+
if (recent) {
|
|
48
|
+
const multipliers = { minute: 60_000, hour: 3_600_000, day: 86_400_000, week: 604_800_000 };
|
|
49
|
+
from = new Date(now.getTime() - Number(recent[1]) * multipliers[recent[2]]).toISOString();
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
version: "1.0",
|
|
53
|
+
question,
|
|
54
|
+
mode: "hybrid",
|
|
55
|
+
targets: [...new Set(targets)],
|
|
56
|
+
filters: { source_types: [...new Set(sourceTypes)], event_types: [], severities, from, to: null },
|
|
57
|
+
options: { limit: 20, max_scan: 300, top_k: 10, graph_hops: 1 },
|
|
58
|
+
planner: { kind: "deterministic", model: null },
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function normalizeSemanticQuery(input, { question, planner = {} } = {}) {
|
|
63
|
+
const raw = typeof input === "string" ? defaultPlan(boundedString(input, "question")) : input;
|
|
64
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new TypeError("Semantic query must be an object");
|
|
65
|
+
for (const forbidden of ["tenant_id", "pipeline_id", "base_url", "authorization", "token"])
|
|
66
|
+
if (Object.hasOwn(raw, forbidden)) throw new TypeError(`Semantic query cannot set ${forbidden}`);
|
|
67
|
+
const actualQuestion = boundedString(question ?? raw.question, "question");
|
|
68
|
+
const filters = raw.filters && typeof raw.filters === "object" ? raw.filters : {};
|
|
69
|
+
const options = raw.options && typeof raw.options === "object" ? raw.options : {};
|
|
70
|
+
const mode = String(raw.mode || "hybrid");
|
|
71
|
+
if (!MODES.has(mode)) throw new TypeError("Unsupported semantic query mode");
|
|
72
|
+
const targets = list(raw.targets?.length ? raw.targets : ["events"], { allowed: TARGETS, limit: 3 });
|
|
73
|
+
const from = timestamp(filters.from, "filters.from");
|
|
74
|
+
const to = timestamp(filters.to, "filters.to");
|
|
75
|
+
if (from && to && Date.parse(from) > Date.parse(to)) throw new TypeError("filters.from must not be after filters.to");
|
|
76
|
+
const normalized = {
|
|
77
|
+
version: "1.0",
|
|
78
|
+
question: actualQuestion,
|
|
79
|
+
mode,
|
|
80
|
+
targets,
|
|
81
|
+
filters: {
|
|
82
|
+
source_types: list(filters.source_types, { limit: 20 }),
|
|
83
|
+
event_types: list(filters.event_types, { limit: 20 }),
|
|
84
|
+
severities: list(filters.severities, { allowed: SEVERITIES, limit: 5 }),
|
|
85
|
+
keywords: list(filters.keywords, { limit: 32 }),
|
|
86
|
+
device_id: filters.device_id ? boundedString(filters.device_id, "filters.device_id", 200) : null,
|
|
87
|
+
run_id: filters.run_id ? boundedString(filters.run_id, "filters.run_id", 300) : null,
|
|
88
|
+
session_id: filters.session_id ? boundedString(filters.session_id, "filters.session_id", 200) : null,
|
|
89
|
+
relationship: filters.relationship ? boundedString(filters.relationship, "filters.relationship", 100) : null,
|
|
90
|
+
rule_id: filters.rule_id ? boundedString(filters.rule_id, "filters.rule_id", 200) : null,
|
|
91
|
+
from,
|
|
92
|
+
to,
|
|
93
|
+
},
|
|
94
|
+
options: {
|
|
95
|
+
limit: Math.min(Math.max(Number(options.limit) || 20, 1), 100),
|
|
96
|
+
max_scan: Math.min(Math.max(Number(options.max_scan) || 300, 1), 500),
|
|
97
|
+
top_k: Math.min(Math.max(Number(options.top_k) || 10, 1), 50),
|
|
98
|
+
graph_hops: Math.min(Math.max(Number(options.graph_hops) || 1, 0), 3),
|
|
99
|
+
},
|
|
100
|
+
planner: {
|
|
101
|
+
kind: String(planner.kind || raw.planner?.kind || "deterministic"),
|
|
102
|
+
model: planner.model || raw.planner?.model || null,
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
normalized.plan_hash = createHash("sha256").update(JSON.stringify(normalized)).digest("hex");
|
|
106
|
+
return normalized;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export class DeterministicSemanticPlanner {
|
|
110
|
+
plan(question) { return normalizeSemanticQuery(defaultPlan(boundedString(question, "question"))); }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export class ModelSemanticPlanner {
|
|
114
|
+
constructor({ generateObject, model = "unspecified", fallback = new DeterministicSemanticPlanner() }) {
|
|
115
|
+
if (typeof generateObject !== "function") throw new TypeError("generateObject must be a function");
|
|
116
|
+
this.generateObject = generateObject;
|
|
117
|
+
this.model = model;
|
|
118
|
+
this.fallback = fallback;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async plan(question) {
|
|
122
|
+
const cleanQuestion = boundedString(question, "question");
|
|
123
|
+
try {
|
|
124
|
+
const candidate = await this.generateObject({
|
|
125
|
+
question: cleanQuestion,
|
|
126
|
+
contract: "semantic-query-plan/1.0",
|
|
127
|
+
allowed_targets: [...TARGETS],
|
|
128
|
+
allowed_modes: [...MODES],
|
|
129
|
+
allowed_severities: [...SEVERITIES],
|
|
130
|
+
instruction: "Return only a query plan. Never produce tenant, pipeline, URL, token, SQL, or authorization fields.",
|
|
131
|
+
});
|
|
132
|
+
return normalizeSemanticQuery(candidate, {
|
|
133
|
+
question: cleanQuestion,
|
|
134
|
+
planner: { kind: "model", model: this.model },
|
|
135
|
+
});
|
|
136
|
+
} catch (error) {
|
|
137
|
+
const plan = await this.fallback.plan(cleanQuestion);
|
|
138
|
+
return {
|
|
139
|
+
...normalizeSemanticQuery(plan, { question: cleanQuestion, planner: { kind: "deterministic_fallback", model: this.model } }),
|
|
140
|
+
planner_error: error?.name || "invalid_model_plan",
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|