@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,101 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Base event envelope - all trace events must include these fields
|
|
4
|
+
*/
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.createBaseEvent = createBaseEvent;
|
|
7
|
+
exports.validateBaseEnvelope = validateBaseEnvelope;
|
|
8
|
+
const fs_1 = require("fs");
|
|
9
|
+
const path_1 = require("path");
|
|
10
|
+
/**
|
|
11
|
+
* Resolve service name from environment variable, package.json, or fallback
|
|
12
|
+
*/
|
|
13
|
+
function resolveServiceName() {
|
|
14
|
+
// 1. Check environment variable (required in production)
|
|
15
|
+
const envServiceName = process.env.SERVICE_NAME;
|
|
16
|
+
if (envServiceName) {
|
|
17
|
+
return envServiceName;
|
|
18
|
+
}
|
|
19
|
+
// 2. Try to read from package.json (dev fallback)
|
|
20
|
+
try {
|
|
21
|
+
// Try to find package.json in common locations
|
|
22
|
+
const possiblePaths = [
|
|
23
|
+
(0, path_1.join)(process.cwd(), 'package.json'),
|
|
24
|
+
(0, path_1.join)(__dirname, '..', 'package.json'),
|
|
25
|
+
(0, path_1.join)(__dirname, '..', '..', 'package.json'),
|
|
26
|
+
];
|
|
27
|
+
for (const pkgPath of possiblePaths) {
|
|
28
|
+
try {
|
|
29
|
+
const pkgContent = (0, fs_1.readFileSync)(pkgPath, 'utf8');
|
|
30
|
+
const pkg = JSON.parse(pkgContent);
|
|
31
|
+
if (pkg.name) {
|
|
32
|
+
return pkg.name;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// Continue to next path
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// Fall through to fallback
|
|
42
|
+
}
|
|
43
|
+
// 3. Fallback
|
|
44
|
+
return 'unknown-service';
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Create a base event envelope with required fields
|
|
48
|
+
*
|
|
49
|
+
* @param type - Event type (e.g., 'http.request.inbound')
|
|
50
|
+
* @param timestamp - Event timestamp (milliseconds since epoch)
|
|
51
|
+
* @param traceId - Trace ID
|
|
52
|
+
* @param spanId - Span ID
|
|
53
|
+
* @param parentSpanId - Parent span ID (null for root spans)
|
|
54
|
+
* @param serviceName - Optional service name (will be resolved if not provided)
|
|
55
|
+
* @param additionalFields - Additional event-specific fields
|
|
56
|
+
* @returns Complete event object with base envelope + additional fields
|
|
57
|
+
*/
|
|
58
|
+
function createBaseEvent(type, timestamp, traceId, spanId, parentSpanId, serviceName, additionalFields) {
|
|
59
|
+
const resolvedServiceName = serviceName || resolveServiceName();
|
|
60
|
+
const baseEnvelope = {
|
|
61
|
+
type,
|
|
62
|
+
timestamp,
|
|
63
|
+
traceId,
|
|
64
|
+
spanId,
|
|
65
|
+
parentSpanId,
|
|
66
|
+
serviceName: resolvedServiceName,
|
|
67
|
+
};
|
|
68
|
+
// Merge additional fields, ensuring base envelope fields take precedence
|
|
69
|
+
return {
|
|
70
|
+
...additionalFields,
|
|
71
|
+
...baseEnvelope,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Validate that an event has all required base envelope fields
|
|
76
|
+
*/
|
|
77
|
+
function validateBaseEnvelope(event) {
|
|
78
|
+
const errors = [];
|
|
79
|
+
if (!event.type || typeof event.type !== 'string') {
|
|
80
|
+
errors.push('Missing or invalid "type" field');
|
|
81
|
+
}
|
|
82
|
+
if (typeof event.timestamp !== 'number') {
|
|
83
|
+
errors.push('Missing or invalid "timestamp" field (must be number)');
|
|
84
|
+
}
|
|
85
|
+
if (!event.traceId || typeof event.traceId !== 'string') {
|
|
86
|
+
errors.push('Missing or invalid "traceId" field');
|
|
87
|
+
}
|
|
88
|
+
if (!event.spanId || typeof event.spanId !== 'string') {
|
|
89
|
+
errors.push('Missing or invalid "spanId" field');
|
|
90
|
+
}
|
|
91
|
+
if (event.parentSpanId !== null && typeof event.parentSpanId !== 'string') {
|
|
92
|
+
errors.push('Invalid "parentSpanId" field (must be string or null)');
|
|
93
|
+
}
|
|
94
|
+
if (!event.serviceName || typeof event.serviceName !== 'string') {
|
|
95
|
+
errors.push('Missing or invalid "serviceName" field');
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
valid: errors.length === 0,
|
|
99
|
+
errors,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { FastifyPluginAsync } from "fastify";
|
|
2
|
+
import { TraceBundle } from "./trace-bundle-writer";
|
|
3
|
+
export interface FastifyTracingOptions {
|
|
4
|
+
serviceName: string;
|
|
5
|
+
serviceVersion?: string;
|
|
6
|
+
environment?: string;
|
|
7
|
+
traceDir?: string;
|
|
8
|
+
headerAllowlist?: string[];
|
|
9
|
+
maxBodySize?: number;
|
|
10
|
+
enabled?: boolean;
|
|
11
|
+
uploadUrl?: string;
|
|
12
|
+
apiKey?: string;
|
|
13
|
+
uploadOnComplete?: boolean;
|
|
14
|
+
}
|
|
15
|
+
declare module "fastify" {
|
|
16
|
+
interface FastifyRequest {
|
|
17
|
+
traceBundle?: TraceBundle;
|
|
18
|
+
traceSpanId?: string;
|
|
19
|
+
traceStartTime?: number;
|
|
20
|
+
traceRequestBodyHash?: string | null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Fastify plugin for capturing HTTP request/response traces
|
|
25
|
+
*/
|
|
26
|
+
export declare const fastifyTracing: FastifyPluginAsync<FastifyTracingOptions>;
|
|
27
|
+
declare const _default: FastifyPluginAsync<FastifyTracingOptions>;
|
|
28
|
+
export default _default;
|
|
29
|
+
//# sourceMappingURL=fastify-plugin.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fastify-plugin.d.ts","sourceRoot":"","sources":["../src/fastify-plugin.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAgC,MAAM,SAAS,CAAC;AAE3E,OAAO,EAKL,WAAW,EACZ,MAAM,uBAAuB,CAAC;AAY/B,MAAM,WAAW,qBAAqB;IACpC,WAAW,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAC3B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,OAAO,CAAC;IAElB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B;AAED,OAAO,QAAQ,SAAS,CAAC;IACvB,UAAU,cAAc;QACtB,WAAW,CAAC,EAAE,WAAW,CAAC;QAC1B,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,cAAc,CAAC,EAAE,MAAM,CAAC;QACxB,oBAAoB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;KACtC;CACF;AAID;;GAEG;AACH,eAAO,MAAM,cAAc,EAAE,kBAAkB,CAAC,qBAAqB,CAsRpE,CAAC;;AAGF,wBAGG"}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.fastifyTracing = void 0;
|
|
7
|
+
const fastify_plugin_1 = __importDefault(require("fastify-plugin"));
|
|
8
|
+
const trace_bundle_writer_1 = require("./trace-bundle-writer");
|
|
9
|
+
const http_wrapper_1 = require("./http-wrapper");
|
|
10
|
+
const redaction_1 = require("./redaction");
|
|
11
|
+
const shape_digest_utils_1 = require("./shape-digest-utils");
|
|
12
|
+
const trace_uploader_1 = require("./trace-uploader");
|
|
13
|
+
const DEFAULT_MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB
|
|
14
|
+
/**
|
|
15
|
+
* Fastify plugin for capturing HTTP request/response traces
|
|
16
|
+
*/
|
|
17
|
+
const fastifyTracing = async (fastify, options) => {
|
|
18
|
+
const { serviceName, serviceVersion, environment, traceDir, headerAllowlist, maxBodySize = DEFAULT_MAX_BODY_SIZE, enabled = true, uploadUrl, apiKey, uploadOnComplete = true, } = options;
|
|
19
|
+
if (!enabled) {
|
|
20
|
+
fastify.log.info("[SDK] Tracing is disabled");
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (!serviceName) {
|
|
24
|
+
fastify.log.warn("[SDK] serviceName is required for tracing");
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
// Hook: onRequest - initialize trace but don't write request event yet (body not available)
|
|
28
|
+
fastify.addHook("onRequest", async (request, reply) => {
|
|
29
|
+
try {
|
|
30
|
+
// Check for trace context propagation headers
|
|
31
|
+
const incomingTraceId = request.headers['x-wraith-trace-id'];
|
|
32
|
+
const incomingParentSpanId = request.headers['x-wraith-parent-span-id'];
|
|
33
|
+
// Create trace bundle for this request
|
|
34
|
+
// Use incoming traceId if present (cross-service propagation), otherwise generate new one
|
|
35
|
+
const traceBundle = await (0, trace_bundle_writer_1.createTrace)({
|
|
36
|
+
serviceName,
|
|
37
|
+
serviceVersion,
|
|
38
|
+
environment,
|
|
39
|
+
traceDir,
|
|
40
|
+
traceId: incomingTraceId, // Will be undefined if header not present, triggering new traceId generation
|
|
41
|
+
});
|
|
42
|
+
const spanId = (0, trace_bundle_writer_1.generateSpanId)();
|
|
43
|
+
const startTime = Date.now();
|
|
44
|
+
// Store on request for later use
|
|
45
|
+
request.traceBundle = traceBundle;
|
|
46
|
+
request.traceSpanId = spanId;
|
|
47
|
+
request.traceStartTime = startTime;
|
|
48
|
+
request.traceRequestBodyHash = null; // Will be set in preHandler
|
|
49
|
+
// Store incoming parent span ID for use in preHandler
|
|
50
|
+
request.traceIncomingParentSpanId = incomingParentSpanId || null;
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
// Fail-open: log error but don't crash
|
|
54
|
+
fastify.log.error(`[SDK] Failed to initialize trace: ${error}`);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
// Hook: preHandler - capture inbound request with body (body is now parsed by Fastify)
|
|
58
|
+
fastify.addHook("preHandler", async (request, reply) => {
|
|
59
|
+
const traceBundle = request.traceBundle;
|
|
60
|
+
const spanId = request.traceSpanId;
|
|
61
|
+
const startTime = request.traceStartTime;
|
|
62
|
+
if (!traceBundle || !spanId || !startTime)
|
|
63
|
+
return;
|
|
64
|
+
// Set trace context for this request (so wrapped fetch can access it)
|
|
65
|
+
(0, http_wrapper_1.setTraceContext)(traceBundle, spanId);
|
|
66
|
+
try {
|
|
67
|
+
// Capture request body (now available after Fastify parsing)
|
|
68
|
+
let requestBody = null;
|
|
69
|
+
if (request.body) {
|
|
70
|
+
if (typeof request.body === "string") {
|
|
71
|
+
requestBody = request.body;
|
|
72
|
+
}
|
|
73
|
+
else if (Buffer.isBuffer(request.body)) {
|
|
74
|
+
requestBody = request.body;
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
requestBody = JSON.stringify(request.body);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Process body (hash + optional blob)
|
|
81
|
+
const { bodyHash, bodyBlob } = await (0, trace_bundle_writer_1.processBody)(requestBody, traceBundle.writeBlob);
|
|
82
|
+
request.traceRequestBodyHash = bodyHash;
|
|
83
|
+
// Filter headers (redaction is applied automatically)
|
|
84
|
+
const filteredHeaders = (0, trace_bundle_writer_1.filterHeaders)(request.headers, headerAllowlist);
|
|
85
|
+
// Extract and redact query params
|
|
86
|
+
const rawQuery = request.query || {};
|
|
87
|
+
const query = (0, redaction_1.redactQueryParams)(rawQuery);
|
|
88
|
+
// Get incoming parent span ID (from cross-service propagation)
|
|
89
|
+
const incomingParentSpanId = request.traceIncomingParentSpanId;
|
|
90
|
+
// Compute shape digest for request body (async, non-blocking)
|
|
91
|
+
const contentType = (0, shape_digest_utils_1.getContentType)(request.headers);
|
|
92
|
+
const mode = (0, shape_digest_utils_1.getShapeDigestMode)();
|
|
93
|
+
const sampleRate = (0, shape_digest_utils_1.getShapeDigestSampleRate)();
|
|
94
|
+
// Use requestBody if available, otherwise fall back to request.body with proper typing
|
|
95
|
+
const bodyForDigest = requestBody ?? request.body;
|
|
96
|
+
const requestShapeDigest = await (0, shape_digest_utils_1.computeBodyShapeDigest)(bodyForDigest, contentType, { mode, sampleRate });
|
|
97
|
+
const requestContentType = contentType || undefined;
|
|
98
|
+
const bodyForSize = requestBody ?? request.body;
|
|
99
|
+
const requestSizeBytes = (0, shape_digest_utils_1.getBodySizeBytes)(bodyForSize);
|
|
100
|
+
// Write http.request.inbound event (now with body!)
|
|
101
|
+
await traceBundle.writeEvent({
|
|
102
|
+
type: "http.request.inbound",
|
|
103
|
+
timestamp: startTime,
|
|
104
|
+
spanId,
|
|
105
|
+
parentSpanId: incomingParentSpanId, // Use propagated parentSpanId if present, otherwise null (root span)
|
|
106
|
+
method: request.method,
|
|
107
|
+
path: request.url.split("?")[0], // Path without query
|
|
108
|
+
query,
|
|
109
|
+
headers: filteredHeaders,
|
|
110
|
+
bodyHash,
|
|
111
|
+
bodyBlob,
|
|
112
|
+
...(requestShapeDigest && { requestShapeDigest }),
|
|
113
|
+
...(requestContentType && { requestContentType }),
|
|
114
|
+
...(requestSizeBytes !== undefined && { requestSizeBytes }),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
fastify.log.error(`[SDK] Failed to capture inbound request: ${error}`);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
// Hook: onSend - capture response
|
|
122
|
+
fastify.addHook("onSend", async (request, reply, payload) => {
|
|
123
|
+
const traceBundle = request.traceBundle;
|
|
124
|
+
const spanId = request.traceSpanId;
|
|
125
|
+
const startTime = request.traceStartTime;
|
|
126
|
+
if (!traceBundle || !spanId || !startTime) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
const endTime = Date.now();
|
|
131
|
+
const durationMs = endTime - startTime;
|
|
132
|
+
// Process response body
|
|
133
|
+
let responseBody = null;
|
|
134
|
+
if (payload) {
|
|
135
|
+
if (Buffer.isBuffer(payload)) {
|
|
136
|
+
responseBody = payload;
|
|
137
|
+
}
|
|
138
|
+
else if (typeof payload === "string") {
|
|
139
|
+
responseBody = payload;
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
responseBody = JSON.stringify(payload);
|
|
143
|
+
}
|
|
144
|
+
// Check size limit
|
|
145
|
+
const bodySize = Buffer.isBuffer(responseBody)
|
|
146
|
+
? responseBody.length
|
|
147
|
+
: responseBody.length;
|
|
148
|
+
if (bodySize > maxBodySize) {
|
|
149
|
+
responseBody = null; // Skip large bodies
|
|
150
|
+
fastify.log.debug(`[SDK] Response body too large (${bodySize} bytes), skipping`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const { bodyHash, bodyBlob } = await (0, trace_bundle_writer_1.processBody)(responseBody, traceBundle.writeBlob);
|
|
154
|
+
// Filter response headers (convert numbers to strings)
|
|
155
|
+
const responseHeaders = {};
|
|
156
|
+
const rawHeaders = reply.getHeaders();
|
|
157
|
+
for (const [key, value] of Object.entries(rawHeaders)) {
|
|
158
|
+
responseHeaders[key] = String(value);
|
|
159
|
+
}
|
|
160
|
+
const filteredHeaders = (0, trace_bundle_writer_1.filterHeaders)(responseHeaders, headerAllowlist);
|
|
161
|
+
// Write http.response.inbound event
|
|
162
|
+
await traceBundle.writeEvent({
|
|
163
|
+
type: "http.response.inbound",
|
|
164
|
+
timestamp: endTime,
|
|
165
|
+
spanId,
|
|
166
|
+
parentSpanId: null,
|
|
167
|
+
statusCode: reply.statusCode,
|
|
168
|
+
headers: filteredHeaders,
|
|
169
|
+
bodyHash,
|
|
170
|
+
bodyBlob,
|
|
171
|
+
durationMs,
|
|
172
|
+
});
|
|
173
|
+
// Complete trace with inbound request summary
|
|
174
|
+
await traceBundle.complete({
|
|
175
|
+
method: request.method,
|
|
176
|
+
path: request.url.split("?")[0],
|
|
177
|
+
headers: (0, trace_bundle_writer_1.filterHeaders)(request.headers, headerAllowlist),
|
|
178
|
+
bodyHash: request.traceRequestBodyHash || undefined,
|
|
179
|
+
});
|
|
180
|
+
// Auto-upload trace if configured
|
|
181
|
+
if (uploadUrl && apiKey && uploadOnComplete !== false) {
|
|
182
|
+
// Upload asynchronously (don't block response)
|
|
183
|
+
const actualTraceDir = traceDir || './traces';
|
|
184
|
+
(0, trace_uploader_1.uploadTrace)({
|
|
185
|
+
uploadUrl,
|
|
186
|
+
apiKey,
|
|
187
|
+
traceDir: actualTraceDir,
|
|
188
|
+
traceId: traceBundle.traceId,
|
|
189
|
+
serviceName,
|
|
190
|
+
serviceVersion,
|
|
191
|
+
environment,
|
|
192
|
+
}).then(result => {
|
|
193
|
+
if (result.success) {
|
|
194
|
+
fastify.log.info(`[SDK] Trace uploaded: ${result.traceId}`);
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
fastify.log.warn(`[SDK] Trace upload failed: ${result.error}`);
|
|
198
|
+
}
|
|
199
|
+
}).catch(error => {
|
|
200
|
+
fastify.log.error(`[SDK] Trace upload error: ${error}`);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
// Clear trace context
|
|
204
|
+
(0, http_wrapper_1.setTraceContext)(null, null);
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
// Fail-open: log error but don't crash
|
|
208
|
+
fastify.log.error(`[SDK] Failed to capture response: ${error}`);
|
|
209
|
+
// Clear trace context even on error
|
|
210
|
+
(0, http_wrapper_1.setTraceContext)(null, null);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
// Hook: onError - capture errors
|
|
214
|
+
fastify.addHook("onError", async (request, reply, error) => {
|
|
215
|
+
const traceBundle = request.traceBundle;
|
|
216
|
+
const spanId = request.traceSpanId;
|
|
217
|
+
if (!traceBundle || !spanId) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
await traceBundle.writeEvent({
|
|
222
|
+
type: "error",
|
|
223
|
+
timestamp: Date.now(),
|
|
224
|
+
spanId,
|
|
225
|
+
parentSpanId: null,
|
|
226
|
+
error: {
|
|
227
|
+
name: error.name || "Error",
|
|
228
|
+
message: error.message || String(error),
|
|
229
|
+
stack: error.stack || undefined,
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
fastify.log.error(`[SDK] Failed to capture error: ${err}`);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
};
|
|
238
|
+
exports.fastifyTracing = fastifyTracing;
|
|
239
|
+
// Wrap with fastify-plugin to prevent encapsulation (hooks apply to all routes)
|
|
240
|
+
exports.default = (0, fastify_plugin_1.default)(exports.fastifyTracing, {
|
|
241
|
+
name: "@runtime-digital-twin/fastify-tracing",
|
|
242
|
+
fastify: "4.x",
|
|
243
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP Response Sentinels
|
|
3
|
+
*
|
|
4
|
+
* Automatic contract checking for outbound HTTP responses.
|
|
5
|
+
* Emits state.invariant events based on response characteristics.
|
|
6
|
+
*/
|
|
7
|
+
import type { ShapeDigest } from "@runtime-digital-twin/core";
|
|
8
|
+
export type SentinelsMode = "off" | "observe" | "enforce";
|
|
9
|
+
/**
|
|
10
|
+
* Get sentinels mode from environment
|
|
11
|
+
*/
|
|
12
|
+
export declare function getSentinelsMode(): SentinelsMode;
|
|
13
|
+
/**
|
|
14
|
+
* Check if sentinels are enabled
|
|
15
|
+
*/
|
|
16
|
+
export declare function areSentinelsEnabled(): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Check if sentinels are in enforce mode
|
|
19
|
+
*/
|
|
20
|
+
export declare function isEnforceMode(): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Custom error for sentinel violations in enforce mode
|
|
23
|
+
*/
|
|
24
|
+
export declare class SentinelViolationError extends Error {
|
|
25
|
+
readonly invariantName: string;
|
|
26
|
+
readonly severity: "warn" | "error";
|
|
27
|
+
constructor(invariantName: string, severity: "warn" | "error", message: string);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Check and emit sentinels for outbound HTTP response
|
|
31
|
+
*
|
|
32
|
+
* @param responseShapeDigest - Shape digest of response body
|
|
33
|
+
* @param statusCode - HTTP status code
|
|
34
|
+
* @param responseBody - Response body (for checking if empty)
|
|
35
|
+
* @param spanId - Span ID to attach invariant to (should be requestSpanId)
|
|
36
|
+
* @param parentSpanId - Parent span ID
|
|
37
|
+
*/
|
|
38
|
+
export declare function checkResponseSentinels(responseShapeDigest: ShapeDigest | null | undefined, statusCode: number, responseBody: string | null | undefined, spanId: string, parentSpanId: string | null): Promise<void>;
|
|
39
|
+
//# sourceMappingURL=http-sentinels.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"http-sentinels.d.ts","sourceRoot":"","sources":["../src/http-sentinels.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AAE9D,MAAM,MAAM,aAAa,GAAG,KAAK,GAAG,SAAS,GAAG,SAAS,CAAC;AAE1D;;GAEG;AACH,wBAAgB,gBAAgB,IAAI,aAAa,CAMhD;AAED;;GAEG;AACH,wBAAgB,mBAAmB,IAAI,OAAO,CAE7C;AAED;;GAEG;AACH,wBAAgB,aAAa,IAAI,OAAO,CAEvC;AAED;;GAEG;AACH,qBAAa,sBAAuB,SAAQ,KAAK;aAE7B,aAAa,EAAE,MAAM;aACrB,QAAQ,EAAE,MAAM,GAAG,OAAO;gBAD1B,aAAa,EAAE,MAAM,EACrB,QAAQ,EAAE,MAAM,GAAG,OAAO,EAC1C,OAAO,EAAE,MAAM;CAKlB;AAED;;;;;;;;GAQG;AACH,wBAAsB,sBAAsB,CAC1C,mBAAmB,EAAE,WAAW,GAAG,IAAI,GAAG,SAAS,EACnD,UAAU,EAAE,MAAM,EAClB,YAAY,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACvC,MAAM,EAAE,MAAM,EACd,YAAY,EAAE,MAAM,GAAG,IAAI,GAC1B,OAAO,CAAC,IAAI,CAAC,CAoJf"}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* HTTP Response Sentinels
|
|
4
|
+
*
|
|
5
|
+
* Automatic contract checking for outbound HTTP responses.
|
|
6
|
+
* Emits state.invariant events based on response characteristics.
|
|
7
|
+
*/
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.SentinelViolationError = void 0;
|
|
10
|
+
exports.getSentinelsMode = getSentinelsMode;
|
|
11
|
+
exports.areSentinelsEnabled = areSentinelsEnabled;
|
|
12
|
+
exports.isEnforceMode = isEnforceMode;
|
|
13
|
+
exports.checkResponseSentinels = checkResponseSentinels;
|
|
14
|
+
const invariants_1 = require("./invariants");
|
|
15
|
+
/**
|
|
16
|
+
* Get sentinels mode from environment
|
|
17
|
+
*/
|
|
18
|
+
function getSentinelsMode() {
|
|
19
|
+
const mode = process.env.WRAITH_SENTINELS_MODE?.toLowerCase();
|
|
20
|
+
if (mode === "off" || mode === "observe" || mode === "enforce") {
|
|
21
|
+
return mode;
|
|
22
|
+
}
|
|
23
|
+
return "observe"; // default
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Check if sentinels are enabled
|
|
27
|
+
*/
|
|
28
|
+
function areSentinelsEnabled() {
|
|
29
|
+
return getSentinelsMode() !== "off";
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Check if sentinels are in enforce mode
|
|
33
|
+
*/
|
|
34
|
+
function isEnforceMode() {
|
|
35
|
+
return getSentinelsMode() === "enforce";
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Custom error for sentinel violations in enforce mode
|
|
39
|
+
*/
|
|
40
|
+
class SentinelViolationError extends Error {
|
|
41
|
+
invariantName;
|
|
42
|
+
severity;
|
|
43
|
+
constructor(invariantName, severity, message) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.invariantName = invariantName;
|
|
46
|
+
this.severity = severity;
|
|
47
|
+
this.name = "SentinelViolationError";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
exports.SentinelViolationError = SentinelViolationError;
|
|
51
|
+
/**
|
|
52
|
+
* Check and emit sentinels for outbound HTTP response
|
|
53
|
+
*
|
|
54
|
+
* @param responseShapeDigest - Shape digest of response body
|
|
55
|
+
* @param statusCode - HTTP status code
|
|
56
|
+
* @param responseBody - Response body (for checking if empty)
|
|
57
|
+
* @param spanId - Span ID to attach invariant to (should be requestSpanId)
|
|
58
|
+
* @param parentSpanId - Parent span ID
|
|
59
|
+
*/
|
|
60
|
+
async function checkResponseSentinels(responseShapeDigest, statusCode, responseBody, spanId, parentSpanId) {
|
|
61
|
+
if (process.env.DEBUG_HTTP_SENTINELS) {
|
|
62
|
+
console.log('[SENTINELS] checkResponseSentinels called:', {
|
|
63
|
+
hasShapeDigest: !!responseShapeDigest,
|
|
64
|
+
statusCode,
|
|
65
|
+
hasBody: !!responseBody,
|
|
66
|
+
spanId,
|
|
67
|
+
parentSpanId,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
if (!areSentinelsEnabled()) {
|
|
71
|
+
if (process.env.DEBUG_HTTP_SENTINELS) {
|
|
72
|
+
console.log('[SENTINELS] Sentinels disabled');
|
|
73
|
+
}
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const enforceMode = isEnforceMode();
|
|
77
|
+
const errors = [];
|
|
78
|
+
if (process.env.DEBUG_HTTP_SENTINELS) {
|
|
79
|
+
console.log('[SENTINELS] Mode:', enforceMode ? 'enforce' : 'observe');
|
|
80
|
+
}
|
|
81
|
+
// Sentinel 1: Forbidden nulls in JSON response
|
|
82
|
+
if (responseShapeDigest &&
|
|
83
|
+
responseShapeDigest.kind === "json" &&
|
|
84
|
+
responseShapeDigest.nullPaths &&
|
|
85
|
+
responseShapeDigest.nullPaths.length > 0) {
|
|
86
|
+
const topNullPaths = responseShapeDigest.nullPaths.slice(0, 3);
|
|
87
|
+
if (process.env.DEBUG_HTTP_SENTINELS) {
|
|
88
|
+
console.log('[SENTINELS] Emitting forbidden_null invariant:', {
|
|
89
|
+
nullPaths: topNullPaths,
|
|
90
|
+
spanId,
|
|
91
|
+
parentSpanId,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
await (0, invariants_1.emitInvariant)({
|
|
96
|
+
name: "http.response.forbidden_null",
|
|
97
|
+
passed: false,
|
|
98
|
+
severity: "warn",
|
|
99
|
+
details: {
|
|
100
|
+
paths: topNullPaths,
|
|
101
|
+
expected: "no null values in response",
|
|
102
|
+
observed: `null values found at: ${topNullPaths.join(", ")}`,
|
|
103
|
+
},
|
|
104
|
+
}, spanId, // Use the provided spanId (requestSpanId)
|
|
105
|
+
parentSpanId // Use the provided parentSpanId
|
|
106
|
+
);
|
|
107
|
+
if (process.env.DEBUG_HTTP_SENTINELS) {
|
|
108
|
+
console.log('[SENTINELS] Invariant emitted successfully');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
if (process.env.DEBUG_HTTP_SENTINELS) {
|
|
113
|
+
console.error('[SENTINELS] Failed to emit invariant:', error);
|
|
114
|
+
}
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
if (enforceMode) {
|
|
118
|
+
errors.push(new SentinelViolationError("http.response.forbidden_null", "warn", `Response contains null values at: ${topNullPaths.join(", ")}`));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Sentinel 2: Empty object response (status 200 but object with 0 keys)
|
|
122
|
+
if (statusCode === 200 &&
|
|
123
|
+
responseShapeDigest &&
|
|
124
|
+
responseShapeDigest.kind === "json" &&
|
|
125
|
+
responseShapeDigest.topLevelType === "object" &&
|
|
126
|
+
(!responseShapeDigest.keys || responseShapeDigest.keys.length === 0)) {
|
|
127
|
+
await (0, invariants_1.emitInvariant)({
|
|
128
|
+
name: "http.response.empty_object",
|
|
129
|
+
passed: false,
|
|
130
|
+
severity: "warn",
|
|
131
|
+
details: {
|
|
132
|
+
expected: "non-empty object or different response type",
|
|
133
|
+
observed: "empty object {}",
|
|
134
|
+
},
|
|
135
|
+
}, spanId, parentSpanId);
|
|
136
|
+
}
|
|
137
|
+
// Sentinel 3: Status 204 with non-empty body
|
|
138
|
+
if (statusCode === 204 && responseBody && responseBody.trim().length > 0) {
|
|
139
|
+
await (0, invariants_1.emitInvariant)({
|
|
140
|
+
name: "http.response.204_with_body",
|
|
141
|
+
passed: false,
|
|
142
|
+
severity: "warn",
|
|
143
|
+
details: {
|
|
144
|
+
expected: "no body for 204 No Content",
|
|
145
|
+
observed: `body has ${responseBody.length} bytes`,
|
|
146
|
+
},
|
|
147
|
+
}, spanId, parentSpanId);
|
|
148
|
+
}
|
|
149
|
+
// Sentinel 4: 5xx status codes
|
|
150
|
+
if (statusCode >= 500) {
|
|
151
|
+
await (0, invariants_1.emitInvariant)({
|
|
152
|
+
name: "http.response.5xx",
|
|
153
|
+
passed: false,
|
|
154
|
+
severity: "error",
|
|
155
|
+
details: {
|
|
156
|
+
expected: "status code < 500",
|
|
157
|
+
observed: `status code ${statusCode}`,
|
|
158
|
+
},
|
|
159
|
+
}, spanId, parentSpanId);
|
|
160
|
+
if (enforceMode) {
|
|
161
|
+
errors.push(new SentinelViolationError("http.response.5xx", "error", `Outbound HTTP call returned 5xx status: ${statusCode}`));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// In enforce mode, throw errors for critical violations
|
|
165
|
+
if (enforceMode && errors.length > 0) {
|
|
166
|
+
// Throw the first error (or combine if needed)
|
|
167
|
+
throw errors[0];
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Set the current trace context (called by Fastify plugin)
|
|
3
|
+
* Note: This uses enterWith which sets the context for the current async execution
|
|
4
|
+
*/
|
|
5
|
+
export declare function setTraceContext(bundle: {
|
|
6
|
+
traceId: string;
|
|
7
|
+
writeEvent: (event: any) => Promise<void>;
|
|
8
|
+
writeBlob: (content: string | object) => Promise<string>;
|
|
9
|
+
} | null, spanId: string | null): void;
|
|
10
|
+
/**
|
|
11
|
+
* Get the current trace context
|
|
12
|
+
*/
|
|
13
|
+
export declare function getTraceContext(): {
|
|
14
|
+
bundle: {
|
|
15
|
+
traceId: string;
|
|
16
|
+
writeEvent: (event: any) => Promise<void>;
|
|
17
|
+
writeBlob: (content: string | object) => Promise<string>;
|
|
18
|
+
} | null;
|
|
19
|
+
spanId: string | null;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Wrap fetch/undici to capture outbound HTTP calls
|
|
23
|
+
*/
|
|
24
|
+
export declare function wrapFetch(originalFetch: typeof fetch): typeof fetch;
|
|
25
|
+
//# sourceMappingURL=http-wrapper.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"http-wrapper.d.ts","sourceRoot":"","sources":["../src/http-wrapper.ts"],"names":[],"mappings":"AAwBA;;;GAGG;AACH,wBAAgB,eAAe,CAC7B,MAAM,EAAE;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,CAAC,KAAK,EAAE,GAAG,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAAC,SAAS,EAAE,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAA;CAAE,GAAG,IAAI,EACvI,MAAM,EAAE,MAAM,GAAG,IAAI,QAQtB;AAED;;GAEG;AACH,wBAAgB,eAAe,IAAI;IACjC,MAAM,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,CAAC,KAAK,EAAE,GAAG,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;QAAC,SAAS,EAAE,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAA;KAAE,GAAG,IAAI,CAAC;IACxI,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB,CAMA;AAED;;GAEG;AACH,wBAAgB,SAAS,CAAC,aAAa,EAAE,OAAO,KAAK,GAAG,OAAO,KAAK,CA2dnE"}
|