@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.
Files changed (56) hide show
  1. package/README.md +214 -0
  2. package/dist/constants.d.ts +11 -0
  3. package/dist/constants.d.ts.map +1 -0
  4. package/dist/constants.js +13 -0
  5. package/dist/db-wrapper.d.ts +258 -0
  6. package/dist/db-wrapper.d.ts.map +1 -0
  7. package/dist/db-wrapper.js +636 -0
  8. package/dist/event-envelope.d.ts +35 -0
  9. package/dist/event-envelope.d.ts.map +1 -0
  10. package/dist/event-envelope.js +101 -0
  11. package/dist/fastify-plugin.d.ts +29 -0
  12. package/dist/fastify-plugin.d.ts.map +1 -0
  13. package/dist/fastify-plugin.js +243 -0
  14. package/dist/http-sentinels.d.ts +39 -0
  15. package/dist/http-sentinels.d.ts.map +1 -0
  16. package/dist/http-sentinels.js +169 -0
  17. package/dist/http-wrapper.d.ts +25 -0
  18. package/dist/http-wrapper.d.ts.map +1 -0
  19. package/dist/http-wrapper.js +477 -0
  20. package/dist/index.d.ts +19 -0
  21. package/dist/index.d.ts.map +1 -0
  22. package/dist/index.js +93 -0
  23. package/dist/invariants.d.ts +58 -0
  24. package/dist/invariants.d.ts.map +1 -0
  25. package/dist/invariants.js +192 -0
  26. package/dist/multi-service-edge-builder.d.ts +80 -0
  27. package/dist/multi-service-edge-builder.d.ts.map +1 -0
  28. package/dist/multi-service-edge-builder.js +107 -0
  29. package/dist/outbound-matcher.d.ts +192 -0
  30. package/dist/outbound-matcher.d.ts.map +1 -0
  31. package/dist/outbound-matcher.js +457 -0
  32. package/dist/peer-service-resolver.d.ts +22 -0
  33. package/dist/peer-service-resolver.d.ts.map +1 -0
  34. package/dist/peer-service-resolver.js +85 -0
  35. package/dist/redaction.d.ts +111 -0
  36. package/dist/redaction.d.ts.map +1 -0
  37. package/dist/redaction.js +487 -0
  38. package/dist/replay-logger.d.ts +438 -0
  39. package/dist/replay-logger.d.ts.map +1 -0
  40. package/dist/replay-logger.js +434 -0
  41. package/dist/root-cause-analyzer.d.ts +45 -0
  42. package/dist/root-cause-analyzer.d.ts.map +1 -0
  43. package/dist/root-cause-analyzer.js +606 -0
  44. package/dist/shape-digest-utils.d.ts +45 -0
  45. package/dist/shape-digest-utils.d.ts.map +1 -0
  46. package/dist/shape-digest-utils.js +154 -0
  47. package/dist/trace-bundle-writer.d.ts +52 -0
  48. package/dist/trace-bundle-writer.d.ts.map +1 -0
  49. package/dist/trace-bundle-writer.js +267 -0
  50. package/dist/trace-loader.d.ts +69 -0
  51. package/dist/trace-loader.d.ts.map +1 -0
  52. package/dist/trace-loader.js +146 -0
  53. package/dist/trace-uploader.d.ts +25 -0
  54. package/dist/trace-uploader.d.ts.map +1 -0
  55. package/dist/trace-uploader.js +132 -0
  56. 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"}