@iris-eval/mcp-server 0.3.0 → 0.4.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -3
- package/dist/audit-log-reader.d.ts +24 -0
- package/dist/audit-log-reader.js +87 -0
- package/dist/config/defaults.js +7 -1
- package/dist/custom-rule-store.d.ts +27 -0
- package/dist/custom-rule-store.js +188 -0
- package/dist/dashboard/assets/index-BEG5FYWH.css +1 -0
- package/dist/dashboard/assets/index-D9JHfSB2.js +12 -0
- package/dist/dashboard/index.html +2 -2
- package/dist/dashboard/routes/audit.d.ts +3 -0
- package/dist/dashboard/routes/audit.js +36 -0
- package/dist/dashboard/routes/eval-stats.js +9 -4
- package/dist/dashboard/routes/evaluations.js +3 -1
- package/dist/dashboard/routes/filters.js +5 -3
- package/dist/dashboard/routes/health.js +8 -1
- package/dist/dashboard/routes/index.d.ts +4 -0
- package/dist/dashboard/routes/index.js +4 -0
- package/dist/dashboard/routes/moments.d.ts +3 -0
- package/dist/dashboard/routes/moments.js +115 -0
- package/dist/dashboard/routes/preferences.d.ts +3 -0
- package/dist/dashboard/routes/preferences.js +52 -0
- package/dist/dashboard/routes/rules.d.ts +10 -0
- package/dist/dashboard/routes/rules.js +169 -0
- package/dist/dashboard/routes/summary.js +3 -1
- package/dist/dashboard/routes/traces.js +7 -4
- package/dist/dashboard/server.d.ts +9 -1
- package/dist/dashboard/server.js +52 -3
- package/dist/eval/citation-verify/extract.d.ts +11 -0
- package/dist/eval/citation-verify/extract.js +102 -0
- package/dist/eval/citation-verify/resolve.d.ts +26 -0
- package/dist/eval/citation-verify/resolve.js +237 -0
- package/dist/eval/citation-verify/verifier.d.ts +43 -0
- package/dist/eval/citation-verify/verifier.js +203 -0
- package/dist/eval/decision-moment.d.ts +12 -0
- package/dist/eval/decision-moment.js +181 -0
- package/dist/eval/llm-judge/client.d.ts +28 -0
- package/dist/eval/llm-judge/client.js +183 -0
- package/dist/eval/llm-judge/evaluator.d.ts +32 -0
- package/dist/eval/llm-judge/evaluator.js +138 -0
- package/dist/eval/llm-judge/pricing.d.ts +9 -0
- package/dist/eval/llm-judge/pricing.js +31 -0
- package/dist/eval/llm-judge/templates/index.d.ts +20 -0
- package/dist/eval/llm-judge/templates/index.js +170 -0
- package/dist/eval/rules/custom.js +13 -2
- package/dist/eval/rules/relevance.js +51 -6
- package/dist/eval/rules/safety.d.ts +1 -0
- package/dist/eval/rules/safety.js +87 -3
- package/dist/index.js +77 -14
- package/dist/middleware/index.d.ts +1 -0
- package/dist/middleware/index.js +1 -0
- package/dist/middleware/tenant.d.ts +17 -0
- package/dist/middleware/tenant.js +26 -0
- package/dist/otel/exporter.d.ts +24 -0
- package/dist/otel/exporter.js +116 -0
- package/dist/otel/lazy.d.ts +5 -0
- package/dist/otel/lazy.js +31 -0
- package/dist/otel/mapper.d.ts +24 -0
- package/dist/otel/mapper.js +208 -0
- package/dist/preferences.d.ts +129 -0
- package/dist/preferences.js +152 -0
- package/dist/resources/dashboard-summary.js +3 -1
- package/dist/resources/trace-detail.js +5 -3
- package/dist/server.d.ts +3 -1
- package/dist/server.js +9 -3
- package/dist/storage/migrations/004-tenant-id.d.ts +3 -0
- package/dist/storage/migrations/004-tenant-id.js +40 -0
- package/dist/storage/migrations/index.js +2 -1
- package/dist/storage/sqlite-adapter.d.ts +17 -15
- package/dist/storage/sqlite-adapter.js +130 -79
- package/dist/tools/delete-rule.d.ts +3 -0
- package/dist/tools/delete-rule.js +53 -0
- package/dist/tools/delete-trace.d.ts +3 -0
- package/dist/tools/delete-trace.js +54 -0
- package/dist/tools/deploy-rule.d.ts +3 -0
- package/dist/tools/deploy-rule.js +91 -0
- package/dist/tools/evaluate-output.js +23 -2
- package/dist/tools/evaluate-with-llm-judge.d.ts +3 -0
- package/dist/tools/evaluate-with-llm-judge.js +147 -0
- package/dist/tools/get-traces.js +23 -3
- package/dist/tools/index.d.ts +2 -1
- package/dist/tools/index.js +13 -1
- package/dist/tools/list-rules.d.ts +3 -0
- package/dist/tools/list-rules.js +66 -0
- package/dist/tools/log-trace.js +30 -2
- package/dist/tools/verify-citations.d.ts +3 -0
- package/dist/tools/verify-citations.js +157 -0
- package/dist/types/custom-rule.d.ts +70 -0
- package/dist/types/custom-rule.js +1 -0
- package/dist/types/decision-moment.d.ts +122 -0
- package/dist/types/decision-moment.js +17 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.js +1 -1
- package/dist/types/query.d.ts +25 -15
- package/dist/types/tenant.d.ts +26 -0
- package/dist/types/tenant.js +58 -0
- package/dist/utils/open-browser.d.ts +1 -0
- package/dist/utils/open-browser.js +45 -0
- package/dist/utils/validate-port-config.d.ts +2 -0
- package/dist/utils/validate-port-config.js +9 -0
- package/package.json +4 -1
- package/server.json +2 -2
- package/dist/dashboard/assets/index-CnDg6bYi.js +0 -43
- package/dist/dashboard/assets/index-ZsBou2-c.css +0 -1
package/dist/index.js
CHANGED
|
@@ -8,6 +8,12 @@ import { createStdioTransport } from './transport/stdio.js';
|
|
|
8
8
|
import { createHttpTransport } from './transport/http.js';
|
|
9
9
|
import { createDashboardServer } from './dashboard/server.js';
|
|
10
10
|
import { createLogger } from './utils/logger.js';
|
|
11
|
+
import { loadOrInitPreferences, shouldAutoLaunchDashboard, createPreferenceStore } from './preferences.js';
|
|
12
|
+
import { openBrowser } from './utils/open-browser.js';
|
|
13
|
+
import { createCustomRuleStore } from './custom-rule-store.js';
|
|
14
|
+
import { createCustomRule } from './eval/rules/custom.js';
|
|
15
|
+
import { LOCAL_TENANT } from './types/tenant.js';
|
|
16
|
+
import { validatePortConfig } from './utils/validate-port-config.js';
|
|
11
17
|
const PortSchema = z
|
|
12
18
|
.string()
|
|
13
19
|
.regex(/^\d+$/, 'must be a positive integer')
|
|
@@ -71,16 +77,29 @@ Options:
|
|
|
71
77
|
-h, --help Show this help message
|
|
72
78
|
|
|
73
79
|
Environment variables (CLI flags take precedence):
|
|
74
|
-
IRIS_TRANSPORT
|
|
75
|
-
IRIS_HOST
|
|
76
|
-
IRIS_PORT
|
|
77
|
-
IRIS_DB_PATH
|
|
78
|
-
IRIS_LOG_LEVEL
|
|
79
|
-
IRIS_DASHBOARD
|
|
80
|
-
IRIS_DASHBOARD_PORT
|
|
81
|
-
IRIS_API_KEY
|
|
82
|
-
IRIS_ALLOWED_ORIGINS
|
|
83
|
-
|
|
80
|
+
IRIS_TRANSPORT stdio | http
|
|
81
|
+
IRIS_HOST Bind address for HTTP transport (default: 127.0.0.1)
|
|
82
|
+
IRIS_PORT HTTP transport port (1-65535)
|
|
83
|
+
IRIS_DB_PATH SQLite database path
|
|
84
|
+
IRIS_LOG_LEVEL debug | info | warn | error
|
|
85
|
+
IRIS_DASHBOARD true to enable web dashboard
|
|
86
|
+
IRIS_DASHBOARD_PORT Dashboard port (1-65535, default: 6920)
|
|
87
|
+
IRIS_API_KEY API key for HTTP authentication
|
|
88
|
+
IRIS_ALLOWED_ORIGINS Comma-separated CORS origin allowlist
|
|
89
|
+
IRIS_NO_AUTO_LAUNCH Set to 1 to disable first-run dashboard auto-launch
|
|
90
|
+
IRIS_ANTHROPIC_API_KEY Required by evaluate_with_llm_judge + verify_citations (provider=anthropic)
|
|
91
|
+
IRIS_OPENAI_API_KEY Required by evaluate_with_llm_judge + verify_citations (provider=openai)
|
|
92
|
+
IRIS_LLM_JUDGE_MAX_COST_USD_PER_EVAL Hard cost cap per LLM judge call (default: 0.25)
|
|
93
|
+
IRIS_CITATION_ALLOW_FETCH Set to 1 to permit outbound HTTP in verify_citations (off by default)
|
|
94
|
+
IRIS_CITATION_DOMAINS Comma-separated hostname allowlist for verify_citations (suffix match)
|
|
95
|
+
IRIS_OTEL_ENDPOINT Enable best-effort OTLP/HTTP JSON trace export to this collector URL
|
|
96
|
+
IRIS_OTEL_SERVICE_NAME service.name resource attribute for OTel export (default: iris-mcp)
|
|
97
|
+
IRIS_OTEL_HEADERS Comma-separated k=v headers for OTel export (e.g. "authorization=Bearer abc")
|
|
98
|
+
IRIS_OTEL_TIMEOUT_MS Per-export timeout (default: 15000)
|
|
99
|
+
RATE_LIMIT_SALT (waitlist API only — required when website is deployed)
|
|
100
|
+
|
|
101
|
+
Dashboard preferences (~/.iris/preferences.json):
|
|
102
|
+
Edit autoLaunch: false to permanently disable first-run dashboard auto-launch.
|
|
84
103
|
`);
|
|
85
104
|
process.exit(0);
|
|
86
105
|
}
|
|
@@ -96,15 +115,38 @@ const config = loadConfig({
|
|
|
96
115
|
const logger = createLogger(config);
|
|
97
116
|
async function main() {
|
|
98
117
|
logger.info(`Starting Iris MCP server v${config.server.version}`);
|
|
118
|
+
// F-006: fail fast on HTTP+dashboard port collision. See validatePortConfig.
|
|
119
|
+
validatePortConfig(config);
|
|
99
120
|
const storage = createStorage(config);
|
|
100
121
|
await storage.initialize();
|
|
101
122
|
logger.info(`Storage initialized (${config.storage.type}: ${config.storage.path})`);
|
|
102
|
-
|
|
123
|
+
// Load the custom rule store first so it can be shared between the
|
|
124
|
+
// MCP server (for deploy_rule / delete_rule / list_rules tools) and
|
|
125
|
+
// the HTTP dashboard (Make-This-A-Rule composer). A rule deployed
|
|
126
|
+
// via either surface is immediately visible from the other.
|
|
127
|
+
const customRuleStore = createCustomRuleStore();
|
|
128
|
+
const { mcpServer, evalEngine } = createIrisServer(config, storage, customRuleStore);
|
|
129
|
+
// Load deployed custom rules from ~/.iris/custom-rules.json (B3 — workflow inversion).
|
|
130
|
+
// Each enabled rule is registered with the engine under its evalType so it fires on
|
|
131
|
+
// every evaluate_output call of that category. Persistence via custom-rule-store.
|
|
132
|
+
const enabled = customRuleStore.enabledRules();
|
|
133
|
+
for (const rule of enabled) {
|
|
134
|
+
evalEngine.registerRule(rule.evalType, createCustomRule(rule.definition));
|
|
135
|
+
}
|
|
136
|
+
if (enabled.length > 0) {
|
|
137
|
+
logger.info(`Loaded ${enabled.length} deployed custom rule(s) from ${customRuleStore.filePath}`);
|
|
138
|
+
}
|
|
103
139
|
const httpServers = [];
|
|
104
|
-
// Run data retention cleanup on startup
|
|
140
|
+
// Run data retention cleanup on startup.
|
|
141
|
+
//
|
|
142
|
+
// For OSS single-tenant installs we explicitly scope cleanup to
|
|
143
|
+
// LOCAL_TENANT. Cloud will enumerate all tenants (TenantRegistry) and
|
|
144
|
+
// call this per-tenant so retention applies uniformly; the adapter
|
|
145
|
+
// method already scopes DELETEs by tenant, so the behavior scales
|
|
146
|
+
// cleanly.
|
|
105
147
|
if (config.retention.days > 0) {
|
|
106
148
|
try {
|
|
107
|
-
const deleted = await storage.deleteTracesOlderThan(config.retention.days);
|
|
149
|
+
const deleted = await storage.deleteTracesOlderThan(LOCAL_TENANT, config.retention.days);
|
|
108
150
|
if (deleted > 0) {
|
|
109
151
|
logger.info(`Retention cleanup: deleted ${deleted} trace(s) older than ${config.retention.days} days`);
|
|
110
152
|
}
|
|
@@ -130,9 +172,30 @@ async function main() {
|
|
|
130
172
|
}
|
|
131
173
|
}
|
|
132
174
|
if (config.dashboard.enabled || config.transport.type === 'http') {
|
|
133
|
-
const
|
|
175
|
+
const preferenceStore = createPreferenceStore();
|
|
176
|
+
const dashboardServer = createDashboardServer(storage, config, logger, {
|
|
177
|
+
customRuleStore,
|
|
178
|
+
evalEngine,
|
|
179
|
+
preferenceStore,
|
|
180
|
+
});
|
|
134
181
|
const server = dashboardServer.start();
|
|
135
182
|
httpServers.push(server);
|
|
183
|
+
// First-run auto-launch (B7): on first dashboard launch, open the
|
|
184
|
+
// dashboard in the user's default browser. Skipped in CI, when the
|
|
185
|
+
// user has previously set autoLaunch=false in ~/.iris/preferences.json,
|
|
186
|
+
// or when IRIS_NO_AUTO_LAUNCH=1 is set.
|
|
187
|
+
if (config.dashboard.enabled) {
|
|
188
|
+
const prefState = loadOrInitPreferences();
|
|
189
|
+
if (prefState.isFirstRun && shouldAutoLaunchDashboard(prefState)) {
|
|
190
|
+
const url = `http://localhost:${config.dashboard.port}`;
|
|
191
|
+
logger.info(`First run detected — opening dashboard at ${url}`);
|
|
192
|
+
logger.info(`(To disable auto-launch: set IRIS_NO_AUTO_LAUNCH=1 or edit ${prefState.path})`);
|
|
193
|
+
openBrowser(url);
|
|
194
|
+
}
|
|
195
|
+
else if (prefState.isFirstRun) {
|
|
196
|
+
logger.info(`First run detected — skipping auto-launch (CI/IRIS_NO_AUTO_LAUNCH set). Dashboard at http://localhost:${config.dashboard.port}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
136
199
|
}
|
|
137
200
|
if (config.security.apiKey) {
|
|
138
201
|
logger.info('API key authentication enabled');
|
|
@@ -2,3 +2,4 @@ export { createAuthMiddleware } from './auth.js';
|
|
|
2
2
|
export { createCorsMiddleware } from './cors.js';
|
|
3
3
|
export { createErrorHandler } from './error-handler.js';
|
|
4
4
|
export { createApiRateLimiter, createMcpRateLimiter } from './rate-limit.js';
|
|
5
|
+
export { createTenantMiddleware, requireTenant } from './tenant.js';
|
package/dist/middleware/index.js
CHANGED
|
@@ -2,3 +2,4 @@ export { createAuthMiddleware } from './auth.js';
|
|
|
2
2
|
export { createCorsMiddleware } from './cors.js';
|
|
3
3
|
export { createErrorHandler } from './error-handler.js';
|
|
4
4
|
export { createApiRateLimiter, createMcpRateLimiter } from './rate-limit.js';
|
|
5
|
+
export { createTenantMiddleware, requireTenant } from './tenant.js';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { RequestHandler, Request } from 'express';
|
|
2
|
+
import type { TenantId } from '../types/tenant.js';
|
|
3
|
+
declare module 'express-serve-static-core' {
|
|
4
|
+
interface Request {
|
|
5
|
+
tenantId?: TenantId;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
/** Read `req.tenantId` with a fail-safe guarantee for downstream code. */
|
|
9
|
+
export declare function requireTenant(req: Request): TenantId;
|
|
10
|
+
/**
|
|
11
|
+
* OSS tenant resolver. Every request is the single local user.
|
|
12
|
+
*
|
|
13
|
+
* Cloud deployments swap this for an auth-aware resolver that reads the
|
|
14
|
+
* session. The route handlers don't change — they all just call
|
|
15
|
+
* requireTenant(req).
|
|
16
|
+
*/
|
|
17
|
+
export declare function createTenantMiddleware(): RequestHandler;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { LOCAL_TENANT } from '../types/tenant.js';
|
|
2
|
+
/** Read `req.tenantId` with a fail-safe guarantee for downstream code. */
|
|
3
|
+
export function requireTenant(req) {
|
|
4
|
+
const t = req.tenantId;
|
|
5
|
+
if (!t) {
|
|
6
|
+
/* Defense in depth. Should never happen if tenant middleware is
|
|
7
|
+
* mounted before routes, but we fail safe rather than fall back to
|
|
8
|
+
* LOCAL_TENANT because a missing tenant id is a code path bug, not
|
|
9
|
+
* a fallback case. */
|
|
10
|
+
throw new Error('req.tenantId missing; tenant middleware not mounted?');
|
|
11
|
+
}
|
|
12
|
+
return t;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* OSS tenant resolver. Every request is the single local user.
|
|
16
|
+
*
|
|
17
|
+
* Cloud deployments swap this for an auth-aware resolver that reads the
|
|
18
|
+
* session. The route handlers don't change — they all just call
|
|
19
|
+
* requireTenant(req).
|
|
20
|
+
*/
|
|
21
|
+
export function createTenantMiddleware() {
|
|
22
|
+
return (req, _res, next) => {
|
|
23
|
+
req.tenantId = LOCAL_TENANT;
|
|
24
|
+
next();
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Trace } from '../types/trace.js';
|
|
2
|
+
export interface OtelExporterConfig {
|
|
3
|
+
endpoint: string;
|
|
4
|
+
serviceName: string;
|
|
5
|
+
headers?: Record<string, string>;
|
|
6
|
+
timeoutMs?: number;
|
|
7
|
+
pathPrefix?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface OtelExportResult {
|
|
10
|
+
ok: boolean;
|
|
11
|
+
status: number;
|
|
12
|
+
bytesSent: number;
|
|
13
|
+
latencyMs: number;
|
|
14
|
+
error?: string;
|
|
15
|
+
}
|
|
16
|
+
export declare class OtelExporter {
|
|
17
|
+
private readonly endpoint;
|
|
18
|
+
private readonly serviceName;
|
|
19
|
+
private readonly headers;
|
|
20
|
+
private readonly timeoutMs;
|
|
21
|
+
constructor(config: OtelExporterConfig);
|
|
22
|
+
exportTraces(traces: readonly Trace[]): Promise<OtelExportResult>;
|
|
23
|
+
}
|
|
24
|
+
export declare function exporterFromEnv(): OtelExporter | null;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// OTLP/HTTP JSON exporter — posts ExportTraceServiceRequest payloads to
|
|
2
|
+
// an OpenTelemetry collector, Jaeger, Grafana Tempo, or Datadog OTLP
|
|
3
|
+
// ingest endpoint. Works against any receiver that speaks OTLP/HTTP
|
|
4
|
+
// JSON at /v1/traces (the OTLP spec canonical path).
|
|
5
|
+
//
|
|
6
|
+
// Hand-rolled against the OTLP spec rather than pulling in @opentelemetry/*
|
|
7
|
+
// — consistent with the LLM client + citation resolver approach (keep
|
|
8
|
+
// supply-chain surface small, the wire format is simple). gRPC transport
|
|
9
|
+
// is not included in v0.4.0; callers with gRPC-only receivers should
|
|
10
|
+
// front it with an HTTP-accepting collector (Jaeger/Tempo/OTEL Collector
|
|
11
|
+
// accept HTTP natively).
|
|
12
|
+
//
|
|
13
|
+
// Batch behavior: callers (typically log_trace handler) accumulate Trace
|
|
14
|
+
// rows and call exportTraces([...]) at convenient points. The exporter
|
|
15
|
+
// does NOT own a timer — keeping it stateless keeps it testable and
|
|
16
|
+
// avoids accidentally leaking a process-wide interval in tests.
|
|
17
|
+
import { buildExportPayload } from './mapper.js';
|
|
18
|
+
export class OtelExporter {
|
|
19
|
+
endpoint;
|
|
20
|
+
serviceName;
|
|
21
|
+
headers;
|
|
22
|
+
timeoutMs;
|
|
23
|
+
constructor(config) {
|
|
24
|
+
// Normalize endpoint — strip trailing slash; append /v1/traces if
|
|
25
|
+
// the caller gave a collector root. This matches the behavior of
|
|
26
|
+
// the official OTLP/HTTP clients.
|
|
27
|
+
const base = config.endpoint.replace(/\/+$/, '');
|
|
28
|
+
const path = config.pathPrefix ?? '/v1/traces';
|
|
29
|
+
this.endpoint = base.endsWith(path) ? base : base + path;
|
|
30
|
+
this.serviceName = config.serviceName;
|
|
31
|
+
this.headers = { ...(config.headers ?? {}) };
|
|
32
|
+
this.timeoutMs = config.timeoutMs ?? 15_000;
|
|
33
|
+
}
|
|
34
|
+
async exportTraces(traces) {
|
|
35
|
+
if (traces.length === 0) {
|
|
36
|
+
return { ok: true, status: 204, bytesSent: 0, latencyMs: 0 };
|
|
37
|
+
}
|
|
38
|
+
const payload = buildExportPayload(traces, this.serviceName);
|
|
39
|
+
const body = JSON.stringify(payload);
|
|
40
|
+
const started = Date.now();
|
|
41
|
+
const controller = new AbortController();
|
|
42
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
43
|
+
try {
|
|
44
|
+
const res = await fetch(this.endpoint, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
signal: controller.signal,
|
|
47
|
+
headers: {
|
|
48
|
+
'content-type': 'application/json',
|
|
49
|
+
...this.headers,
|
|
50
|
+
},
|
|
51
|
+
body,
|
|
52
|
+
});
|
|
53
|
+
const latencyMs = Date.now() - started;
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
const text = await res.text().catch(() => '');
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
status: res.status,
|
|
59
|
+
bytesSent: body.length,
|
|
60
|
+
latencyMs,
|
|
61
|
+
error: `OTLP exporter got ${res.status}: ${text.slice(0, 200)}`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
ok: true,
|
|
66
|
+
status: res.status,
|
|
67
|
+
bytesSent: body.length,
|
|
68
|
+
latencyMs,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
const latencyMs = Date.now() - started;
|
|
73
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
status: 0,
|
|
77
|
+
bytesSent: body.length,
|
|
78
|
+
latencyMs,
|
|
79
|
+
error: msg,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export function exporterFromEnv() {
|
|
88
|
+
const endpoint = process.env.IRIS_OTEL_ENDPOINT;
|
|
89
|
+
if (!endpoint)
|
|
90
|
+
return null;
|
|
91
|
+
const serviceName = process.env.IRIS_OTEL_SERVICE_NAME ?? 'iris-mcp';
|
|
92
|
+
// IRIS_OTEL_HEADERS is a comma-separated list of key=value pairs —
|
|
93
|
+
// the OTel Collector convention. e.g. "authorization=Bearer xyz,x-tenant=foo".
|
|
94
|
+
let headers;
|
|
95
|
+
const rawHeaders = process.env.IRIS_OTEL_HEADERS;
|
|
96
|
+
if (rawHeaders) {
|
|
97
|
+
headers = {};
|
|
98
|
+
for (const pair of rawHeaders.split(',')) {
|
|
99
|
+
const eq = pair.indexOf('=');
|
|
100
|
+
if (eq < 1)
|
|
101
|
+
continue;
|
|
102
|
+
const k = pair.slice(0, eq).trim();
|
|
103
|
+
const v = pair.slice(eq + 1).trim();
|
|
104
|
+
if (k && v)
|
|
105
|
+
headers[k] = v;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const timeoutRaw = process.env.IRIS_OTEL_TIMEOUT_MS;
|
|
109
|
+
const timeoutMs = timeoutRaw ? Number(timeoutRaw) : undefined;
|
|
110
|
+
return new OtelExporter({
|
|
111
|
+
endpoint,
|
|
112
|
+
serviceName,
|
|
113
|
+
headers,
|
|
114
|
+
timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : undefined,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { OtelExporter } from './exporter.js';
|
|
2
|
+
import type { Trace } from '../types/trace.js';
|
|
3
|
+
export declare function getLazyExporter(): OtelExporter | null;
|
|
4
|
+
export declare function __resetExporterForTests(override?: OtelExporter | null): void;
|
|
5
|
+
export declare function bestEffortExport(trace: Trace, onError?: (err: Error) => void): void;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Process-wide lazy exporter — initialized on first use, then reused.
|
|
2
|
+
// Tests may reset it via __resetExporterForTests.
|
|
3
|
+
import { exporterFromEnv } from './exporter.js';
|
|
4
|
+
let cachedExporter; // undefined = not yet resolved
|
|
5
|
+
export function getLazyExporter() {
|
|
6
|
+
if (cachedExporter === undefined) {
|
|
7
|
+
cachedExporter = exporterFromEnv();
|
|
8
|
+
}
|
|
9
|
+
return cachedExporter;
|
|
10
|
+
}
|
|
11
|
+
export function __resetExporterForTests(override) {
|
|
12
|
+
cachedExporter = override === undefined ? undefined : override;
|
|
13
|
+
}
|
|
14
|
+
// Fire-and-forget wrapper with inline error swallow. Runs in the
|
|
15
|
+
// background; the caller does NOT await. We log on failure so operators
|
|
16
|
+
// aren't surprised by silent drop, but never let an exporter problem
|
|
17
|
+
// bubble into user-visible tool errors.
|
|
18
|
+
export function bestEffortExport(trace, onError) {
|
|
19
|
+
const exporter = getLazyExporter();
|
|
20
|
+
if (!exporter)
|
|
21
|
+
return;
|
|
22
|
+
exporter
|
|
23
|
+
.exportTraces([trace])
|
|
24
|
+
.then((result) => {
|
|
25
|
+
if (!result.ok) {
|
|
26
|
+
const msg = `OTel export failed: status=${result.status} ${result.error ?? ''}`.trim();
|
|
27
|
+
onError?.(new Error(msg));
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
.catch((err) => onError?.(err));
|
|
31
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Span, Trace } from '../types/trace.js';
|
|
2
|
+
export interface OtlpKeyValue {
|
|
3
|
+
key: string;
|
|
4
|
+
value: OtlpAnyValue;
|
|
5
|
+
}
|
|
6
|
+
export type OtlpAnyValue = {
|
|
7
|
+
stringValue: string;
|
|
8
|
+
} | {
|
|
9
|
+
boolValue: boolean;
|
|
10
|
+
} | {
|
|
11
|
+
intValue: string;
|
|
12
|
+
} | {
|
|
13
|
+
doubleValue: number;
|
|
14
|
+
} | {
|
|
15
|
+
arrayValue: {
|
|
16
|
+
values: OtlpAnyValue[];
|
|
17
|
+
};
|
|
18
|
+
} | {
|
|
19
|
+
kvlistValue: {
|
|
20
|
+
values: OtlpKeyValue[];
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
export declare function mapSpan(span: Span, traceIdOverride?: string): unknown;
|
|
24
|
+
export declare function buildExportPayload(traces: readonly Trace[], serviceName: string): unknown;
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// Map Iris trace/span types to OTLP JSON shape for export.
|
|
2
|
+
//
|
|
3
|
+
// Reference: https://opentelemetry.io/docs/specs/otlp/#otlphttp-json-encoding
|
|
4
|
+
// and the ExportTraceServiceRequest proto. OTLP JSON encodes uint64 IDs
|
|
5
|
+
// as lowercase hex strings; trace_id is 16 bytes (32 hex chars), span_id
|
|
6
|
+
// is 8 bytes (16 hex chars). Timestamps are nanoseconds since epoch as
|
|
7
|
+
// strings (uint64 range exceeds JS Number.MAX_SAFE_INTEGER so we emit as
|
|
8
|
+
// decimal string).
|
|
9
|
+
//
|
|
10
|
+
// We intentionally do not depend on @opentelemetry/* — the exporter
|
|
11
|
+
// serializes these plain objects, and the payload shape is the OTLP spec.
|
|
12
|
+
// OTel SpanKind enum values (from opentelemetry-proto/trace/v1/trace.proto).
|
|
13
|
+
const KIND_MAP = {
|
|
14
|
+
INTERNAL: 1,
|
|
15
|
+
SERVER: 2,
|
|
16
|
+
CLIENT: 3,
|
|
17
|
+
PRODUCER: 4,
|
|
18
|
+
CONSUMER: 5,
|
|
19
|
+
// Iris-specific kinds — map to INTERNAL for OTel. The actual LLM /
|
|
20
|
+
// tool semantics are preserved in attributes (llm.provider, tool.name).
|
|
21
|
+
LLM: 1,
|
|
22
|
+
TOOL: 1,
|
|
23
|
+
};
|
|
24
|
+
const STATUS_MAP = {
|
|
25
|
+
UNSET: 0,
|
|
26
|
+
OK: 1,
|
|
27
|
+
ERROR: 2,
|
|
28
|
+
};
|
|
29
|
+
// Convert "abcdef0123..." (any length hex) to exactly 32 hex chars, or
|
|
30
|
+
// if non-hex, hash it. Iris sometimes generates IDs like "mcp-<uuid>"
|
|
31
|
+
// that aren't pure hex.
|
|
32
|
+
function toTraceIdHex(raw) {
|
|
33
|
+
const hex = raw.replace(/-/g, '').toLowerCase();
|
|
34
|
+
if (/^[0-9a-f]{32}$/.test(hex))
|
|
35
|
+
return hex;
|
|
36
|
+
if (/^[0-9a-f]{16,}$/.test(hex))
|
|
37
|
+
return hex.slice(0, 32).padEnd(32, '0');
|
|
38
|
+
// Fallback: hash to 32 hex chars deterministically.
|
|
39
|
+
return djb2Hex(raw, 32);
|
|
40
|
+
}
|
|
41
|
+
function toSpanIdHex(raw) {
|
|
42
|
+
const hex = raw.replace(/-/g, '').toLowerCase();
|
|
43
|
+
if (/^[0-9a-f]{16}$/.test(hex))
|
|
44
|
+
return hex;
|
|
45
|
+
if (/^[0-9a-f]{8,}$/.test(hex))
|
|
46
|
+
return hex.slice(0, 16).padEnd(16, '0');
|
|
47
|
+
return djb2Hex(raw, 16);
|
|
48
|
+
}
|
|
49
|
+
// Tiny deterministic string → hex hash. Not cryptographic — just to give
|
|
50
|
+
// OTel consumers a stable id when Iris produced a non-hex identifier.
|
|
51
|
+
function djb2Hex(s, hexLen) {
|
|
52
|
+
let h = 5381n;
|
|
53
|
+
for (let i = 0; i < s.length; i++) {
|
|
54
|
+
h = ((h << 5n) + h + BigInt(s.charCodeAt(i))) & ((1n << 64n) - 1n);
|
|
55
|
+
}
|
|
56
|
+
// Use BigInt to avoid JS Number collisions on long strings.
|
|
57
|
+
let hex = h.toString(16);
|
|
58
|
+
while (hex.length < hexLen)
|
|
59
|
+
hex = hex + djb2Step(s + hex).toString(16);
|
|
60
|
+
return hex.slice(0, hexLen).padEnd(hexLen, '0');
|
|
61
|
+
}
|
|
62
|
+
function djb2Step(s) {
|
|
63
|
+
let h = 5381n;
|
|
64
|
+
for (let i = 0; i < s.length; i++) {
|
|
65
|
+
h = ((h << 5n) + h + BigInt(s.charCodeAt(i))) & ((1n << 64n) - 1n);
|
|
66
|
+
}
|
|
67
|
+
return h;
|
|
68
|
+
}
|
|
69
|
+
function toNanoString(iso) {
|
|
70
|
+
if (!iso)
|
|
71
|
+
return '0';
|
|
72
|
+
const ms = new Date(iso).getTime();
|
|
73
|
+
if (!Number.isFinite(ms))
|
|
74
|
+
return '0';
|
|
75
|
+
// ms → ns as string (BigInt-safe).
|
|
76
|
+
return (BigInt(ms) * 1000000n).toString();
|
|
77
|
+
}
|
|
78
|
+
function toAnyValue(v) {
|
|
79
|
+
if (typeof v === 'string')
|
|
80
|
+
return { stringValue: v };
|
|
81
|
+
if (typeof v === 'boolean')
|
|
82
|
+
return { boolValue: v };
|
|
83
|
+
if (typeof v === 'number') {
|
|
84
|
+
if (Number.isInteger(v))
|
|
85
|
+
return { intValue: String(v) };
|
|
86
|
+
return { doubleValue: v };
|
|
87
|
+
}
|
|
88
|
+
if (Array.isArray(v)) {
|
|
89
|
+
return { arrayValue: { values: v.map(toAnyValue) } };
|
|
90
|
+
}
|
|
91
|
+
if (v && typeof v === 'object') {
|
|
92
|
+
return {
|
|
93
|
+
kvlistValue: {
|
|
94
|
+
values: Object.entries(v).map(([k, val]) => ({
|
|
95
|
+
key: k,
|
|
96
|
+
value: toAnyValue(val),
|
|
97
|
+
})),
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
// null / undefined / bigint / fn / symbol — fall back to string
|
|
102
|
+
return { stringValue: String(v) };
|
|
103
|
+
}
|
|
104
|
+
function flattenAttrs(attrs) {
|
|
105
|
+
if (!attrs)
|
|
106
|
+
return [];
|
|
107
|
+
return Object.entries(attrs).map(([k, v]) => ({ key: k, value: toAnyValue(v) }));
|
|
108
|
+
}
|
|
109
|
+
// Iris Span → OTLP Span JSON shape.
|
|
110
|
+
export function mapSpan(span, traceIdOverride) {
|
|
111
|
+
const attributes = flattenAttrs(span.attributes);
|
|
112
|
+
// Surface Iris-specific kinds via attributes so OTel consumers can
|
|
113
|
+
// filter on iris.span_kind even though we mapped everything non-OTel to INTERNAL.
|
|
114
|
+
if (span.kind === 'LLM' || span.kind === 'TOOL') {
|
|
115
|
+
attributes.push({ key: 'iris.span_kind', value: { stringValue: span.kind } });
|
|
116
|
+
}
|
|
117
|
+
const events = (span.events ?? []).map((e) => ({
|
|
118
|
+
timeUnixNano: toNanoString(e.timestamp),
|
|
119
|
+
name: e.name,
|
|
120
|
+
attributes: flattenAttrs(e.attributes),
|
|
121
|
+
}));
|
|
122
|
+
return {
|
|
123
|
+
traceId: toTraceIdHex(traceIdOverride ?? span.trace_id),
|
|
124
|
+
spanId: toSpanIdHex(span.span_id),
|
|
125
|
+
parentSpanId: span.parent_span_id ? toSpanIdHex(span.parent_span_id) : undefined,
|
|
126
|
+
name: span.name,
|
|
127
|
+
kind: KIND_MAP[span.kind] ?? 1,
|
|
128
|
+
startTimeUnixNano: toNanoString(span.start_time),
|
|
129
|
+
endTimeUnixNano: toNanoString(span.end_time ?? span.start_time),
|
|
130
|
+
attributes,
|
|
131
|
+
events,
|
|
132
|
+
status: {
|
|
133
|
+
code: STATUS_MAP[span.status_code] ?? 0,
|
|
134
|
+
message: span.status_message,
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
// Build a full OTLP ExportTraceServiceRequest payload from an Iris trace.
|
|
139
|
+
// If the trace has no explicit spans, we synthesize one root span from
|
|
140
|
+
// the trace-level fields (agent_name + latency + cost + token_usage as
|
|
141
|
+
// attributes) so exports aren't empty for simple trace rows.
|
|
142
|
+
export function buildExportPayload(traces, serviceName) {
|
|
143
|
+
const resource = {
|
|
144
|
+
attributes: [
|
|
145
|
+
{ key: 'service.name', value: { stringValue: serviceName } },
|
|
146
|
+
{ key: 'telemetry.sdk.name', value: { stringValue: 'iris-mcp' } },
|
|
147
|
+
{ key: 'telemetry.sdk.language', value: { stringValue: 'nodejs' } },
|
|
148
|
+
{ key: 'telemetry.sdk.version', value: { stringValue: '0.4.0' } },
|
|
149
|
+
],
|
|
150
|
+
};
|
|
151
|
+
const spans = [];
|
|
152
|
+
for (const trace of traces) {
|
|
153
|
+
if (trace.spans && trace.spans.length > 0) {
|
|
154
|
+
for (const s of trace.spans)
|
|
155
|
+
spans.push(mapSpan(s, trace.trace_id));
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
// Synthesize root span from trace-level info.
|
|
159
|
+
const attrs = {
|
|
160
|
+
'iris.agent_name': trace.agent_name,
|
|
161
|
+
'iris.framework': trace.framework,
|
|
162
|
+
};
|
|
163
|
+
if (trace.input)
|
|
164
|
+
attrs['iris.input'] = truncate(trace.input);
|
|
165
|
+
if (trace.output)
|
|
166
|
+
attrs['iris.output'] = truncate(trace.output);
|
|
167
|
+
if (trace.cost_usd !== undefined)
|
|
168
|
+
attrs['iris.cost_usd'] = trace.cost_usd;
|
|
169
|
+
if (trace.token_usage?.total_tokens !== undefined)
|
|
170
|
+
attrs['iris.total_tokens'] = trace.token_usage.total_tokens;
|
|
171
|
+
if (trace.token_usage?.prompt_tokens !== undefined)
|
|
172
|
+
attrs['iris.prompt_tokens'] = trace.token_usage.prompt_tokens;
|
|
173
|
+
if (trace.token_usage?.completion_tokens !== undefined)
|
|
174
|
+
attrs['iris.completion_tokens'] = trace.token_usage.completion_tokens;
|
|
175
|
+
const end = trace.latency_ms
|
|
176
|
+
? new Date(new Date(trace.timestamp).getTime() + trace.latency_ms).toISOString()
|
|
177
|
+
: trace.timestamp;
|
|
178
|
+
spans.push({
|
|
179
|
+
traceId: toTraceIdHex(trace.trace_id),
|
|
180
|
+
spanId: toSpanIdHex(trace.trace_id + ':root'),
|
|
181
|
+
parentSpanId: undefined,
|
|
182
|
+
name: trace.agent_name || 'agent_execution',
|
|
183
|
+
kind: 1,
|
|
184
|
+
startTimeUnixNano: toNanoString(trace.timestamp),
|
|
185
|
+
endTimeUnixNano: toNanoString(end),
|
|
186
|
+
attributes: flattenAttrs(attrs),
|
|
187
|
+
events: [],
|
|
188
|
+
status: { code: 0 },
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
resourceSpans: [
|
|
194
|
+
{
|
|
195
|
+
resource,
|
|
196
|
+
scopeSpans: [
|
|
197
|
+
{
|
|
198
|
+
scope: { name: 'iris.trace.v1' },
|
|
199
|
+
spans,
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
},
|
|
203
|
+
],
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
function truncate(s, max = 4096) {
|
|
207
|
+
return s.length > max ? s.slice(0, max) + '…' : s;
|
|
208
|
+
}
|