@criterionx/devtools 0.3.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 +187 -0
- package/dist/index.d.ts +141 -0
- package/dist/index.js +415 -0
- package/package.json +58 -0
package/README.md
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# @criterionx/devtools
|
|
2
|
+
|
|
3
|
+
Development tools for debugging and visualizing Criterion decision traces.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @criterionx/devtools
|
|
9
|
+
# or
|
|
10
|
+
pnpm add @criterionx/devtools
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Features
|
|
14
|
+
|
|
15
|
+
- **Trace Collector** - Capture and analyze decision evaluations
|
|
16
|
+
- **HTML Report** - Visual trace viewer with rule evaluation details
|
|
17
|
+
- **Export** - JSON, HTML, and Markdown export formats
|
|
18
|
+
- **Console Formatting** - Pretty-print traces for debugging
|
|
19
|
+
- **Statistics** - Summary analytics for collected traces
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
### Collecting Traces
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
import { createCollector } from "@criterionx/devtools";
|
|
27
|
+
import { riskDecision } from "./decisions";
|
|
28
|
+
|
|
29
|
+
const collector = createCollector({
|
|
30
|
+
maxTraces: 1000, // Maximum traces to keep
|
|
31
|
+
autoLog: true, // Log to console automatically
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Use collector.run() instead of engine.run()
|
|
35
|
+
const result = collector.run(
|
|
36
|
+
riskDecision,
|
|
37
|
+
{ amount: 50000, country: "US" },
|
|
38
|
+
{ profile: defaultProfile }
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
// Get the last trace
|
|
42
|
+
const trace = collector.getLastTrace();
|
|
43
|
+
console.log(trace?.result.status); // "OK"
|
|
44
|
+
console.log(trace?.durationMs); // 0.42
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Generating HTML Reports
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
import { createCollector, generateHtmlReport } from "@criterionx/devtools";
|
|
51
|
+
import fs from "fs";
|
|
52
|
+
|
|
53
|
+
const collector = createCollector();
|
|
54
|
+
|
|
55
|
+
// Run some decisions
|
|
56
|
+
collector.run(riskDecision, input1, { profile });
|
|
57
|
+
collector.run(riskDecision, input2, { profile });
|
|
58
|
+
collector.run(riskDecision, input3, { profile });
|
|
59
|
+
|
|
60
|
+
// Generate HTML report
|
|
61
|
+
const html = generateHtmlReport(
|
|
62
|
+
collector.getTraces(),
|
|
63
|
+
collector.getSummary(),
|
|
64
|
+
{
|
|
65
|
+
title: "Risk Decision Traces",
|
|
66
|
+
darkMode: true,
|
|
67
|
+
}
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
fs.writeFileSync("traces.html", html);
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Console Output
|
|
74
|
+
|
|
75
|
+
```typescript
|
|
76
|
+
import { createCollector, formatTraceForConsole } from "@criterionx/devtools";
|
|
77
|
+
|
|
78
|
+
const collector = createCollector();
|
|
79
|
+
collector.run(riskDecision, { amount: 100, country: "US" }, { profile });
|
|
80
|
+
|
|
81
|
+
const trace = collector.getLastTrace()!;
|
|
82
|
+
console.log(formatTraceForConsole(trace));
|
|
83
|
+
|
|
84
|
+
// Output:
|
|
85
|
+
// ──────────────────────────────────────────────────
|
|
86
|
+
// Decision: transaction-risk v1.0.0
|
|
87
|
+
// Status: OK
|
|
88
|
+
// Duration: 0.42ms
|
|
89
|
+
// Time: 2024-01-15T10:30:00.000Z
|
|
90
|
+
//
|
|
91
|
+
// Rule Evaluation:
|
|
92
|
+
// ✗ blocked-country
|
|
93
|
+
// ✗ high-amount
|
|
94
|
+
// ✓ default — Default low risk
|
|
95
|
+
//
|
|
96
|
+
// Result: Default low risk
|
|
97
|
+
// ──────────────────────────────────────────────────
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Exporting Traces
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
import { createCollector, exportToJson, exportTrace } from "@criterionx/devtools";
|
|
104
|
+
|
|
105
|
+
const collector = createCollector();
|
|
106
|
+
// ... run decisions ...
|
|
107
|
+
|
|
108
|
+
// Export all traces as JSON
|
|
109
|
+
const json = exportToJson(collector.getTraces(), {
|
|
110
|
+
pretty: true,
|
|
111
|
+
includeData: true, // Include input/output data
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Export single trace as markdown
|
|
115
|
+
const trace = collector.getLastTrace()!;
|
|
116
|
+
const markdown = exportTrace(trace, "markdown");
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Statistics
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
const collector = createCollector();
|
|
123
|
+
// ... run multiple decisions ...
|
|
124
|
+
|
|
125
|
+
const summary = collector.getSummary();
|
|
126
|
+
|
|
127
|
+
console.log(summary.totalTraces); // 150
|
|
128
|
+
console.log(summary.avgDurationMs); // 0.38
|
|
129
|
+
console.log(summary.byStatus); // { OK: 145, NO_MATCH: 5 }
|
|
130
|
+
console.log(summary.byRule); // { "high-risk": 50, "default": 95, ... }
|
|
131
|
+
console.log(summary.byDecision); // { "transaction-risk": 100, ... }
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## API Reference
|
|
135
|
+
|
|
136
|
+
### `createCollector(options?)`
|
|
137
|
+
|
|
138
|
+
Create a new trace collector.
|
|
139
|
+
|
|
140
|
+
**Options:**
|
|
141
|
+
- `maxTraces` - Maximum traces to keep (default: 1000)
|
|
142
|
+
- `autoLog` - Log traces to console (default: false)
|
|
143
|
+
|
|
144
|
+
**Methods:**
|
|
145
|
+
- `run(decision, input, options, registry?)` - Run decision and collect trace
|
|
146
|
+
- `getTraces()` - Get all collected traces
|
|
147
|
+
- `getTracesForDecision(id)` - Filter traces by decision ID
|
|
148
|
+
- `getLastTrace()` - Get most recent trace
|
|
149
|
+
- `getSummary()` - Get statistics summary
|
|
150
|
+
- `clear()` - Clear all traces
|
|
151
|
+
- `count` - Number of traces
|
|
152
|
+
|
|
153
|
+
### `generateHtmlReport(traces, summary, options?)`
|
|
154
|
+
|
|
155
|
+
Generate an HTML report.
|
|
156
|
+
|
|
157
|
+
**Options:**
|
|
158
|
+
- `title` - Report title (default: "Criterion Trace Viewer")
|
|
159
|
+
- `darkMode` - Use dark theme (default: false)
|
|
160
|
+
|
|
161
|
+
### `formatTraceForConsole(trace)`
|
|
162
|
+
|
|
163
|
+
Format a trace for console output.
|
|
164
|
+
|
|
165
|
+
### `exportToJson(traces, options?)`
|
|
166
|
+
|
|
167
|
+
Export traces as JSON.
|
|
168
|
+
|
|
169
|
+
**Options:**
|
|
170
|
+
- `pretty` - Pretty print (default: true)
|
|
171
|
+
- `includeData` - Include input/output (default: true)
|
|
172
|
+
|
|
173
|
+
### `exportToHtml(traces, summary, options?)`
|
|
174
|
+
|
|
175
|
+
Export traces as HTML (same as `generateHtmlReport`).
|
|
176
|
+
|
|
177
|
+
### `exportTrace(trace, format)`
|
|
178
|
+
|
|
179
|
+
Export single trace as JSON or Markdown.
|
|
180
|
+
|
|
181
|
+
### `createShareableUrl(traces, summary, options?)`
|
|
182
|
+
|
|
183
|
+
Create a data URI for sharing (base64 encoded HTML).
|
|
184
|
+
|
|
185
|
+
## License
|
|
186
|
+
|
|
187
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { Result, Decision, RunOptions, ProfileRegistry } from '@criterionx/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A recorded trace of a decision evaluation
|
|
5
|
+
*/
|
|
6
|
+
interface Trace<TInput = unknown, TOutput = unknown, TProfile = unknown> {
|
|
7
|
+
id: string;
|
|
8
|
+
timestamp: string;
|
|
9
|
+
decisionId: string;
|
|
10
|
+
decisionVersion: string;
|
|
11
|
+
input: TInput;
|
|
12
|
+
profile: TProfile;
|
|
13
|
+
profileId?: string;
|
|
14
|
+
result: Result<TOutput>;
|
|
15
|
+
durationMs: number;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Options for the trace collector
|
|
19
|
+
*/
|
|
20
|
+
interface CollectorOptions {
|
|
21
|
+
/** Maximum number of traces to keep in memory */
|
|
22
|
+
maxTraces?: number;
|
|
23
|
+
/** Auto-flush traces to console */
|
|
24
|
+
autoLog?: boolean;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Options for exporting traces
|
|
28
|
+
*/
|
|
29
|
+
interface ExportOptions {
|
|
30
|
+
/** Format to export */
|
|
31
|
+
format: "json" | "html";
|
|
32
|
+
/** Include input/output data */
|
|
33
|
+
includeData?: boolean;
|
|
34
|
+
/** Pretty print JSON */
|
|
35
|
+
pretty?: boolean;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Summary statistics for collected traces
|
|
39
|
+
*/
|
|
40
|
+
interface TraceSummary {
|
|
41
|
+
totalTraces: number;
|
|
42
|
+
byDecision: Record<string, number>;
|
|
43
|
+
byStatus: Record<string, number>;
|
|
44
|
+
byRule: Record<string, number>;
|
|
45
|
+
avgDurationMs: number;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Options for the HTML viewer
|
|
49
|
+
*/
|
|
50
|
+
interface ViewerOptions {
|
|
51
|
+
/** Title for the HTML report */
|
|
52
|
+
title?: string;
|
|
53
|
+
/** Include inline styles */
|
|
54
|
+
inlineStyles?: boolean;
|
|
55
|
+
/** Dark mode */
|
|
56
|
+
darkMode?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Trace Collector
|
|
61
|
+
*
|
|
62
|
+
* Wraps the Criterion engine to collect and analyze decision traces.
|
|
63
|
+
*/
|
|
64
|
+
declare class TraceCollector {
|
|
65
|
+
private traces;
|
|
66
|
+
private readonly maxTraces;
|
|
67
|
+
private readonly autoLog;
|
|
68
|
+
private readonly engine;
|
|
69
|
+
constructor(options?: CollectorOptions);
|
|
70
|
+
/**
|
|
71
|
+
* Run a decision and collect the trace
|
|
72
|
+
*/
|
|
73
|
+
run<TInput, TOutput, TProfile>(decision: Decision<TInput, TOutput, TProfile>, input: TInput, options: RunOptions<TProfile>, registry?: ProfileRegistry<TProfile>): Result<TOutput>;
|
|
74
|
+
/**
|
|
75
|
+
* Add a trace to the collection
|
|
76
|
+
*/
|
|
77
|
+
private addTrace;
|
|
78
|
+
/**
|
|
79
|
+
* Log a trace to console
|
|
80
|
+
*/
|
|
81
|
+
private logTrace;
|
|
82
|
+
/**
|
|
83
|
+
* Get all collected traces
|
|
84
|
+
*/
|
|
85
|
+
getTraces(): Trace[];
|
|
86
|
+
/**
|
|
87
|
+
* Get traces for a specific decision
|
|
88
|
+
*/
|
|
89
|
+
getTracesForDecision(decisionId: string): Trace[];
|
|
90
|
+
/**
|
|
91
|
+
* Get the most recent trace
|
|
92
|
+
*/
|
|
93
|
+
getLastTrace(): Trace | undefined;
|
|
94
|
+
/**
|
|
95
|
+
* Get summary statistics
|
|
96
|
+
*/
|
|
97
|
+
getSummary(): TraceSummary;
|
|
98
|
+
/**
|
|
99
|
+
* Clear all traces
|
|
100
|
+
*/
|
|
101
|
+
clear(): void;
|
|
102
|
+
/**
|
|
103
|
+
* Get trace count
|
|
104
|
+
*/
|
|
105
|
+
get count(): number;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Create a new trace collector
|
|
109
|
+
*/
|
|
110
|
+
declare function createCollector(options?: CollectorOptions): TraceCollector;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Generate a complete HTML report for traces
|
|
114
|
+
*/
|
|
115
|
+
declare function generateHtmlReport(traces: Trace[], summary: TraceSummary, options?: ViewerOptions): string;
|
|
116
|
+
/**
|
|
117
|
+
* Format a trace for console output
|
|
118
|
+
*/
|
|
119
|
+
declare function formatTraceForConsole(trace: Trace): string;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Export traces to JSON format
|
|
123
|
+
*/
|
|
124
|
+
declare function exportToJson(traces: Trace[], options?: {
|
|
125
|
+
pretty?: boolean;
|
|
126
|
+
includeData?: boolean;
|
|
127
|
+
}): string;
|
|
128
|
+
/**
|
|
129
|
+
* Export traces to HTML format
|
|
130
|
+
*/
|
|
131
|
+
declare function exportToHtml(traces: Trace[], summary: TraceSummary, options?: ViewerOptions): string;
|
|
132
|
+
/**
|
|
133
|
+
* Export a single trace for debugging
|
|
134
|
+
*/
|
|
135
|
+
declare function exportTrace(trace: Trace, format?: "json" | "markdown"): string;
|
|
136
|
+
/**
|
|
137
|
+
* Create a shareable report URL (data URI)
|
|
138
|
+
*/
|
|
139
|
+
declare function createShareableUrl(traces: Trace[], summary: TraceSummary, options?: ViewerOptions): string;
|
|
140
|
+
|
|
141
|
+
export { type CollectorOptions, type ExportOptions, type Trace, TraceCollector, type TraceSummary, type ViewerOptions, createCollector, createShareableUrl, exportToHtml, exportToJson, exportTrace, formatTraceForConsole, generateHtmlReport };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
// src/collector.ts
|
|
2
|
+
import {
|
|
3
|
+
Engine
|
|
4
|
+
} from "@criterionx/core";
|
|
5
|
+
function generateId() {
|
|
6
|
+
return `trace_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
7
|
+
}
|
|
8
|
+
var TraceCollector = class {
|
|
9
|
+
traces = [];
|
|
10
|
+
maxTraces;
|
|
11
|
+
autoLog;
|
|
12
|
+
engine;
|
|
13
|
+
constructor(options = {}) {
|
|
14
|
+
this.maxTraces = options.maxTraces ?? 1e3;
|
|
15
|
+
this.autoLog = options.autoLog ?? false;
|
|
16
|
+
this.engine = new Engine();
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Run a decision and collect the trace
|
|
20
|
+
*/
|
|
21
|
+
run(decision, input, options, registry) {
|
|
22
|
+
const startTime = performance.now();
|
|
23
|
+
const result = this.engine.run(decision, input, options, registry);
|
|
24
|
+
const endTime = performance.now();
|
|
25
|
+
const durationMs = endTime - startTime;
|
|
26
|
+
const trace = {
|
|
27
|
+
id: generateId(),
|
|
28
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
29
|
+
decisionId: decision.id,
|
|
30
|
+
decisionVersion: decision.version,
|
|
31
|
+
input,
|
|
32
|
+
profile: typeof options.profile === "string" ? registry?.get(options.profile) : options.profile,
|
|
33
|
+
profileId: typeof options.profile === "string" ? options.profile : void 0,
|
|
34
|
+
result,
|
|
35
|
+
durationMs
|
|
36
|
+
};
|
|
37
|
+
this.addTrace(trace);
|
|
38
|
+
if (this.autoLog) {
|
|
39
|
+
this.logTrace(trace);
|
|
40
|
+
}
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Add a trace to the collection
|
|
45
|
+
*/
|
|
46
|
+
addTrace(trace) {
|
|
47
|
+
this.traces.push(trace);
|
|
48
|
+
if (this.traces.length > this.maxTraces) {
|
|
49
|
+
this.traces = this.traces.slice(-this.maxTraces);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Log a trace to console
|
|
54
|
+
*/
|
|
55
|
+
logTrace(trace) {
|
|
56
|
+
const status = trace.result.status;
|
|
57
|
+
const rule = trace.result.meta.matchedRule ?? "none";
|
|
58
|
+
const duration = trace.durationMs.toFixed(2);
|
|
59
|
+
console.log(
|
|
60
|
+
`[Criterion] ${trace.decisionId} | ${status} | rule: ${rule} | ${duration}ms`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Get all collected traces
|
|
65
|
+
*/
|
|
66
|
+
getTraces() {
|
|
67
|
+
return [...this.traces];
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Get traces for a specific decision
|
|
71
|
+
*/
|
|
72
|
+
getTracesForDecision(decisionId) {
|
|
73
|
+
return this.traces.filter((t) => t.decisionId === decisionId);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Get the most recent trace
|
|
77
|
+
*/
|
|
78
|
+
getLastTrace() {
|
|
79
|
+
return this.traces[this.traces.length - 1];
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Get summary statistics
|
|
83
|
+
*/
|
|
84
|
+
getSummary() {
|
|
85
|
+
const byDecision = {};
|
|
86
|
+
const byStatus = {};
|
|
87
|
+
const byRule = {};
|
|
88
|
+
let totalDuration = 0;
|
|
89
|
+
for (const trace of this.traces) {
|
|
90
|
+
byDecision[trace.decisionId] = (byDecision[trace.decisionId] ?? 0) + 1;
|
|
91
|
+
byStatus[trace.result.status] = (byStatus[trace.result.status] ?? 0) + 1;
|
|
92
|
+
const rule = trace.result.meta.matchedRule ?? "NO_MATCH";
|
|
93
|
+
byRule[rule] = (byRule[rule] ?? 0) + 1;
|
|
94
|
+
totalDuration += trace.durationMs;
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
totalTraces: this.traces.length,
|
|
98
|
+
byDecision,
|
|
99
|
+
byStatus,
|
|
100
|
+
byRule,
|
|
101
|
+
avgDurationMs: this.traces.length > 0 ? totalDuration / this.traces.length : 0
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Clear all traces
|
|
106
|
+
*/
|
|
107
|
+
clear() {
|
|
108
|
+
this.traces = [];
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Get trace count
|
|
112
|
+
*/
|
|
113
|
+
get count() {
|
|
114
|
+
return this.traces.length;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
function createCollector(options) {
|
|
118
|
+
return new TraceCollector(options);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// src/viewer.ts
|
|
122
|
+
function generateStyles(darkMode) {
|
|
123
|
+
const bg = darkMode ? "#1a1a2e" : "#ffffff";
|
|
124
|
+
const bgCard = darkMode ? "#16213e" : "#f8f9fa";
|
|
125
|
+
const text = darkMode ? "#eaeaea" : "#212529";
|
|
126
|
+
const textMuted = darkMode ? "#a0a0a0" : "#6c757d";
|
|
127
|
+
const border = darkMode ? "#0f3460" : "#dee2e6";
|
|
128
|
+
const success = "#28a745";
|
|
129
|
+
const danger = "#dc3545";
|
|
130
|
+
const warning = "#ffc107";
|
|
131
|
+
return `
|
|
132
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
133
|
+
body {
|
|
134
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
135
|
+
background: ${bg};
|
|
136
|
+
color: ${text};
|
|
137
|
+
line-height: 1.6;
|
|
138
|
+
padding: 2rem;
|
|
139
|
+
}
|
|
140
|
+
.container { max-width: 1200px; margin: 0 auto; }
|
|
141
|
+
h1 { margin-bottom: 1.5rem; font-size: 1.75rem; }
|
|
142
|
+
h2 { margin: 1.5rem 0 1rem; font-size: 1.25rem; color: ${textMuted}; }
|
|
143
|
+
.summary {
|
|
144
|
+
display: grid;
|
|
145
|
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
146
|
+
gap: 1rem;
|
|
147
|
+
margin-bottom: 2rem;
|
|
148
|
+
}
|
|
149
|
+
.stat {
|
|
150
|
+
background: ${bgCard};
|
|
151
|
+
border: 1px solid ${border};
|
|
152
|
+
border-radius: 8px;
|
|
153
|
+
padding: 1rem;
|
|
154
|
+
}
|
|
155
|
+
.stat-value { font-size: 2rem; font-weight: bold; }
|
|
156
|
+
.stat-label { color: ${textMuted}; font-size: 0.875rem; }
|
|
157
|
+
.trace {
|
|
158
|
+
background: ${bgCard};
|
|
159
|
+
border: 1px solid ${border};
|
|
160
|
+
border-radius: 8px;
|
|
161
|
+
margin-bottom: 1rem;
|
|
162
|
+
overflow: hidden;
|
|
163
|
+
}
|
|
164
|
+
.trace-header {
|
|
165
|
+
display: flex;
|
|
166
|
+
justify-content: space-between;
|
|
167
|
+
align-items: center;
|
|
168
|
+
padding: 1rem;
|
|
169
|
+
border-bottom: 1px solid ${border};
|
|
170
|
+
cursor: pointer;
|
|
171
|
+
}
|
|
172
|
+
.trace-header:hover { background: ${darkMode ? "#1f2b4a" : "#f1f3f4"}; }
|
|
173
|
+
.trace-title { font-weight: 600; }
|
|
174
|
+
.trace-meta { display: flex; gap: 1rem; font-size: 0.875rem; color: ${textMuted}; }
|
|
175
|
+
.badge {
|
|
176
|
+
display: inline-block;
|
|
177
|
+
padding: 0.25rem 0.5rem;
|
|
178
|
+
border-radius: 4px;
|
|
179
|
+
font-size: 0.75rem;
|
|
180
|
+
font-weight: 600;
|
|
181
|
+
}
|
|
182
|
+
.badge-ok { background: ${success}; color: white; }
|
|
183
|
+
.badge-no-match { background: ${warning}; color: black; }
|
|
184
|
+
.badge-invalid { background: ${danger}; color: white; }
|
|
185
|
+
.trace-body { padding: 1rem; display: none; }
|
|
186
|
+
.trace.open .trace-body { display: block; }
|
|
187
|
+
.trace-section { margin-bottom: 1rem; }
|
|
188
|
+
.trace-section-title { font-weight: 600; margin-bottom: 0.5rem; font-size: 0.875rem; }
|
|
189
|
+
.rules-list { list-style: none; }
|
|
190
|
+
.rule {
|
|
191
|
+
display: flex;
|
|
192
|
+
align-items: center;
|
|
193
|
+
gap: 0.5rem;
|
|
194
|
+
padding: 0.5rem;
|
|
195
|
+
border-radius: 4px;
|
|
196
|
+
margin-bottom: 0.25rem;
|
|
197
|
+
}
|
|
198
|
+
.rule-matched { background: ${darkMode ? "#1a3a2a" : "#d4edda"}; }
|
|
199
|
+
.rule-icon { font-size: 1rem; }
|
|
200
|
+
.rule-id { font-weight: 500; }
|
|
201
|
+
.rule-explain { color: ${textMuted}; font-size: 0.875rem; }
|
|
202
|
+
pre {
|
|
203
|
+
background: ${darkMode ? "#0d1117" : "#f6f8fa"};
|
|
204
|
+
border: 1px solid ${border};
|
|
205
|
+
border-radius: 4px;
|
|
206
|
+
padding: 1rem;
|
|
207
|
+
overflow-x: auto;
|
|
208
|
+
font-size: 0.875rem;
|
|
209
|
+
}
|
|
210
|
+
code { font-family: 'SF Mono', Monaco, Consolas, monospace; }
|
|
211
|
+
`;
|
|
212
|
+
}
|
|
213
|
+
function renderTrace(trace, index) {
|
|
214
|
+
const statusClass = trace.result.status === "OK" ? "badge-ok" : trace.result.status === "NO_MATCH" ? "badge-no-match" : "badge-invalid";
|
|
215
|
+
const rules = trace.result.meta.evaluatedRules.map(
|
|
216
|
+
(rule) => `
|
|
217
|
+
<li class="rule ${rule.matched ? "rule-matched" : ""}">
|
|
218
|
+
<span class="rule-icon">${rule.matched ? "\u2713" : "\u2717"}</span>
|
|
219
|
+
<span class="rule-id">${rule.ruleId}</span>
|
|
220
|
+
${rule.explanation ? `<span class="rule-explain">\u2014 ${rule.explanation}</span>` : ""}
|
|
221
|
+
</li>
|
|
222
|
+
`
|
|
223
|
+
).join("");
|
|
224
|
+
return `
|
|
225
|
+
<div class="trace" id="trace-${index}">
|
|
226
|
+
<div class="trace-header" onclick="toggleTrace(${index})">
|
|
227
|
+
<div>
|
|
228
|
+
<span class="trace-title">${trace.decisionId}</span>
|
|
229
|
+
<span class="badge ${statusClass}">${trace.result.status}</span>
|
|
230
|
+
</div>
|
|
231
|
+
<div class="trace-meta">
|
|
232
|
+
<span>${trace.result.meta.matchedRule ?? "No match"}</span>
|
|
233
|
+
<span>${trace.durationMs.toFixed(2)}ms</span>
|
|
234
|
+
<span>${new Date(trace.timestamp).toLocaleTimeString()}</span>
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
<div class="trace-body">
|
|
238
|
+
<div class="trace-section">
|
|
239
|
+
<div class="trace-section-title">Rule Evaluation</div>
|
|
240
|
+
<ul class="rules-list">${rules}</ul>
|
|
241
|
+
</div>
|
|
242
|
+
<div class="trace-section">
|
|
243
|
+
<div class="trace-section-title">Input</div>
|
|
244
|
+
<pre><code>${JSON.stringify(trace.input, null, 2)}</code></pre>
|
|
245
|
+
</div>
|
|
246
|
+
${trace.result.data ? `
|
|
247
|
+
<div class="trace-section">
|
|
248
|
+
<div class="trace-section-title">Output</div>
|
|
249
|
+
<pre><code>${JSON.stringify(trace.result.data, null, 2)}</code></pre>
|
|
250
|
+
</div>
|
|
251
|
+
` : ""}
|
|
252
|
+
<div class="trace-section">
|
|
253
|
+
<div class="trace-section-title">Explanation</div>
|
|
254
|
+
<p>${trace.result.meta.explanation}</p>
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
`;
|
|
259
|
+
}
|
|
260
|
+
function renderSummary(summary) {
|
|
261
|
+
return `
|
|
262
|
+
<div class="summary">
|
|
263
|
+
<div class="stat">
|
|
264
|
+
<div class="stat-value">${summary.totalTraces}</div>
|
|
265
|
+
<div class="stat-label">Total Traces</div>
|
|
266
|
+
</div>
|
|
267
|
+
<div class="stat">
|
|
268
|
+
<div class="stat-value">${summary.avgDurationMs.toFixed(2)}ms</div>
|
|
269
|
+
<div class="stat-label">Avg Duration</div>
|
|
270
|
+
</div>
|
|
271
|
+
<div class="stat">
|
|
272
|
+
<div class="stat-value">${summary.byStatus["OK"] ?? 0}</div>
|
|
273
|
+
<div class="stat-label">Successful</div>
|
|
274
|
+
</div>
|
|
275
|
+
<div class="stat">
|
|
276
|
+
<div class="stat-value">${(summary.byStatus["NO_MATCH"] ?? 0) + (summary.byStatus["INVALID_INPUT"] ?? 0) + (summary.byStatus["INVALID_OUTPUT"] ?? 0)}</div>
|
|
277
|
+
<div class="stat-label">Failed/No Match</div>
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
280
|
+
`;
|
|
281
|
+
}
|
|
282
|
+
function generateHtmlReport(traces, summary, options = {}) {
|
|
283
|
+
const title = options.title ?? "Criterion Trace Viewer";
|
|
284
|
+
const darkMode = options.darkMode ?? false;
|
|
285
|
+
const tracesHtml = traces.slice().reverse().map((t, i) => renderTrace(t, i)).join("");
|
|
286
|
+
return `<!DOCTYPE html>
|
|
287
|
+
<html lang="en">
|
|
288
|
+
<head>
|
|
289
|
+
<meta charset="UTF-8">
|
|
290
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
291
|
+
<title>${title}</title>
|
|
292
|
+
<style>${generateStyles(darkMode)}</style>
|
|
293
|
+
</head>
|
|
294
|
+
<body>
|
|
295
|
+
<div class="container">
|
|
296
|
+
<h1>${title}</h1>
|
|
297
|
+
|
|
298
|
+
<h2>Summary</h2>
|
|
299
|
+
${renderSummary(summary)}
|
|
300
|
+
|
|
301
|
+
<h2>Traces (${traces.length})</h2>
|
|
302
|
+
${tracesHtml || "<p>No traces recorded yet.</p>"}
|
|
303
|
+
</div>
|
|
304
|
+
|
|
305
|
+
<script>
|
|
306
|
+
function toggleTrace(index) {
|
|
307
|
+
const trace = document.getElementById('trace-' + index);
|
|
308
|
+
trace.classList.toggle('open');
|
|
309
|
+
}
|
|
310
|
+
</script>
|
|
311
|
+
</body>
|
|
312
|
+
</html>`;
|
|
313
|
+
}
|
|
314
|
+
function formatTraceForConsole(trace) {
|
|
315
|
+
const lines = [];
|
|
316
|
+
const hr = "\u2500".repeat(50);
|
|
317
|
+
lines.push(hr);
|
|
318
|
+
lines.push(`Decision: ${trace.decisionId} v${trace.decisionVersion}`);
|
|
319
|
+
lines.push(`Status: ${trace.result.status}`);
|
|
320
|
+
lines.push(`Duration: ${trace.durationMs.toFixed(2)}ms`);
|
|
321
|
+
lines.push(`Time: ${trace.timestamp}`);
|
|
322
|
+
lines.push("");
|
|
323
|
+
lines.push("Rule Evaluation:");
|
|
324
|
+
for (const rule of trace.result.meta.evaluatedRules) {
|
|
325
|
+
const icon = rule.matched ? "\u2713" : "\u2717";
|
|
326
|
+
const explain = rule.explanation ? ` \u2014 ${rule.explanation}` : "";
|
|
327
|
+
lines.push(` ${icon} ${rule.ruleId}${explain}`);
|
|
328
|
+
}
|
|
329
|
+
lines.push("");
|
|
330
|
+
lines.push(`Result: ${trace.result.meta.explanation}`);
|
|
331
|
+
lines.push(hr);
|
|
332
|
+
return lines.join("\n");
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// src/export.ts
|
|
336
|
+
function exportToJson(traces, options = {}) {
|
|
337
|
+
const { pretty = true, includeData = true } = options;
|
|
338
|
+
const exportData = traces.map((trace) => {
|
|
339
|
+
if (!includeData) {
|
|
340
|
+
return {
|
|
341
|
+
id: trace.id,
|
|
342
|
+
timestamp: trace.timestamp,
|
|
343
|
+
decisionId: trace.decisionId,
|
|
344
|
+
decisionVersion: trace.decisionVersion,
|
|
345
|
+
status: trace.result.status,
|
|
346
|
+
matchedRule: trace.result.meta.matchedRule,
|
|
347
|
+
durationMs: trace.durationMs
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
return trace;
|
|
351
|
+
});
|
|
352
|
+
return pretty ? JSON.stringify(exportData, null, 2) : JSON.stringify(exportData);
|
|
353
|
+
}
|
|
354
|
+
function exportToHtml(traces, summary, options = {}) {
|
|
355
|
+
return generateHtmlReport(traces, summary, options);
|
|
356
|
+
}
|
|
357
|
+
function exportTrace(trace, format = "json") {
|
|
358
|
+
if (format === "markdown") {
|
|
359
|
+
return formatTraceAsMarkdown(trace);
|
|
360
|
+
}
|
|
361
|
+
return JSON.stringify(trace, null, 2);
|
|
362
|
+
}
|
|
363
|
+
function formatTraceAsMarkdown(trace) {
|
|
364
|
+
const lines = [];
|
|
365
|
+
lines.push(`# Trace: ${trace.decisionId}`);
|
|
366
|
+
lines.push("");
|
|
367
|
+
lines.push(`- **Version:** ${trace.decisionVersion}`);
|
|
368
|
+
lines.push(`- **Status:** ${trace.result.status}`);
|
|
369
|
+
lines.push(`- **Duration:** ${trace.durationMs.toFixed(2)}ms`);
|
|
370
|
+
lines.push(`- **Timestamp:** ${trace.timestamp}`);
|
|
371
|
+
lines.push("");
|
|
372
|
+
lines.push("## Rule Evaluation");
|
|
373
|
+
lines.push("");
|
|
374
|
+
lines.push("| Rule | Matched | Explanation |");
|
|
375
|
+
lines.push("|------|---------|-------------|");
|
|
376
|
+
for (const rule of trace.result.meta.evaluatedRules) {
|
|
377
|
+
const matched = rule.matched ? "Yes" : "No";
|
|
378
|
+
const explain = rule.explanation ?? "-";
|
|
379
|
+
lines.push(`| ${rule.ruleId} | ${matched} | ${explain} |`);
|
|
380
|
+
}
|
|
381
|
+
lines.push("");
|
|
382
|
+
lines.push("## Input");
|
|
383
|
+
lines.push("");
|
|
384
|
+
lines.push("```json");
|
|
385
|
+
lines.push(JSON.stringify(trace.input, null, 2));
|
|
386
|
+
lines.push("```");
|
|
387
|
+
lines.push("");
|
|
388
|
+
if (trace.result.data) {
|
|
389
|
+
lines.push("## Output");
|
|
390
|
+
lines.push("");
|
|
391
|
+
lines.push("```json");
|
|
392
|
+
lines.push(JSON.stringify(trace.result.data, null, 2));
|
|
393
|
+
lines.push("```");
|
|
394
|
+
lines.push("");
|
|
395
|
+
}
|
|
396
|
+
lines.push("## Explanation");
|
|
397
|
+
lines.push("");
|
|
398
|
+
lines.push(trace.result.meta.explanation);
|
|
399
|
+
return lines.join("\n");
|
|
400
|
+
}
|
|
401
|
+
function createShareableUrl(traces, summary, options = {}) {
|
|
402
|
+
const html = generateHtmlReport(traces, summary, options);
|
|
403
|
+
const encoded = Buffer.from(html).toString("base64");
|
|
404
|
+
return `data:text/html;base64,${encoded}`;
|
|
405
|
+
}
|
|
406
|
+
export {
|
|
407
|
+
TraceCollector,
|
|
408
|
+
createCollector,
|
|
409
|
+
createShareableUrl,
|
|
410
|
+
exportToHtml,
|
|
411
|
+
exportToJson,
|
|
412
|
+
exportTrace,
|
|
413
|
+
formatTraceForConsole,
|
|
414
|
+
generateHtmlReport
|
|
415
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@criterionx/devtools",
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "Development tools for debugging Criterion decisions",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"LICENSE",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
15
|
+
"dev": "tsup src/index.ts --format esm --dts --watch",
|
|
16
|
+
"test": "vitest run",
|
|
17
|
+
"test:watch": "vitest",
|
|
18
|
+
"test:coverage": "vitest run --coverage",
|
|
19
|
+
"typecheck": "tsc --noEmit"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"criterion",
|
|
23
|
+
"devtools",
|
|
24
|
+
"debugging",
|
|
25
|
+
"decision-engine",
|
|
26
|
+
"trace-viewer"
|
|
27
|
+
],
|
|
28
|
+
"author": {
|
|
29
|
+
"name": "Tomas Maritano",
|
|
30
|
+
"url": "https://github.com/tomymaritano"
|
|
31
|
+
},
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "https://github.com/tomymaritano/criterionx.git",
|
|
36
|
+
"directory": "packages/devtools"
|
|
37
|
+
},
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/tomymaritano/criterionx/issues"
|
|
40
|
+
},
|
|
41
|
+
"homepage": "https://github.com/tomymaritano/criterionx#readme",
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@criterionx/core": "workspace:*"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/node": "^20.0.0",
|
|
47
|
+
"tsup": "^8.0.0",
|
|
48
|
+
"typescript": "^5.3.0",
|
|
49
|
+
"vitest": "^4.0.0",
|
|
50
|
+
"zod": "^3.23.0"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"zod": "^3.0.0"
|
|
54
|
+
},
|
|
55
|
+
"engines": {
|
|
56
|
+
"node": ">=18"
|
|
57
|
+
}
|
|
58
|
+
}
|