@runtime-digital-twin/sdk 1.0.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 +214 -0
- package/dist/constants.d.ts +11 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +13 -0
- package/dist/db-wrapper.d.ts +258 -0
- package/dist/db-wrapper.d.ts.map +1 -0
- package/dist/db-wrapper.js +636 -0
- package/dist/event-envelope.d.ts +35 -0
- package/dist/event-envelope.d.ts.map +1 -0
- package/dist/event-envelope.js +101 -0
- package/dist/fastify-plugin.d.ts +29 -0
- package/dist/fastify-plugin.d.ts.map +1 -0
- package/dist/fastify-plugin.js +243 -0
- package/dist/http-sentinels.d.ts +39 -0
- package/dist/http-sentinels.d.ts.map +1 -0
- package/dist/http-sentinels.js +169 -0
- package/dist/http-wrapper.d.ts +25 -0
- package/dist/http-wrapper.d.ts.map +1 -0
- package/dist/http-wrapper.js +477 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +93 -0
- package/dist/invariants.d.ts +58 -0
- package/dist/invariants.d.ts.map +1 -0
- package/dist/invariants.js +192 -0
- package/dist/multi-service-edge-builder.d.ts +80 -0
- package/dist/multi-service-edge-builder.d.ts.map +1 -0
- package/dist/multi-service-edge-builder.js +107 -0
- package/dist/outbound-matcher.d.ts +192 -0
- package/dist/outbound-matcher.d.ts.map +1 -0
- package/dist/outbound-matcher.js +457 -0
- package/dist/peer-service-resolver.d.ts +22 -0
- package/dist/peer-service-resolver.d.ts.map +1 -0
- package/dist/peer-service-resolver.js +85 -0
- package/dist/redaction.d.ts +111 -0
- package/dist/redaction.d.ts.map +1 -0
- package/dist/redaction.js +487 -0
- package/dist/replay-logger.d.ts +438 -0
- package/dist/replay-logger.d.ts.map +1 -0
- package/dist/replay-logger.js +434 -0
- package/dist/root-cause-analyzer.d.ts +45 -0
- package/dist/root-cause-analyzer.d.ts.map +1 -0
- package/dist/root-cause-analyzer.js +606 -0
- package/dist/shape-digest-utils.d.ts +45 -0
- package/dist/shape-digest-utils.d.ts.map +1 -0
- package/dist/shape-digest-utils.js +154 -0
- package/dist/trace-bundle-writer.d.ts +52 -0
- package/dist/trace-bundle-writer.d.ts.map +1 -0
- package/dist/trace-bundle-writer.js +267 -0
- package/dist/trace-loader.d.ts +69 -0
- package/dist/trace-loader.d.ts.map +1 -0
- package/dist/trace-loader.js +146 -0
- package/dist/trace-uploader.d.ts +25 -0
- package/dist/trace-uploader.d.ts.map +1 -0
- package/dist/trace-uploader.js +132 -0
- package/package.json +63 -0
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.OutboundMatcher = void 0;
|
|
4
|
+
exports.normalizeUrl = normalizeUrl;
|
|
5
|
+
exports.normalizeHeaders = normalizeHeaders;
|
|
6
|
+
exports.computeBodyHash = computeBodyHash;
|
|
7
|
+
exports.computeMatchSignature = computeMatchSignature;
|
|
8
|
+
exports.compareOutboundCalls = compareOutboundCalls;
|
|
9
|
+
exports.parseRecordedOutboundCalls = parseRecordedOutboundCalls;
|
|
10
|
+
exports.formatOutboundMismatch = formatOutboundMismatch;
|
|
11
|
+
exports.formatUnexpectedOutboundCall = formatUnexpectedOutboundCall;
|
|
12
|
+
exports.formatMissingOutboundCall = formatMissingOutboundCall;
|
|
13
|
+
exports.formatOutboundCallFailure = formatOutboundCallFailure;
|
|
14
|
+
const crypto_1 = require("crypto");
|
|
15
|
+
/**
|
|
16
|
+
* Headers to include in matching (case-insensitive)
|
|
17
|
+
*/
|
|
18
|
+
const MATCHING_HEADERS = ["content-type", "accept", "authorization"];
|
|
19
|
+
/**
|
|
20
|
+
* Normalize URL for matching: pathname + sorted query params
|
|
21
|
+
*/
|
|
22
|
+
function normalizeUrl(url) {
|
|
23
|
+
try {
|
|
24
|
+
const urlObj = new URL(url);
|
|
25
|
+
const searchParams = new URLSearchParams(urlObj.search);
|
|
26
|
+
const sortedParams = Array.from(searchParams.entries()).sort(([a], [b]) => a.localeCompare(b));
|
|
27
|
+
const normalizedSearch = new URLSearchParams(sortedParams).toString();
|
|
28
|
+
return `${urlObj.pathname}${normalizedSearch ? "?" + normalizedSearch : ""}`;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// If URL parsing fails, try to extract path+query manually
|
|
32
|
+
const match = url.match(/^[^:]+:\/\/[^/]+(\/.*)$/);
|
|
33
|
+
if (match) {
|
|
34
|
+
return match[1];
|
|
35
|
+
}
|
|
36
|
+
return url;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Normalize headers for matching: lowercase keys, filter to matching headers
|
|
41
|
+
*/
|
|
42
|
+
function normalizeHeaders(headers) {
|
|
43
|
+
const normalized = {};
|
|
44
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
45
|
+
const lowerKey = key.toLowerCase();
|
|
46
|
+
if (MATCHING_HEADERS.includes(lowerKey)) {
|
|
47
|
+
normalized[lowerKey] = value;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return normalized;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Compute body hash from content
|
|
54
|
+
*/
|
|
55
|
+
function computeBodyHash(body) {
|
|
56
|
+
if (!body)
|
|
57
|
+
return null;
|
|
58
|
+
let bodyStr;
|
|
59
|
+
if (Buffer.isBuffer(body)) {
|
|
60
|
+
bodyStr = body.toString("utf8");
|
|
61
|
+
}
|
|
62
|
+
else if (typeof body === "object") {
|
|
63
|
+
bodyStr = JSON.stringify(body);
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
bodyStr = String(body);
|
|
67
|
+
}
|
|
68
|
+
const hash = (0, crypto_1.createHash)("sha256").update(bodyStr).digest("hex");
|
|
69
|
+
return `sha256:${hash}`;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Compute match signature for comparison
|
|
73
|
+
*/
|
|
74
|
+
function computeMatchSignature(call) {
|
|
75
|
+
const normalizedUrl = normalizeUrl(call.url);
|
|
76
|
+
const normalizedHeaders = normalizeHeaders(call.headers);
|
|
77
|
+
const headerStr = Object.entries(normalizedHeaders)
|
|
78
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
79
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
80
|
+
.join("&");
|
|
81
|
+
return `${call.method}:${normalizedUrl}:${call.bodyHash || "null"}:${headerStr}`;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Compare two calls and return differences
|
|
85
|
+
*/
|
|
86
|
+
function compareOutboundCalls(expected, actual) {
|
|
87
|
+
const diffs = [];
|
|
88
|
+
// Method
|
|
89
|
+
if (expected.method !== actual.method) {
|
|
90
|
+
diffs.push({
|
|
91
|
+
field: "method",
|
|
92
|
+
expected: expected.method,
|
|
93
|
+
actual: actual.method,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
// URL (normalized)
|
|
97
|
+
const expectedUrl = expected.normalizedUrl;
|
|
98
|
+
const actualUrl = normalizeUrl(actual.url);
|
|
99
|
+
if (expectedUrl !== actualUrl) {
|
|
100
|
+
diffs.push({ field: "url", expected: expectedUrl, actual: actualUrl });
|
|
101
|
+
}
|
|
102
|
+
// Body hash
|
|
103
|
+
if (expected.bodyHash !== actual.bodyHash) {
|
|
104
|
+
diffs.push({
|
|
105
|
+
field: "bodyHash",
|
|
106
|
+
expected: expected.bodyHash,
|
|
107
|
+
actual: actual.bodyHash,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
// Headers (only check headers that were RECORDED - if recorded headers are empty, skip comparison)
|
|
111
|
+
const expectedHeaders = normalizeHeaders(expected.headers);
|
|
112
|
+
const actualHeaders = normalizeHeaders(actual.headers);
|
|
113
|
+
// Only compare headers that exist in the recorded trace
|
|
114
|
+
// Headers in actual but not in expected are ignored (caller may add headers we don't care about)
|
|
115
|
+
for (const key of Object.keys(expectedHeaders)) {
|
|
116
|
+
const expectedVal = expectedHeaders[key];
|
|
117
|
+
const actualVal = actualHeaders[key] || null;
|
|
118
|
+
if (expectedVal !== actualVal) {
|
|
119
|
+
diffs.push({
|
|
120
|
+
field: `header:${key}`,
|
|
121
|
+
expected: expectedVal,
|
|
122
|
+
actual: actualVal,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return { matches: diffs.length === 0, diffs };
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Ordered outbound call matcher for replay
|
|
130
|
+
*/
|
|
131
|
+
class OutboundMatcher {
|
|
132
|
+
recordedCalls = [];
|
|
133
|
+
currentIndex = 0;
|
|
134
|
+
mode;
|
|
135
|
+
traceDir;
|
|
136
|
+
divergences = [];
|
|
137
|
+
logger = null;
|
|
138
|
+
constructor(recordedCalls, mode, traceDir, logger) {
|
|
139
|
+
this.recordedCalls = recordedCalls;
|
|
140
|
+
this.mode = mode;
|
|
141
|
+
this.traceDir = traceDir;
|
|
142
|
+
this.logger = logger || null;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Set the logger for structured replay logging
|
|
146
|
+
*/
|
|
147
|
+
setLogger(logger) {
|
|
148
|
+
this.logger = logger;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Get all recorded divergences (for explain mode)
|
|
152
|
+
*/
|
|
153
|
+
getDivergences() {
|
|
154
|
+
return [...this.divergences];
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Get current call index
|
|
158
|
+
*/
|
|
159
|
+
getCurrentIndex() {
|
|
160
|
+
return this.currentIndex;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Get total recorded calls
|
|
164
|
+
*/
|
|
165
|
+
getTotalCalls() {
|
|
166
|
+
return this.recordedCalls.length;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Match the next outbound call against the recorded sequence
|
|
170
|
+
*
|
|
171
|
+
* In strict mode: throws OutboundRequestMismatch on any mismatch
|
|
172
|
+
* In explain mode: emits divergence and returns best-effort match
|
|
173
|
+
*/
|
|
174
|
+
matchNext(actual) {
|
|
175
|
+
const callIndex = this.currentIndex;
|
|
176
|
+
const actualNormalizedUrl = normalizeUrl(actual.url);
|
|
177
|
+
// Check if we've exceeded recorded calls - this is an unexpected call
|
|
178
|
+
if (callIndex >= this.recordedCalls.length) {
|
|
179
|
+
const failure = {
|
|
180
|
+
type: "unexpected_outbound_call",
|
|
181
|
+
location: `${this.traceDir}/events.jsonl:call[${callIndex}]`,
|
|
182
|
+
expected: `<end of recorded calls after ${this.recordedCalls.length} call(s)>`,
|
|
183
|
+
actual: `${actual.method} ${actualNormalizedUrl}`,
|
|
184
|
+
hint: "An outbound HTTP call occurred during replay that was not recorded in the original trace. This may indicate non-deterministic behavior or code changes.",
|
|
185
|
+
callIndex,
|
|
186
|
+
service: this.traceDir,
|
|
187
|
+
spanId: null,
|
|
188
|
+
call: actual,
|
|
189
|
+
};
|
|
190
|
+
// Log unexpected call
|
|
191
|
+
this.logger?.httpUnexpected({
|
|
192
|
+
callIndex,
|
|
193
|
+
method: actual.method,
|
|
194
|
+
url: actual.url,
|
|
195
|
+
bodyHash: actual.bodyHash,
|
|
196
|
+
expectedCallCount: this.recordedCalls.length,
|
|
197
|
+
});
|
|
198
|
+
if (this.mode === "strict") {
|
|
199
|
+
throw failure;
|
|
200
|
+
}
|
|
201
|
+
// Explain mode: emit divergence and fail (no recorded response to return)
|
|
202
|
+
this.divergences.push({
|
|
203
|
+
type: "divergence",
|
|
204
|
+
severity: "error",
|
|
205
|
+
callIndex,
|
|
206
|
+
message: `Unexpected outbound call: ${actual.method} ${actual.url}`,
|
|
207
|
+
expected: null,
|
|
208
|
+
actual,
|
|
209
|
+
recoverable: false,
|
|
210
|
+
});
|
|
211
|
+
throw failure; // Can't continue without a response
|
|
212
|
+
}
|
|
213
|
+
const expected = this.recordedCalls[callIndex];
|
|
214
|
+
const { matches, diffs } = compareOutboundCalls(expected, actual);
|
|
215
|
+
if (matches) {
|
|
216
|
+
// Log successful match
|
|
217
|
+
this.logger?.httpMatch({
|
|
218
|
+
callIndex,
|
|
219
|
+
method: actual.method,
|
|
220
|
+
url: actual.url,
|
|
221
|
+
normalizedUrl: actualNormalizedUrl,
|
|
222
|
+
bodyHash: actual.bodyHash,
|
|
223
|
+
matchedSpanId: expected.spanId,
|
|
224
|
+
});
|
|
225
|
+
this.currentIndex++;
|
|
226
|
+
return expected;
|
|
227
|
+
}
|
|
228
|
+
// Mismatch detected
|
|
229
|
+
const failure = {
|
|
230
|
+
type: "outbound_request_mismatch",
|
|
231
|
+
location: `${this.traceDir}/events.jsonl:call[${callIndex}]`,
|
|
232
|
+
expected: `${expected.method} ${expected.normalizedUrl}`,
|
|
233
|
+
actual: `${actual.method} ${actualNormalizedUrl}`,
|
|
234
|
+
hint: "Outbound call does not match recorded trace. Check for non-determinism or code changes.",
|
|
235
|
+
callIndex,
|
|
236
|
+
diff: diffs,
|
|
237
|
+
};
|
|
238
|
+
// Explain mode: check if divergence is recoverable
|
|
239
|
+
// Recoverable if only headers differ (method, URL, body match)
|
|
240
|
+
const criticalDiffs = diffs.filter((d) => d.field === "method" || d.field === "url" || d.field === "bodyHash");
|
|
241
|
+
const recoverable = criticalDiffs.length === 0;
|
|
242
|
+
// Log mismatch
|
|
243
|
+
this.logger?.httpMismatch({
|
|
244
|
+
callIndex,
|
|
245
|
+
expected: {
|
|
246
|
+
method: expected.method,
|
|
247
|
+
url: expected.normalizedUrl,
|
|
248
|
+
bodyHash: expected.bodyHash,
|
|
249
|
+
},
|
|
250
|
+
actual: {
|
|
251
|
+
method: actual.method,
|
|
252
|
+
url: actualNormalizedUrl,
|
|
253
|
+
bodyHash: actual.bodyHash,
|
|
254
|
+
},
|
|
255
|
+
differences: diffs,
|
|
256
|
+
recoverable,
|
|
257
|
+
});
|
|
258
|
+
if (this.mode === "strict") {
|
|
259
|
+
throw failure;
|
|
260
|
+
}
|
|
261
|
+
this.divergences.push({
|
|
262
|
+
type: "divergence",
|
|
263
|
+
severity: recoverable ? "warning" : "error",
|
|
264
|
+
callIndex,
|
|
265
|
+
message: `Outbound call mismatch: ${diffs
|
|
266
|
+
.map((d) => `${d.field}: ${d.expected} → ${d.actual}`)
|
|
267
|
+
.join(", ")}`,
|
|
268
|
+
expected,
|
|
269
|
+
actual,
|
|
270
|
+
recoverable,
|
|
271
|
+
});
|
|
272
|
+
// Emit to stderr for CLI visibility
|
|
273
|
+
console.warn(`[Replay] Divergence at call ${callIndex}: ${failure.expected} vs ${failure.actual}`);
|
|
274
|
+
for (const diff of diffs) {
|
|
275
|
+
console.warn(` ${diff.field}: expected="${diff.expected}" actual="${diff.actual}"`);
|
|
276
|
+
}
|
|
277
|
+
if (!recoverable) {
|
|
278
|
+
throw failure;
|
|
279
|
+
}
|
|
280
|
+
// Continue with the recorded response despite header differences
|
|
281
|
+
this.currentIndex++;
|
|
282
|
+
return expected;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Check if all recorded calls have been matched
|
|
286
|
+
*/
|
|
287
|
+
isComplete() {
|
|
288
|
+
return this.currentIndex >= this.recordedCalls.length;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Get summary of unmatched calls (calls that were recorded but not replayed)
|
|
292
|
+
*/
|
|
293
|
+
getUnmatchedCalls() {
|
|
294
|
+
return this.recordedCalls.slice(this.currentIndex);
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Finalize replay and check for missing outbound calls.
|
|
298
|
+
* Should be called at the end of replay to detect any recorded calls that never happened.
|
|
299
|
+
*
|
|
300
|
+
* In strict mode: throws MissingOutboundCall[] if any calls are missing
|
|
301
|
+
* In explain mode: emits divergences and returns the missing calls
|
|
302
|
+
*
|
|
303
|
+
* @returns Array of MissingOutboundCall failures (empty if all calls matched)
|
|
304
|
+
*/
|
|
305
|
+
finalize() {
|
|
306
|
+
const missingCalls = [];
|
|
307
|
+
const unmatchedCalls = this.getUnmatchedCalls();
|
|
308
|
+
for (let i = 0; i < unmatchedCalls.length; i++) {
|
|
309
|
+
const recordedCall = unmatchedCalls[i];
|
|
310
|
+
const callIndex = this.currentIndex + i;
|
|
311
|
+
const failure = {
|
|
312
|
+
type: "missing_outbound_call",
|
|
313
|
+
location: `${this.traceDir}/events.jsonl:call[${callIndex}]`,
|
|
314
|
+
expected: `${recordedCall.method} ${recordedCall.normalizedUrl}`,
|
|
315
|
+
actual: "<call never made>",
|
|
316
|
+
hint: "A recorded outbound HTTP call was never made during replay. This may indicate code path divergence, conditional logic changes, or early termination.",
|
|
317
|
+
callIndex,
|
|
318
|
+
service: this.traceDir,
|
|
319
|
+
spanId: recordedCall.spanId,
|
|
320
|
+
recordedCall,
|
|
321
|
+
};
|
|
322
|
+
missingCalls.push(failure);
|
|
323
|
+
// Log missing call
|
|
324
|
+
this.logger?.httpMissing({
|
|
325
|
+
callIndex,
|
|
326
|
+
method: recordedCall.method,
|
|
327
|
+
url: recordedCall.normalizedUrl,
|
|
328
|
+
spanId: recordedCall.spanId,
|
|
329
|
+
bodyHash: recordedCall.bodyHash,
|
|
330
|
+
});
|
|
331
|
+
// Emit divergence
|
|
332
|
+
this.divergences.push({
|
|
333
|
+
type: "divergence",
|
|
334
|
+
severity: "error",
|
|
335
|
+
callIndex,
|
|
336
|
+
message: `Missing outbound call: ${recordedCall.method} ${recordedCall.normalizedUrl}`,
|
|
337
|
+
expected: recordedCall,
|
|
338
|
+
actual: { method: "", url: "", headers: {}, bodyHash: null },
|
|
339
|
+
recoverable: false,
|
|
340
|
+
});
|
|
341
|
+
// Emit to stderr for CLI visibility
|
|
342
|
+
console.warn(`[Replay] Missing call ${callIndex}: expected ${recordedCall.method} ${recordedCall.normalizedUrl}`);
|
|
343
|
+
}
|
|
344
|
+
if (missingCalls.length > 0 && this.mode === "strict") {
|
|
345
|
+
// In strict mode, throw the first missing call
|
|
346
|
+
// (caller can access all via the returned array if caught)
|
|
347
|
+
throw missingCalls[0];
|
|
348
|
+
}
|
|
349
|
+
return missingCalls;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
exports.OutboundMatcher = OutboundMatcher;
|
|
353
|
+
/**
|
|
354
|
+
* Parse recorded outbound calls from trace events
|
|
355
|
+
*/
|
|
356
|
+
function parseRecordedOutboundCalls(events) {
|
|
357
|
+
const requestsBySpan = new Map();
|
|
358
|
+
for (const event of events) {
|
|
359
|
+
if (event.type === "http.request.outbound") {
|
|
360
|
+
requestsBySpan.set(event.spanId, {
|
|
361
|
+
spanId: event.spanId,
|
|
362
|
+
method: event.method,
|
|
363
|
+
url: event.url,
|
|
364
|
+
normalizedUrl: normalizeUrl(event.url),
|
|
365
|
+
headers: event.headers || {},
|
|
366
|
+
bodyHash: event.bodyHash || null,
|
|
367
|
+
response: null,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
else if (event.type === "http.response.outbound") {
|
|
371
|
+
const request = requestsBySpan.get(event.spanId);
|
|
372
|
+
if (request) {
|
|
373
|
+
request.response = {
|
|
374
|
+
statusCode: event.statusCode,
|
|
375
|
+
headers: event.headers || {},
|
|
376
|
+
bodyHash: event.bodyHash || null,
|
|
377
|
+
bodyBlob: event.bodyBlob || null,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
// Return calls in order (by their appearance in events)
|
|
383
|
+
// Filter out requests without responses
|
|
384
|
+
return Array.from(requestsBySpan.values()).filter((call) => call.response !== null);
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Format OutboundRequestMismatch for CLI output
|
|
388
|
+
*/
|
|
389
|
+
function formatOutboundMismatch(failure) {
|
|
390
|
+
const lines = [
|
|
391
|
+
`[Replay Error] ${failure.type}`,
|
|
392
|
+
` Location: ${failure.location}`,
|
|
393
|
+
` Call #${failure.callIndex}`,
|
|
394
|
+
` Expected: ${failure.expected}`,
|
|
395
|
+
` Actual: ${failure.actual}`,
|
|
396
|
+
` Differences:`,
|
|
397
|
+
];
|
|
398
|
+
for (const diff of failure.diff) {
|
|
399
|
+
lines.push(` ${diff.field}: "${diff.expected}" → "${diff.actual}"`);
|
|
400
|
+
}
|
|
401
|
+
lines.push(` Hint: ${failure.hint}`);
|
|
402
|
+
return lines.join("\n");
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Format UnexpectedOutboundCall for CLI output
|
|
406
|
+
*/
|
|
407
|
+
function formatUnexpectedOutboundCall(failure) {
|
|
408
|
+
const lines = [
|
|
409
|
+
`[Replay Error] ${failure.type}`,
|
|
410
|
+
` Location: ${failure.location}`,
|
|
411
|
+
` Service: ${failure.service}`,
|
|
412
|
+
` Call #${failure.callIndex}`,
|
|
413
|
+
` Expected: ${failure.expected}`,
|
|
414
|
+
` Actual: ${failure.actual}`,
|
|
415
|
+
` Call Details:`,
|
|
416
|
+
` Method: ${failure.call.method}`,
|
|
417
|
+
` URL: ${failure.call.url}`,
|
|
418
|
+
` Body Hash: ${failure.call.bodyHash || "<none>"}`,
|
|
419
|
+
` Hint: ${failure.hint}`,
|
|
420
|
+
];
|
|
421
|
+
return lines.join("\n");
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Format MissingOutboundCall for CLI output
|
|
425
|
+
*/
|
|
426
|
+
function formatMissingOutboundCall(failure) {
|
|
427
|
+
const lines = [
|
|
428
|
+
`[Replay Error] ${failure.type}`,
|
|
429
|
+
` Location: ${failure.location}`,
|
|
430
|
+
` Service: ${failure.service}`,
|
|
431
|
+
` Span ID: ${failure.spanId}`,
|
|
432
|
+
` Call #${failure.callIndex}`,
|
|
433
|
+
` Expected: ${failure.expected}`,
|
|
434
|
+
` Actual: ${failure.actual}`,
|
|
435
|
+
` Recorded Call:`,
|
|
436
|
+
` Method: ${failure.recordedCall.method}`,
|
|
437
|
+
` URL: ${failure.recordedCall.url}`,
|
|
438
|
+
` Body Hash: ${failure.recordedCall.bodyHash || "<none>"}`,
|
|
439
|
+
` Hint: ${failure.hint}`,
|
|
440
|
+
];
|
|
441
|
+
return lines.join("\n");
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Format any outbound call failure for CLI output
|
|
445
|
+
*/
|
|
446
|
+
function formatOutboundCallFailure(failure) {
|
|
447
|
+
switch (failure.type) {
|
|
448
|
+
case "outbound_request_mismatch":
|
|
449
|
+
return formatOutboundMismatch(failure);
|
|
450
|
+
case "unexpected_outbound_call":
|
|
451
|
+
return formatUnexpectedOutboundCall(failure);
|
|
452
|
+
case "missing_outbound_call":
|
|
453
|
+
return formatMissingOutboundCall(failure);
|
|
454
|
+
default:
|
|
455
|
+
return `[Replay Error] Unknown failure type`;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Peer service resolution for outbound HTTP calls
|
|
3
|
+
* Maps hostnames to service names for multi-service graph building
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Resolve peer service name from hostname using mapping
|
|
7
|
+
*
|
|
8
|
+
* @param hostname - The hostname from the URL (e.g., "pricing.internal", "api.example.com")
|
|
9
|
+
* @returns Object with peerService (string | null) and peerHost (string | null)
|
|
10
|
+
*/
|
|
11
|
+
export declare function resolvePeerService(hostname: string): {
|
|
12
|
+
peerService: string | null;
|
|
13
|
+
peerHost: string | null;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Extract URL template from URL (path without query params)
|
|
17
|
+
*
|
|
18
|
+
* @param url - Full URL string
|
|
19
|
+
* @returns URL template (path only, no query params) or null if parsing fails
|
|
20
|
+
*/
|
|
21
|
+
export declare function extractUrlTemplate(url: string): string | null;
|
|
22
|
+
//# sourceMappingURL=peer-service-resolver.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"peer-service-resolver.d.ts","sourceRoot":"","sources":["../src/peer-service-resolver.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG;IACpD,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB,CAiDA;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAgB7D"}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Peer service resolution for outbound HTTP calls
|
|
4
|
+
* Maps hostnames to service names for multi-service graph building
|
|
5
|
+
*/
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.resolvePeerService = resolvePeerService;
|
|
8
|
+
exports.extractUrlTemplate = extractUrlTemplate;
|
|
9
|
+
/**
|
|
10
|
+
* Resolve peer service name from hostname using mapping
|
|
11
|
+
*
|
|
12
|
+
* @param hostname - The hostname from the URL (e.g., "pricing.internal", "api.example.com")
|
|
13
|
+
* @returns Object with peerService (string | null) and peerHost (string | null)
|
|
14
|
+
*/
|
|
15
|
+
function resolvePeerService(hostname) {
|
|
16
|
+
// Parse peer mapping from environment variable
|
|
17
|
+
const peerMapJson = process.env.WRAITH_PEER_MAP_JSON;
|
|
18
|
+
if (!peerMapJson) {
|
|
19
|
+
// No mapping configured - return hostname as peerHost
|
|
20
|
+
return {
|
|
21
|
+
peerService: null,
|
|
22
|
+
peerHost: hostname,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
let peerMap;
|
|
26
|
+
try {
|
|
27
|
+
peerMap = JSON.parse(peerMapJson);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
console.warn(`[SDK] Failed to parse WRAITH_PEER_MAP_JSON: ${error}`);
|
|
31
|
+
return {
|
|
32
|
+
peerService: null,
|
|
33
|
+
peerHost: hostname,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
// Check for exact hostname match
|
|
37
|
+
if (peerMap[hostname]) {
|
|
38
|
+
return {
|
|
39
|
+
peerService: peerMap[hostname],
|
|
40
|
+
peerHost: hostname,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
// Check for domain match (e.g., "*.internal" matches "pricing.internal")
|
|
44
|
+
for (const [pattern, serviceName] of Object.entries(peerMap)) {
|
|
45
|
+
if (pattern.startsWith('*.')) {
|
|
46
|
+
const domain = pattern.substring(2); // Remove "*."
|
|
47
|
+
if (hostname.endsWith('.' + domain) || hostname === domain) {
|
|
48
|
+
return {
|
|
49
|
+
peerService: serviceName,
|
|
50
|
+
peerHost: hostname,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// No match found - return hostname as peerHost
|
|
56
|
+
return {
|
|
57
|
+
peerService: null,
|
|
58
|
+
peerHost: hostname,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Extract URL template from URL (path without query params)
|
|
63
|
+
*
|
|
64
|
+
* @param url - Full URL string
|
|
65
|
+
* @returns URL template (path only, no query params) or null if parsing fails
|
|
66
|
+
*/
|
|
67
|
+
function extractUrlTemplate(url) {
|
|
68
|
+
try {
|
|
69
|
+
const urlObj = new URL(url);
|
|
70
|
+
return urlObj.pathname;
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
// If URL parsing fails, try to extract path manually
|
|
74
|
+
try {
|
|
75
|
+
const pathMatch = url.match(/^https?:\/\/[^\/]+(\/[^?]*)/);
|
|
76
|
+
if (pathMatch) {
|
|
77
|
+
return pathMatch[1];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// Ignore
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redaction Module
|
|
3
|
+
*
|
|
4
|
+
* Provides privacy guardrails for trace capture by:
|
|
5
|
+
* - Masking sensitive headers (auth, tokens, cookies)
|
|
6
|
+
* - Redacting passwords and secrets from request/response bodies
|
|
7
|
+
* - Supporting custom redaction rules via config file
|
|
8
|
+
* - Using deterministic hashing for redacted values (stable across runs)
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Redaction rule types
|
|
12
|
+
*/
|
|
13
|
+
export type RedactionRuleType = "header" | "body_field" | "body_pattern" | "query_param";
|
|
14
|
+
/**
|
|
15
|
+
* A single redaction rule
|
|
16
|
+
*/
|
|
17
|
+
export interface RedactionRule {
|
|
18
|
+
type: RedactionRuleType;
|
|
19
|
+
/** For header/query_param: exact name or pattern */
|
|
20
|
+
name?: string;
|
|
21
|
+
/** For body_pattern: regex pattern */
|
|
22
|
+
pattern?: string;
|
|
23
|
+
/** Whether to use regex matching for name */
|
|
24
|
+
regex?: boolean;
|
|
25
|
+
/** Custom replacement (default: [REDACTED:hash]) */
|
|
26
|
+
replacement?: string;
|
|
27
|
+
/** Whether to include a deterministic hash suffix */
|
|
28
|
+
hash?: boolean;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Redaction configuration
|
|
32
|
+
*/
|
|
33
|
+
export interface RedactionConfig {
|
|
34
|
+
/** Enable/disable redaction (default: true) */
|
|
35
|
+
enabled: boolean;
|
|
36
|
+
/** Custom rules to add to defaults */
|
|
37
|
+
rules: RedactionRule[];
|
|
38
|
+
/** Override default rules entirely */
|
|
39
|
+
overrideDefaults?: boolean;
|
|
40
|
+
/** Salt for deterministic hashing (for privacy, use a secret) */
|
|
41
|
+
hashSalt?: string;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Compute a deterministic hash for a redacted value
|
|
45
|
+
* The hash is stable across runs given the same input and salt
|
|
46
|
+
*/
|
|
47
|
+
export declare function computeRedactionHash(value: string, salt?: string): string;
|
|
48
|
+
/**
|
|
49
|
+
* Create a redacted placeholder
|
|
50
|
+
*/
|
|
51
|
+
export declare function createRedactedValue(originalValue: string, includeHash?: boolean, salt?: string): string;
|
|
52
|
+
/**
|
|
53
|
+
* Load redaction config from file
|
|
54
|
+
*/
|
|
55
|
+
export declare function loadRedactionConfig(configPath?: string): RedactionConfig;
|
|
56
|
+
/**
|
|
57
|
+
* Set global redaction config
|
|
58
|
+
*/
|
|
59
|
+
export declare function setRedactionConfig(config: Partial<RedactionConfig>): void;
|
|
60
|
+
/**
|
|
61
|
+
* Get current redaction config
|
|
62
|
+
*/
|
|
63
|
+
export declare function getRedactionConfig(): RedactionConfig;
|
|
64
|
+
/**
|
|
65
|
+
* Reset redaction config to defaults
|
|
66
|
+
*/
|
|
67
|
+
export declare function resetRedactionConfig(): void;
|
|
68
|
+
/**
|
|
69
|
+
* Check if a header name should be redacted
|
|
70
|
+
*/
|
|
71
|
+
export declare function shouldRedactHeader(headerName: string): boolean;
|
|
72
|
+
/**
|
|
73
|
+
* Check if a query param should be redacted
|
|
74
|
+
*/
|
|
75
|
+
export declare function shouldRedactQueryParam(paramName: string): boolean;
|
|
76
|
+
/**
|
|
77
|
+
* Redact headers in a headers object
|
|
78
|
+
*/
|
|
79
|
+
export declare function redactHeaders(headers: Record<string, string | string[] | undefined>): Record<string, string | string[] | undefined>;
|
|
80
|
+
/**
|
|
81
|
+
* Redact query parameters
|
|
82
|
+
*/
|
|
83
|
+
export declare function redactQueryParams(query: Record<string, any>): Record<string, any>;
|
|
84
|
+
/**
|
|
85
|
+
* Redact sensitive fields from a body object (recursive)
|
|
86
|
+
*/
|
|
87
|
+
export declare function redactBodyObject(body: any): any;
|
|
88
|
+
/**
|
|
89
|
+
* Redact patterns from a body string
|
|
90
|
+
*/
|
|
91
|
+
export declare function redactBodyPatterns(body: string): string;
|
|
92
|
+
/**
|
|
93
|
+
* Redact a body (handles both string and object)
|
|
94
|
+
*/
|
|
95
|
+
export declare function redactBody(body: string | object | null | undefined): string | object | null | undefined;
|
|
96
|
+
/**
|
|
97
|
+
* Redact a URL (query params)
|
|
98
|
+
*/
|
|
99
|
+
export declare function redactUrl(url: string): string;
|
|
100
|
+
/**
|
|
101
|
+
* Check if a value looks like it contains sensitive data
|
|
102
|
+
* (heuristic-based, for additional protection)
|
|
103
|
+
*/
|
|
104
|
+
export declare function containsSensitivePatterns(value: string): boolean;
|
|
105
|
+
/**
|
|
106
|
+
* Export default sensitive lists for testing
|
|
107
|
+
*/
|
|
108
|
+
export declare const SENSITIVE_HEADERS: string[];
|
|
109
|
+
export declare const SENSITIVE_QUERY_PARAMS: string[];
|
|
110
|
+
export declare const SENSITIVE_BODY_FIELDS: string[];
|
|
111
|
+
//# sourceMappingURL=redaction.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"redaction.d.ts","sourceRoot":"","sources":["../src/redaction.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAMH;;GAEG;AACH,MAAM,MAAM,iBAAiB,GACzB,QAAQ,GACR,YAAY,GACZ,cAAc,GACd,aAAa,CAAC;AAElB;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,iBAAiB,CAAC;IACxB,oDAAoD;IACpD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,sCAAsC;IACtC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,6CAA6C;IAC7C,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,oDAAoD;IACpD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,qDAAqD;IACrD,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,+CAA+C;IAC/C,OAAO,EAAE,OAAO,CAAC;IACjB,sCAAsC;IACtC,KAAK,EAAE,aAAa,EAAE,CAAC;IACvB,sCAAsC;IACtC,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,iEAAiE;IACjE,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAkJD;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,KAAK,EAAE,MAAM,EACb,IAAI,GAAE,MAAoC,GACzC,MAAM,CAMR;AAED;;GAEG;AACH,wBAAgB,mBAAmB,CACjC,aAAa,EAAE,MAAM,EACrB,WAAW,GAAE,OAAc,EAC3B,IAAI,CAAC,EAAE,MAAM,GACZ,MAAM,CAMR;AAED;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,eAAe,CAwCxE;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,OAAO,CAAC,eAAe,CAAC,GAAG,IAAI,CAQzE;AAED;;GAEG;AACH,wBAAgB,kBAAkB,IAAI,eAAe,CAEpD;AAED;;GAEG;AACH,wBAAgB,oBAAoB,IAAI,IAAI,CAE3C;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAiB9D;AAED;;GAEG;AACH,wBAAgB,sBAAsB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAiBjE;AAED;;GAEG;AACH,wBAAgB,aAAa,CAC3B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,CAAC,GACrD,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,CAAC,CAwB/C;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GACzB,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAsBrB;AAsBD;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,GAAG,GAAG,GAAG,CAkC/C;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAwBvD;AAED;;GAEG;AACH,wBAAgB,UAAU,CACxB,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,GAAG,SAAS,GACvC,MAAM,GAAG,MAAM,GAAG,IAAI,GAAG,SAAS,CA6BpC;AAED;;GAEG;AACH,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CA6B7C;AAED;;;GAGG;AACH,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAYhE;AAED;;GAEG;AACH,eAAO,MAAM,iBAAiB,UAA4B,CAAC;AAC3D,eAAO,MAAM,sBAAsB,UAAiC,CAAC;AACrE,eAAO,MAAM,qBAAqB,UAAgC,CAAC"}
|