@unrdf/observability 26.4.2

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.
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Tamper Detection - Verify receipt integrity and detect modifications
3
+ *
4
+ * Provides comprehensive verification:
5
+ * - Individual receipt hash verification
6
+ * - Chain link verification (previousHash matches)
7
+ * - Temporal ordering verification
8
+ * - Merkle proof verification
9
+ * - Complete chain verification
10
+ *
11
+ * @module @unrdf/observability/receipts/tamper-detection
12
+ */
13
+
14
+ import { blake3 } from 'hash-wasm';
15
+ import { VerificationResultSchema } from './receipt-schema.mjs';
16
+
17
+ /**
18
+ * TamperDetector - Verify receipt integrity
19
+ */
20
+ export class TamperDetector {
21
+ /**
22
+ * Verify a single receipt's hash integrity
23
+ *
24
+ * @param {Object} receipt - Receipt to verify
25
+ * @returns {Promise<Object>} Verification result
26
+ */
27
+ async verifyReceipt(receipt) {
28
+ const errors = [];
29
+ const checks = {};
30
+
31
+ try {
32
+ // Check required fields
33
+ const required = [
34
+ 'id',
35
+ 'hash',
36
+ 'timestamp_ns',
37
+ 'timestamp_iso',
38
+ 'operation',
39
+ 'payload',
40
+ 'previousHash',
41
+ ];
42
+ for (const field of required) {
43
+ if (!(field in receipt)) {
44
+ errors.push(`Missing required field: ${field}`);
45
+ }
46
+ }
47
+
48
+ if (errors.length > 0) {
49
+ return {
50
+ valid: false,
51
+ receiptId: receipt.id,
52
+ errors,
53
+ checks,
54
+ };
55
+ }
56
+
57
+ // Recompute hash
58
+ const canonicalContent = {
59
+ id: receipt.id,
60
+ timestamp_ns: receipt.timestamp_ns,
61
+ timestamp_iso: receipt.timestamp_iso,
62
+ operation: receipt.operation,
63
+ payload: receipt.payload,
64
+ previousHash: receipt.previousHash,
65
+ ...(receipt.actor && { actor: receipt.actor }),
66
+ ...(receipt.metadata && { metadata: receipt.metadata }),
67
+ };
68
+
69
+ const canonical = JSON.stringify(canonicalContent, Object.keys(canonicalContent).sort());
70
+ const computedHash = await blake3(canonical);
71
+
72
+ checks.hashIntegrity = computedHash === receipt.hash;
73
+ if (!checks.hashIntegrity) {
74
+ errors.push(`Hash mismatch: expected ${receipt.hash}, got ${computedHash}`);
75
+ }
76
+
77
+ return VerificationResultSchema.parse({
78
+ valid: errors.length === 0,
79
+ receiptId: receipt.id,
80
+ errors,
81
+ checks,
82
+ });
83
+ } catch (err) {
84
+ return {
85
+ valid: false,
86
+ receiptId: receipt.id,
87
+ errors: [`Verification exception: ${err.message}`],
88
+ checks,
89
+ };
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Verify chain link between two receipts
95
+ *
96
+ * @param {Object} current - Current receipt
97
+ * @param {Object} previous - Previous receipt
98
+ * @returns {Promise<Object>} Verification result
99
+ */
100
+ async verifyChainLink(current, previous) {
101
+ const errors = [];
102
+ const checks = {};
103
+
104
+ // Verify previousHash matches
105
+ checks.chainLink = current.previousHash === previous.hash;
106
+ if (!checks.chainLink) {
107
+ errors.push(
108
+ `Chain broken: current.previousHash (${current.previousHash}) !== previous.hash (${previous.hash})`
109
+ );
110
+ }
111
+
112
+ // Verify temporal ordering
113
+ const currentTime = BigInt(current.timestamp_ns);
114
+ const previousTime = BigInt(previous.timestamp_ns);
115
+ checks.temporalOrder = currentTime > previousTime;
116
+ if (!checks.temporalOrder) {
117
+ errors.push(`Temporal ordering violated: ${currentTime} <= ${previousTime}`);
118
+ }
119
+
120
+ return VerificationResultSchema.parse({
121
+ valid: errors.length === 0,
122
+ receiptId: current.id,
123
+ errors,
124
+ checks,
125
+ });
126
+ }
127
+
128
+ /**
129
+ * Verify entire receipt chain
130
+ *
131
+ * @param {Array<Object>} receipts - Array of receipts to verify
132
+ * @returns {Promise<Object>} Verification result
133
+ */
134
+ async verifyChain(receipts) {
135
+ const errors = [];
136
+ const checks = {};
137
+
138
+ if (receipts.length === 0) {
139
+ return {
140
+ valid: true,
141
+ errors: [],
142
+ checks: {},
143
+ };
144
+ }
145
+
146
+ // Verify first receipt (genesis)
147
+ const firstResult = await this.verifyReceipt(receipts[0]);
148
+ if (!firstResult.valid) {
149
+ errors.push(`Receipt 0 invalid: ${firstResult.errors.join(', ')}`);
150
+ return { valid: false, errors, checks };
151
+ }
152
+
153
+ if (receipts[0].previousHash !== null) {
154
+ errors.push('Genesis receipt must have null previousHash');
155
+ return { valid: false, errors, checks };
156
+ }
157
+
158
+ // Verify remaining receipts and chain links
159
+ for (let i = 1; i < receipts.length; i++) {
160
+ const receiptResult = await this.verifyReceipt(receipts[i]);
161
+ if (!receiptResult.valid) {
162
+ errors.push(`Receipt ${i} invalid: ${receiptResult.errors.join(', ')}`);
163
+ }
164
+
165
+ const linkResult = await this.verifyChainLink(receipts[i], receipts[i - 1]);
166
+ if (!linkResult.valid) {
167
+ errors.push(`Chain link ${i - 1} -> ${i} invalid: ${linkResult.errors.join(', ')}`);
168
+ }
169
+ }
170
+
171
+ return VerificationResultSchema.parse({
172
+ valid: errors.length === 0,
173
+ errors,
174
+ checks,
175
+ });
176
+ }
177
+
178
+ /**
179
+ * Detect tampering by comparing original and suspect receipts
180
+ *
181
+ * @param {Object} original - Original receipt
182
+ * @param {Object} suspect - Suspect receipt
183
+ * @returns {Promise<Object>} Tampering analysis
184
+ */
185
+ async detectTampering(original, suspect) {
186
+ const analysis = {
187
+ tampered: false,
188
+ changes: [],
189
+ };
190
+
191
+ // Compare all fields
192
+ const fields = [
193
+ 'id',
194
+ 'hash',
195
+ 'timestamp_ns',
196
+ 'timestamp_iso',
197
+ 'operation',
198
+ 'payload',
199
+ 'previousHash',
200
+ 'actor',
201
+ ];
202
+ for (const field of fields) {
203
+ const origValue = JSON.stringify(original[field]);
204
+ const suspValue = JSON.stringify(suspect[field]);
205
+ if (origValue !== suspValue) {
206
+ analysis.tampered = true;
207
+ analysis.changes.push({
208
+ field,
209
+ original: original[field],
210
+ suspect: suspect[field],
211
+ });
212
+ }
213
+ }
214
+
215
+ return analysis;
216
+ }
217
+ }
218
+
219
+ export default TamperDetector;
@@ -0,0 +1,302 @@
1
+ /**
2
+ * @file Advanced Metrics Tests
3
+ * @module observability/test/advanced-metrics
4
+ */
5
+
6
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
7
+ import { createAdvancedMetrics } from '../src/advanced-metrics.mjs';
8
+
9
+ describe('AdvancedMetrics', () => {
10
+ let metrics;
11
+
12
+ beforeEach(() => {
13
+ metrics = createAdvancedMetrics({
14
+ serviceName: 'test-service',
15
+ enabled: true,
16
+ samplingRate: 1.0, // 100% for testing
17
+ });
18
+ });
19
+
20
+ describe('Configuration', () => {
21
+ it('should create metrics with default config', () => {
22
+ const defaultMetrics = createAdvancedMetrics();
23
+ expect(defaultMetrics.config.serviceName).toBe('unrdf');
24
+ expect(defaultMetrics.config.enabled).toBe(true);
25
+ });
26
+
27
+ it('should accept custom configuration', () => {
28
+ const customMetrics = createAdvancedMetrics({
29
+ serviceName: 'custom-service',
30
+ samplingRate: 0.5,
31
+ });
32
+
33
+ expect(customMetrics.config.serviceName).toBe('custom-service');
34
+ expect(customMetrics.config.samplingRate).toBe(0.5);
35
+ });
36
+
37
+ it('should validate configuration with Zod schema', () => {
38
+ expect(() => {
39
+ createAdvancedMetrics({
40
+ samplingRate: 1.5, // Invalid: > 1
41
+ });
42
+ }).toThrow();
43
+ });
44
+ });
45
+
46
+ describe('Business Metrics', () => {
47
+ it('should record successful operation', () => {
48
+ const spy = vi.spyOn(metrics.businessMetrics.operations, 'add');
49
+
50
+ metrics.recordOperation({
51
+ operation: 'sparql-query',
52
+ success: true,
53
+ duration: 50,
54
+ });
55
+
56
+ expect(spy).toHaveBeenCalledWith(1, {
57
+ operation: 'sparql-query',
58
+ result: 'success',
59
+ });
60
+ });
61
+
62
+ it('should record failed operation with error type', () => {
63
+ const spy = vi.spyOn(metrics.businessMetrics.failuresByType, 'add');
64
+
65
+ metrics.recordOperation({
66
+ operation: 'triple-insert',
67
+ success: false,
68
+ duration: 100,
69
+ errorType: 'ValidationError',
70
+ });
71
+
72
+ expect(spy).toHaveBeenCalledWith(1, {
73
+ operation: 'triple-insert',
74
+ error_type: 'ValidationError',
75
+ });
76
+ });
77
+
78
+ it('should record SLA violations', () => {
79
+ const spy = vi.spyOn(metrics.businessMetrics.slaViolations, 'add');
80
+
81
+ metrics.recordOperation({
82
+ operation: 'query-execution',
83
+ success: true,
84
+ duration: 150,
85
+ slaThreshold: 100,
86
+ });
87
+
88
+ expect(spy).toHaveBeenCalledWith(1, {
89
+ operation: 'query-execution',
90
+ threshold: '100',
91
+ });
92
+ });
93
+
94
+ it('should not record SLA violation when under threshold', () => {
95
+ const spy = vi.spyOn(metrics.businessMetrics.slaViolations, 'add');
96
+
97
+ metrics.recordOperation({
98
+ operation: 'query-execution',
99
+ success: true,
100
+ duration: 50,
101
+ slaThreshold: 100,
102
+ });
103
+
104
+ expect(spy).not.toHaveBeenCalled();
105
+ });
106
+
107
+ it('should record success rate', () => {
108
+ const spy = vi.spyOn(metrics.businessMetrics.successRate, 'add');
109
+
110
+ metrics.recordSuccessRate('query-execution', 0.95);
111
+
112
+ expect(spy).toHaveBeenCalledWith(0.95, { operation: 'query-execution' });
113
+ });
114
+ });
115
+
116
+ describe('Latency Metrics', () => {
117
+ it('should record operation latency in histogram', () => {
118
+ const spy = vi.spyOn(metrics.latencyMetrics.histogram, 'record');
119
+
120
+ metrics.recordOperation({
121
+ operation: 'parse-turtle',
122
+ success: true,
123
+ duration: 75,
124
+ });
125
+
126
+ expect(spy).toHaveBeenCalledWith(75, { operation: 'parse-turtle' });
127
+ });
128
+
129
+ it('should record latency percentiles', () => {
130
+ const p50Spy = vi.spyOn(metrics.latencyMetrics.p50, 'add');
131
+ const p95Spy = vi.spyOn(metrics.latencyMetrics.p95, 'add');
132
+ const p99Spy = vi.spyOn(metrics.latencyMetrics.p99, 'add');
133
+
134
+ metrics.recordLatencyPercentiles('query', {
135
+ p50: 25,
136
+ p90: 50,
137
+ p95: 100,
138
+ p99: 500,
139
+ max: 1000,
140
+ });
141
+
142
+ expect(p50Spy).toHaveBeenCalledWith(25, { operation: 'query' });
143
+ expect(p95Spy).toHaveBeenCalledWith(100, { operation: 'query' });
144
+ expect(p99Spy).toHaveBeenCalledWith(500, { operation: 'query' });
145
+ });
146
+ });
147
+
148
+ describe('Throughput Metrics', () => {
149
+ it('should record throughput', () => {
150
+ const spy = vi.spyOn(metrics.throughputMetrics.opsPerSecond, 'add');
151
+
152
+ metrics.recordThroughput('insert', 125);
153
+
154
+ expect(spy).toHaveBeenCalledWith(125, { operation: 'insert' });
155
+ });
156
+
157
+ it('should update throughput automatically', async () => {
158
+ // Record operations
159
+ for (let i = 0; i < 10; i++) {
160
+ metrics.recordOperation({
161
+ operation: 'test-op',
162
+ success: true,
163
+ duration: 10,
164
+ });
165
+ }
166
+
167
+ // Wait for throughput calculation (>1 second)
168
+ await new Promise(resolve => setTimeout(resolve, 1100));
169
+
170
+ // Throughput should be calculated
171
+ expect(metrics.operationCounts.size).toBe(0); // Cleared after calculation
172
+ });
173
+ });
174
+
175
+ describe('Resource Metrics', () => {
176
+ it('should record memory utilization', () => {
177
+ const spy = vi.spyOn(metrics.resourceMetrics.heapUsed, 'add');
178
+
179
+ metrics.recordResourceUtilization();
180
+
181
+ expect(spy).toHaveBeenCalled();
182
+ const callArgs = spy.mock.calls[0][0];
183
+ expect(callArgs).toBeGreaterThan(0);
184
+ });
185
+
186
+ it('should record event loop lag', () => {
187
+ const lagSpy = vi.spyOn(metrics.resourceMetrics.eventLoopLag, 'record');
188
+ const cpuSpy = vi.spyOn(metrics.resourceMetrics.cpuLoad, 'add');
189
+
190
+ metrics.recordEventLoopLag(15);
191
+
192
+ expect(lagSpy).toHaveBeenCalledWith(15);
193
+ expect(cpuSpy).toHaveBeenCalled();
194
+ });
195
+
196
+ it('should estimate CPU load from event loop lag', () => {
197
+ const spy = vi.spyOn(metrics.resourceMetrics.cpuLoad, 'add');
198
+
199
+ // High lag = high CPU estimate
200
+ metrics.recordEventLoopLag(150);
201
+
202
+ const cpuLoad = spy.mock.calls[0][0];
203
+ expect(cpuLoad).toBeGreaterThan(0);
204
+ expect(cpuLoad).toBeLessThanOrEqual(1);
205
+ });
206
+ });
207
+
208
+ describe('Sampling', () => {
209
+ it('should sample at configured rate', () => {
210
+ const sampledMetrics = createAdvancedMetrics({
211
+ enabled: true,
212
+ samplingRate: 0.5, // 50%
213
+ });
214
+
215
+ const spy = vi.spyOn(sampledMetrics.businessMetrics.operations, 'add');
216
+
217
+ // Record 100 operations
218
+ for (let i = 0; i < 100; i++) {
219
+ sampledMetrics.recordOperation({
220
+ operation: 'test',
221
+ success: true,
222
+ duration: 10,
223
+ });
224
+ }
225
+
226
+ // Should sample ~50 (with some variance)
227
+ const callCount = spy.mock.calls.length;
228
+ expect(callCount).toBeGreaterThan(30);
229
+ expect(callCount).toBeLessThan(70);
230
+ });
231
+
232
+ it('should not record when disabled', () => {
233
+ const disabledMetrics = createAdvancedMetrics({
234
+ enabled: false,
235
+ });
236
+
237
+ const spy = vi.spyOn(disabledMetrics.businessMetrics?.operations || {}, 'add', () => {});
238
+
239
+ disabledMetrics.recordOperation({
240
+ operation: 'test',
241
+ success: true,
242
+ duration: 10,
243
+ });
244
+
245
+ expect(spy).not.toHaveBeenCalled();
246
+ });
247
+ });
248
+
249
+ describe('Performance', () => {
250
+ it('should record operation in <0.1ms', () => {
251
+ const start = performance.now();
252
+
253
+ for (let i = 0; i < 1000; i++) {
254
+ metrics.recordOperation({
255
+ operation: 'perf-test',
256
+ success: true,
257
+ duration: 10,
258
+ });
259
+ }
260
+
261
+ const elapsed = performance.now() - start;
262
+ const avgTime = elapsed / 1000;
263
+
264
+ expect(avgTime).toBeLessThan(0.1); // <0.1ms per operation
265
+ });
266
+
267
+ it('should have minimal memory overhead', () => {
268
+ const before = process.memoryUsage().heapUsed;
269
+
270
+ // Record 10,000 operations
271
+ for (let i = 0; i < 10000; i++) {
272
+ metrics.recordOperation({
273
+ operation: 'memory-test',
274
+ success: true,
275
+ duration: 10,
276
+ });
277
+ }
278
+
279
+ const after = process.memoryUsage().heapUsed;
280
+ const overhead = (after - before) / 1024 / 1024; // MB
281
+
282
+ expect(overhead).toBeLessThan(10); // <10MB overhead
283
+ });
284
+ });
285
+
286
+ describe('Summary', () => {
287
+ it('should provide metrics summary', () => {
288
+ metrics.recordOperation({
289
+ operation: 'test-op',
290
+ success: true,
291
+ duration: 10,
292
+ });
293
+
294
+ const summary = metrics.getSummary();
295
+
296
+ expect(summary).toHaveProperty('enabled');
297
+ expect(summary).toHaveProperty('samplingRate');
298
+ expect(summary).toHaveProperty('operationTypes');
299
+ expect(summary.enabled).toBe(true);
300
+ });
301
+ });
302
+ });