@optimizely-opal/opal-tool-ocp-sdk 1.0.0-beta.8 → 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 +60 -197
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/logging/ToolLogger.d.ts +11 -3
- package/dist/logging/ToolLogger.d.ts.map +1 -1
- package/dist/logging/ToolLogger.js +114 -13
- package/dist/logging/ToolLogger.js.map +1 -1
- package/dist/logging/ToolLogger.test.js +177 -71
- package/dist/logging/ToolLogger.test.js.map +1 -1
- package/dist/types/Models.d.ts +3 -1
- package/dist/types/Models.d.ts.map +1 -1
- package/dist/types/Models.js +5 -1
- package/dist/types/Models.js.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +1 -1
- package/src/logging/ToolLogger.test.ts +225 -74
- package/src/logging/ToolLogger.ts +129 -15
- package/src/types/Models.ts +3 -1
|
@@ -88,6 +88,30 @@ describe('ToolLogger', () => {
|
|
|
88
88
|
return response;
|
|
89
89
|
};
|
|
90
90
|
|
|
91
|
+
const createMockResponseWithBody = (
|
|
92
|
+
status: number,
|
|
93
|
+
bodyData: Uint8Array | undefined,
|
|
94
|
+
contentType: string
|
|
95
|
+
): App.Response => {
|
|
96
|
+
const mockHeaders = {
|
|
97
|
+
get: jest.fn().mockReturnValue(contentType)
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const response = {
|
|
101
|
+
status,
|
|
102
|
+
headers: mockHeaders
|
|
103
|
+
} as any;
|
|
104
|
+
|
|
105
|
+
Object.defineProperty(response, 'bodyAsU8Array', {
|
|
106
|
+
get() {
|
|
107
|
+
return bodyData;
|
|
108
|
+
},
|
|
109
|
+
enumerable: true
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return response;
|
|
113
|
+
};
|
|
114
|
+
|
|
91
115
|
describe('logRequest', () => {
|
|
92
116
|
it('should log request with parameters', () => {
|
|
93
117
|
const req = createMockRequest();
|
|
@@ -240,7 +264,7 @@ describe('ToolLogger', () => {
|
|
|
240
264
|
path: '/test-tool',
|
|
241
265
|
method: 'POST',
|
|
242
266
|
parameters: {
|
|
243
|
-
description: `${'a'.repeat(
|
|
267
|
+
description: `${'a'.repeat(118)}...[22 truncated]...${'a'.repeat(10)}`,
|
|
244
268
|
short_field: 'normal'
|
|
245
269
|
}
|
|
246
270
|
});
|
|
@@ -265,8 +289,8 @@ describe('ToolLogger', () => {
|
|
|
265
289
|
method: 'POST',
|
|
266
290
|
parameters: {
|
|
267
291
|
items: [
|
|
268
|
-
...largeArray.slice(0,
|
|
269
|
-
'... (
|
|
292
|
+
...largeArray.slice(0, 2),
|
|
293
|
+
'... (13 more items truncated)'
|
|
270
294
|
],
|
|
271
295
|
small_array: ['a', 'b']
|
|
272
296
|
}
|
|
@@ -420,6 +444,21 @@ describe('ToolLogger', () => {
|
|
|
420
444
|
// Should not throw error or cause infinite recursion
|
|
421
445
|
expect(() => ToolLogger.logRequest(req)).not.toThrow();
|
|
422
446
|
expect(mockLogger.info).toHaveBeenCalled();
|
|
447
|
+
|
|
448
|
+
// Verify that deeply nested parts are replaced with placeholder
|
|
449
|
+
const logCall = mockLogger.info.mock.calls[0];
|
|
450
|
+
const loggedData = JSON.parse(logCall[1]);
|
|
451
|
+
|
|
452
|
+
// Navigate to a deeply nested level that should be truncated
|
|
453
|
+
// At maxDepth=5, objects beyond depth 5 should be replaced
|
|
454
|
+
const nested1 = loggedData.parameters.deep.nested;
|
|
455
|
+
expect(nested1).toBeDefined(); // depth 2
|
|
456
|
+
const nested2 = nested1.nested;
|
|
457
|
+
expect(nested2).toBeDefined(); // depth 3
|
|
458
|
+
const nested3 = nested2.nested;
|
|
459
|
+
expect(nested3).toBeDefined(); // depth 4
|
|
460
|
+
const nested4 = nested3.nested;
|
|
461
|
+
expect(nested4).toBe('[MAX_DEPTH_EXCEEDED]'); // depth 5, should be truncated
|
|
423
462
|
});
|
|
424
463
|
|
|
425
464
|
it('should replace deeply nested objects with MAX_DEPTH_EXCEEDED placeholder', () => {
|
|
@@ -465,6 +504,7 @@ describe('ToolLogger', () => {
|
|
|
465
504
|
level1: {
|
|
466
505
|
items: [
|
|
467
506
|
{
|
|
507
|
+
shallow: 'data',
|
|
468
508
|
level2: {
|
|
469
509
|
level3: {
|
|
470
510
|
level4: {
|
|
@@ -504,9 +544,12 @@ describe('ToolLogger', () => {
|
|
|
504
544
|
const loggedData = JSON.parse(logCall[1]);
|
|
505
545
|
|
|
506
546
|
const items = loggedData.parameters.level1.items;
|
|
547
|
+
expect(items.length).toBe(3);
|
|
507
548
|
|
|
508
549
|
// First item: deeply nested object with inner parts replaced by placeholder
|
|
550
|
+
// shallow object should be processed normally
|
|
509
551
|
expect(items[0]).toEqual({
|
|
552
|
+
shallow: 'data',
|
|
510
553
|
level2: {
|
|
511
554
|
level3: {
|
|
512
555
|
level4: '[MAX_DEPTH_EXCEEDED]'
|
|
@@ -516,18 +559,6 @@ describe('ToolLogger', () => {
|
|
|
516
559
|
|
|
517
560
|
// Second item: simple string should remain unchanged
|
|
518
561
|
expect(items[1]).toBe('simple-item');
|
|
519
|
-
|
|
520
|
-
// Third item: shallow object should be processed normally
|
|
521
|
-
expect(items[2]).toEqual({ shallow: 'data' });
|
|
522
|
-
|
|
523
|
-
// Fourth item: another deeply nested object with inner parts replaced by placeholder
|
|
524
|
-
expect(items[3]).toEqual({
|
|
525
|
-
level2: {
|
|
526
|
-
level3: {
|
|
527
|
-
level4: '[MAX_DEPTH_EXCEEDED]'
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
});
|
|
531
562
|
});
|
|
532
563
|
});
|
|
533
564
|
|
|
@@ -545,7 +576,8 @@ describe('ToolLogger', () => {
|
|
|
545
576
|
status: 200,
|
|
546
577
|
contentType: 'application/json',
|
|
547
578
|
contentLength: 34, // JSON.stringify({ result: 'success', data: 'test' }).length
|
|
548
|
-
success: true
|
|
579
|
+
success: true,
|
|
580
|
+
responseBody: { result: 'success', data: 'test' }
|
|
549
581
|
};
|
|
550
582
|
|
|
551
583
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
|
@@ -567,14 +599,14 @@ describe('ToolLogger', () => {
|
|
|
567
599
|
status: 400,
|
|
568
600
|
contentType: 'application/json',
|
|
569
601
|
contentLength: 23,
|
|
570
|
-
success: false
|
|
602
|
+
success: false,
|
|
603
|
+
responseBody: { error: 'Bad request' }
|
|
571
604
|
});
|
|
572
605
|
});
|
|
573
606
|
|
|
574
|
-
it('should handle response without
|
|
607
|
+
it('should handle response without body data', () => {
|
|
575
608
|
const req = createMockRequest();
|
|
576
|
-
const response =
|
|
577
|
-
response.bodyJSON = undefined;
|
|
609
|
+
const response = createMockResponseWithBody(204, undefined, 'application/json');
|
|
578
610
|
|
|
579
611
|
ToolLogger.logResponse(req, response);
|
|
580
612
|
|
|
@@ -600,11 +632,12 @@ describe('ToolLogger', () => {
|
|
|
600
632
|
status: 200,
|
|
601
633
|
contentType: 'application/json',
|
|
602
634
|
contentLength: 15,
|
|
603
|
-
success: true
|
|
635
|
+
success: true,
|
|
636
|
+
responseBody: { data: 'test' }
|
|
604
637
|
});
|
|
605
638
|
});
|
|
606
639
|
|
|
607
|
-
it('should handle unknown content type', () => {
|
|
640
|
+
it('should handle unknown content type - response body not logged', () => {
|
|
608
641
|
const req = createMockRequest();
|
|
609
642
|
const response = createMockResponse(200, { data: 'test' });
|
|
610
643
|
response.headers.get = jest.fn().mockReturnValue(null);
|
|
@@ -623,21 +656,35 @@ describe('ToolLogger', () => {
|
|
|
623
656
|
|
|
624
657
|
it('should handle content length calculation error', () => {
|
|
625
658
|
const req = createMockRequest();
|
|
626
|
-
const circularObj: any = { name: 'test' };
|
|
627
|
-
circularObj.self = circularObj; // Create circular reference
|
|
628
659
|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
660
|
+
// Simulate a response that will cause errors when trying to calculate content length
|
|
661
|
+
// by providing a Uint8Array but the underlying data causes issues
|
|
662
|
+
const mockHeaders = {
|
|
663
|
+
get: jest.fn().mockReturnValue('application/json')
|
|
664
|
+
};
|
|
632
665
|
|
|
633
|
-
|
|
634
|
-
event: 'opal_tool_response',
|
|
635
|
-
path: '/test-tool',
|
|
666
|
+
const response = {
|
|
636
667
|
status: 200,
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
668
|
+
headers: mockHeaders
|
|
669
|
+
} as any;
|
|
670
|
+
|
|
671
|
+
// Create a getter that throws when accessed (simulating serialization error)
|
|
672
|
+
Object.defineProperty(response, 'bodyAsU8Array', {
|
|
673
|
+
get() {
|
|
674
|
+
throw new Error('Circular structure');
|
|
675
|
+
},
|
|
676
|
+
enumerable: true
|
|
640
677
|
});
|
|
678
|
+
|
|
679
|
+
ToolLogger.logResponse(req, response);
|
|
680
|
+
|
|
681
|
+
// The error causes both contentLength and responseBody to fail gracefully
|
|
682
|
+
const logCall = mockLogger.info.mock.calls[0];
|
|
683
|
+
const loggedData = JSON.parse(logCall[1]);
|
|
684
|
+
|
|
685
|
+
expect(loggedData.event).toBe('opal_tool_response');
|
|
686
|
+
expect(loggedData.contentLength).toBe('unknown');
|
|
687
|
+
expect(loggedData.responseBody).toBeUndefined();
|
|
641
688
|
});
|
|
642
689
|
|
|
643
690
|
it('should correctly identify success status codes', () => {
|
|
@@ -659,14 +706,16 @@ describe('ToolLogger', () => {
|
|
|
659
706
|
const response = createMockResponse(status);
|
|
660
707
|
ToolLogger.logResponse(req, response);
|
|
661
708
|
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
709
|
+
const logCall = mockLogger.info.mock.calls[0];
|
|
710
|
+
const loggedData = JSON.parse(logCall[1]);
|
|
711
|
+
|
|
712
|
+
expect(loggedData.event).toBe('opal_tool_response');
|
|
713
|
+
expect(loggedData.path).toBe('/test-tool');
|
|
714
|
+
expect(loggedData.status).toBe(status);
|
|
715
|
+
expect(loggedData.contentType).toBe('application/json');
|
|
716
|
+
expect(loggedData.contentLength).toBe(2);
|
|
717
|
+
expect(loggedData.success).toBe(expected);
|
|
718
|
+
expect(loggedData.responseBody).toEqual({});
|
|
670
719
|
});
|
|
671
720
|
});
|
|
672
721
|
|
|
@@ -674,29 +723,148 @@ describe('ToolLogger', () => {
|
|
|
674
723
|
const req = createMockRequest();
|
|
675
724
|
|
|
676
725
|
const testCases = [
|
|
677
|
-
'application/json',
|
|
678
|
-
'text/plain',
|
|
679
|
-
'
|
|
680
|
-
'text/html'
|
|
726
|
+
{ contentType: 'application/json', expectedBody: { data: 'test' } },
|
|
727
|
+
{ contentType: 'text/plain', expectedBody: '{"data":"test"}' },
|
|
728
|
+
{ contentType: 'text/html', expectedBody: '{"data":"test"}' }
|
|
681
729
|
];
|
|
682
730
|
|
|
683
|
-
testCases.forEach((contentType) => {
|
|
731
|
+
testCases.forEach(({ contentType, expectedBody }) => {
|
|
684
732
|
mockLogger.info.mockClear();
|
|
685
733
|
const response = createMockResponse(200, { data: 'test' });
|
|
686
734
|
response.headers.get = jest.fn().mockReturnValue(contentType);
|
|
687
735
|
|
|
688
736
|
ToolLogger.logResponse(req, response);
|
|
689
737
|
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
738
|
+
const logCall = mockLogger.info.mock.calls[0];
|
|
739
|
+
const loggedData = JSON.parse(logCall[1]);
|
|
740
|
+
|
|
741
|
+
expect(loggedData.event).toBe('opal_tool_response');
|
|
742
|
+
expect(loggedData.path).toBe('/test-tool');
|
|
743
|
+
expect(loggedData.status).toBe(200);
|
|
744
|
+
expect(loggedData.contentType).toBe(contentType);
|
|
745
|
+
expect(loggedData.contentLength).toBe(15);
|
|
746
|
+
expect(loggedData.success).toBe(true);
|
|
747
|
+
expect(loggedData.responseBody).toEqual(expectedBody);
|
|
748
|
+
});
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
it('should log short successful response body', () => {
|
|
752
|
+
const req = createMockRequest();
|
|
753
|
+
const response = createMockResponse(200, { result: 'success', data: 'test' });
|
|
754
|
+
|
|
755
|
+
ToolLogger.logResponse(req, response);
|
|
756
|
+
|
|
757
|
+
const logCall = mockLogger.info.mock.calls[0];
|
|
758
|
+
const loggedData = JSON.parse(logCall[1]);
|
|
759
|
+
|
|
760
|
+
expect(loggedData.responseBody).toEqual({ result: 'success', data: 'test' });
|
|
761
|
+
expect(loggedData.success).toBe(true);
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
it('should truncate long successful response body to 256 chars', () => {
|
|
765
|
+
const req = createMockRequest();
|
|
766
|
+
const longData = 'a'.repeat(300);
|
|
767
|
+
const response = createMockResponse(200, { message: longData });
|
|
768
|
+
|
|
769
|
+
ToolLogger.logResponse(req, response);
|
|
770
|
+
|
|
771
|
+
const logCall = mockLogger.info.mock.calls[0];
|
|
772
|
+
const loggedData = JSON.parse(logCall[1]);
|
|
773
|
+
|
|
774
|
+
// The response body should be truncated when stringified
|
|
775
|
+
expect(loggedData.responseBody.message).toContain(' truncated]...');
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
it('truncates long properties of failed responses', () => {
|
|
779
|
+
const req = createMockRequest();
|
|
780
|
+
const longData = 'a'.repeat(150);
|
|
781
|
+
const response = createMockResponse(400, { error: 'Bad request', details: longData });
|
|
782
|
+
|
|
783
|
+
ToolLogger.logResponse(req, response);
|
|
784
|
+
|
|
785
|
+
const logCall = mockLogger.info.mock.calls[0];
|
|
786
|
+
const loggedData = JSON.parse(logCall[1]);
|
|
787
|
+
|
|
788
|
+
// Failed responses should include full body, not truncated
|
|
789
|
+
expect(loggedData.responseBody.error).toEqual('Bad request');
|
|
790
|
+
expect(loggedData.responseBody.details).toContain(' truncated]...');
|
|
791
|
+
expect(loggedData.success).toBe(false);
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
it('should redact sensitive data in response body', () => {
|
|
795
|
+
const req = createMockRequest();
|
|
796
|
+
const response = createMockResponse(200, {
|
|
797
|
+
user: 'john',
|
|
798
|
+
password: 'secret123',
|
|
799
|
+
api_key: 'key456',
|
|
800
|
+
data: 'public'
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
ToolLogger.logResponse(req, response);
|
|
804
|
+
|
|
805
|
+
const logCall = mockLogger.info.mock.calls[0];
|
|
806
|
+
const loggedData = JSON.parse(logCall[1]);
|
|
807
|
+
|
|
808
|
+
expect(loggedData.responseBody).toEqual({
|
|
809
|
+
user: 'john',
|
|
810
|
+
password: '[REDACTED]',
|
|
811
|
+
api_key: '[REDACTED]',
|
|
812
|
+
data: 'public'
|
|
698
813
|
});
|
|
699
814
|
});
|
|
815
|
+
|
|
816
|
+
it('should handle response with no body', () => {
|
|
817
|
+
const req = createMockRequest();
|
|
818
|
+
const response = createMockResponseWithBody(204, undefined, 'application/json');
|
|
819
|
+
|
|
820
|
+
ToolLogger.logResponse(req, response);
|
|
821
|
+
|
|
822
|
+
const logCall = mockLogger.info.mock.calls[0];
|
|
823
|
+
const loggedData = JSON.parse(logCall[1]);
|
|
824
|
+
|
|
825
|
+
expect(loggedData.responseBody).toBeUndefined();
|
|
826
|
+
expect(loggedData.success).toBe(true);
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
it('should handle plain text response body', () => {
|
|
830
|
+
const req = createMockRequest();
|
|
831
|
+
const plainText = 'This is a plain text response';
|
|
832
|
+
const response = createMockResponseWithBody(200, new Uint8Array(Buffer.from(plainText)), 'text/plain');
|
|
833
|
+
|
|
834
|
+
ToolLogger.logResponse(req, response);
|
|
835
|
+
|
|
836
|
+
const logCall = mockLogger.info.mock.calls[0];
|
|
837
|
+
const loggedData = JSON.parse(logCall[1]);
|
|
838
|
+
|
|
839
|
+
expect(loggedData.responseBody).toBe(plainText);
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
it('should truncate long plain text successful responses', () => {
|
|
843
|
+
const req = createMockRequest();
|
|
844
|
+
const longText = 'a'.repeat(300);
|
|
845
|
+
const response = createMockResponseWithBody(200, new Uint8Array(Buffer.from(longText)), 'text/plain');
|
|
846
|
+
|
|
847
|
+
ToolLogger.logResponse(req, response);
|
|
848
|
+
|
|
849
|
+
const logCall = mockLogger.info.mock.calls[0];
|
|
850
|
+
const loggedData = JSON.parse(logCall[1]);
|
|
851
|
+
|
|
852
|
+
expect(loggedData.responseBody).toBe(`${'a'.repeat(256)}... (truncated)`);
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
it('should not truncate long plain text failed responses', () => {
|
|
856
|
+
const req = createMockRequest();
|
|
857
|
+
const longText = 'a'.repeat(150);
|
|
858
|
+
const response = createMockResponseWithBody(500, new Uint8Array(Buffer.from(longText)), 'text/plain');
|
|
859
|
+
|
|
860
|
+
ToolLogger.logResponse(req, response);
|
|
861
|
+
|
|
862
|
+
const logCall = mockLogger.info.mock.calls[0];
|
|
863
|
+
const loggedData = JSON.parse(logCall[1]);
|
|
864
|
+
|
|
865
|
+
expect(loggedData.responseBody).toBe(longText);
|
|
866
|
+
expect(loggedData.success).toBe(false);
|
|
867
|
+
});
|
|
700
868
|
});
|
|
701
869
|
|
|
702
870
|
describe('edge cases', () => {
|
|
@@ -743,7 +911,7 @@ describe('ToolLogger', () => {
|
|
|
743
911
|
string: 'text',
|
|
744
912
|
number: 42,
|
|
745
913
|
boolean: true,
|
|
746
|
-
array: [1, 2
|
|
914
|
+
array: [1, 2],
|
|
747
915
|
object: { nested: 'value' },
|
|
748
916
|
nullValue: null,
|
|
749
917
|
password: 'secret'
|
|
@@ -761,7 +929,7 @@ describe('ToolLogger', () => {
|
|
|
761
929
|
string: 'text',
|
|
762
930
|
number: 42,
|
|
763
931
|
boolean: true,
|
|
764
|
-
array: [1, 2
|
|
932
|
+
array: [1, 2],
|
|
765
933
|
object: { nested: 'value' },
|
|
766
934
|
nullValue: null,
|
|
767
935
|
password: '[REDACTED]'
|
|
@@ -841,24 +1009,7 @@ describe('ToolLogger', () => {
|
|
|
841
1009
|
description: 'OVERRIDDEN: Enhanced minimum detectable effect calculation',
|
|
842
1010
|
required: true
|
|
843
1011
|
},
|
|
844
|
-
|
|
845
|
-
name: 'sigLevel',
|
|
846
|
-
type: 'number',
|
|
847
|
-
description: 'OVERRIDDEN: Enhanced statistical significance level',
|
|
848
|
-
required: true
|
|
849
|
-
},
|
|
850
|
-
{
|
|
851
|
-
name: 'numVariations',
|
|
852
|
-
type: 'number',
|
|
853
|
-
description: 'OVERRIDDEN: Enhanced number of variations handling',
|
|
854
|
-
required: true
|
|
855
|
-
},
|
|
856
|
-
{
|
|
857
|
-
name: 'dailyVisitors',
|
|
858
|
-
type: 'number',
|
|
859
|
-
description: 'OVERRIDDEN: Enhanced daily visitor count with forecasting',
|
|
860
|
-
required: true
|
|
861
|
-
}
|
|
1012
|
+
'... (3 more items truncated)'
|
|
862
1013
|
]
|
|
863
1014
|
}
|
|
864
1015
|
]
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { logger, LogVisibility } from '@zaiusinc/app-sdk';
|
|
2
2
|
import * as App from '@zaiusinc/app-sdk';
|
|
3
3
|
|
|
4
|
+
const MAX_PARAM_LOG_LENGTH = 128;
|
|
5
|
+
const MAX_BODY_LOG_LENGTH = 256;
|
|
6
|
+
const MAX_ARRAY_ITEMS = 2;
|
|
7
|
+
|
|
4
8
|
/**
|
|
5
9
|
* Utility class for logging Opal tool requests and responses with security considerations
|
|
6
10
|
*/
|
|
@@ -48,13 +52,13 @@ export class ToolLogger {
|
|
|
48
52
|
'bearer_token'
|
|
49
53
|
];
|
|
50
54
|
|
|
51
|
-
private static readonly MAX_PARAM_LENGTH = 100;
|
|
52
|
-
private static readonly MAX_ARRAY_ITEMS = 10;
|
|
53
|
-
|
|
54
55
|
/**
|
|
55
56
|
* Redacts sensitive data from an object
|
|
56
57
|
*/
|
|
57
|
-
private static
|
|
58
|
+
private static redactSensitiveDataAndTruncate(data: any, maxDepth = 5, accumulatedLength = 0): any {
|
|
59
|
+
if (accumulatedLength > MAX_BODY_LOG_LENGTH) {
|
|
60
|
+
return '';
|
|
61
|
+
}
|
|
58
62
|
if (maxDepth <= 0) {
|
|
59
63
|
return '[MAX_DEPTH_EXCEEDED]';
|
|
60
64
|
}
|
|
@@ -64,9 +68,13 @@ export class ToolLogger {
|
|
|
64
68
|
}
|
|
65
69
|
|
|
66
70
|
if (typeof data === 'string') {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
71
|
+
if (data.length > MAX_PARAM_LOG_LENGTH) {
|
|
72
|
+
const lead = data.substring(0, MAX_PARAM_LOG_LENGTH - 10);
|
|
73
|
+
const tail = data.substring(data.length - 10);
|
|
74
|
+
return `${lead}...[${data.length - MAX_PARAM_LOG_LENGTH} truncated]...${tail}`;
|
|
75
|
+
} else {
|
|
76
|
+
return data;
|
|
77
|
+
}
|
|
70
78
|
}
|
|
71
79
|
|
|
72
80
|
if (typeof data === 'number' || typeof data === 'boolean') {
|
|
@@ -74,10 +82,10 @@ export class ToolLogger {
|
|
|
74
82
|
}
|
|
75
83
|
|
|
76
84
|
if (Array.isArray(data)) {
|
|
77
|
-
const truncated = data.slice(0,
|
|
78
|
-
const result = truncated.map((item) => this.
|
|
79
|
-
if (data.length >
|
|
80
|
-
result.push(`... (${data.length -
|
|
85
|
+
const truncated = data.slice(0, MAX_ARRAY_ITEMS);
|
|
86
|
+
const result = truncated.map((item) => this.redactSensitiveDataAndTruncate(item, maxDepth, accumulatedLength));
|
|
87
|
+
if (data.length > MAX_ARRAY_ITEMS) {
|
|
88
|
+
result.push(`... (${data.length - MAX_ARRAY_ITEMS} more items truncated)`);
|
|
81
89
|
}
|
|
82
90
|
return result;
|
|
83
91
|
}
|
|
@@ -85,13 +93,20 @@ export class ToolLogger {
|
|
|
85
93
|
if (typeof data === 'object') {
|
|
86
94
|
const result: any = {};
|
|
87
95
|
for (const [key, value] of Object.entries(data)) {
|
|
96
|
+
if (accumulatedLength > MAX_BODY_LOG_LENGTH) {
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
88
99
|
// Check if this field contains sensitive data
|
|
89
100
|
const isSensitive = this.isSensitiveField(key);
|
|
90
101
|
|
|
91
102
|
if (isSensitive) {
|
|
92
103
|
result[key] = '[REDACTED]';
|
|
93
104
|
} else {
|
|
94
|
-
result[key] = this.
|
|
105
|
+
result[key] = this.redactSensitiveDataAndTruncate(value, maxDepth - 1, accumulatedLength);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (result[key]) {
|
|
109
|
+
accumulatedLength += JSON.stringify(result[key]).length;
|
|
95
110
|
}
|
|
96
111
|
}
|
|
97
112
|
return result;
|
|
@@ -118,7 +133,7 @@ export class ToolLogger {
|
|
|
118
133
|
return null;
|
|
119
134
|
}
|
|
120
135
|
|
|
121
|
-
return this.
|
|
136
|
+
return this.redactSensitiveDataAndTruncate(params);
|
|
122
137
|
}
|
|
123
138
|
|
|
124
139
|
/**
|
|
@@ -136,6 +151,99 @@ export class ToolLogger {
|
|
|
136
151
|
}
|
|
137
152
|
}
|
|
138
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Extracts the response body as a string or parsed JSON object
|
|
156
|
+
*/
|
|
157
|
+
private static getResponseBody(response?: App.Response): any {
|
|
158
|
+
if (!response) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
const contentType = response.headers?.get('content-type') || '';
|
|
164
|
+
const isJson = contentType.includes('application/json') || contentType.includes('application/problem+json');
|
|
165
|
+
const isText = contentType.startsWith('text/');
|
|
166
|
+
|
|
167
|
+
if (!isJson && !isText) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Try to access bodyAsU8Array - this may throw
|
|
172
|
+
const bodyData = response.bodyAsU8Array;
|
|
173
|
+
if (!bodyData) {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Convert Uint8Array to string
|
|
178
|
+
const bodyString = Buffer.from(bodyData).toString();
|
|
179
|
+
if (!bodyString) {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Try to parse as JSON if content-type indicates JSON
|
|
184
|
+
if (isJson) {
|
|
185
|
+
try {
|
|
186
|
+
return JSON.parse(bodyString);
|
|
187
|
+
} catch {
|
|
188
|
+
// If JSON parsing fails, return as string
|
|
189
|
+
return bodyString;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Return as plain text for non-JSON content types
|
|
194
|
+
return bodyString;
|
|
195
|
+
} catch {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Creates a summary of response body with security redaction and truncation
|
|
202
|
+
* For failed responses (4xx, 5xx): returns full body with redacted sensitive data
|
|
203
|
+
* For successful responses (2xx): returns first 100 chars with redacted sensitive data
|
|
204
|
+
*/
|
|
205
|
+
private static createResponseBodySummary(response?: App.Response, success?: boolean): any {
|
|
206
|
+
const body = this.getResponseBody(response);
|
|
207
|
+
if (body === null || body === undefined) {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// For objects (parsed JSON), apply redaction
|
|
212
|
+
if (typeof body === 'object') {
|
|
213
|
+
// For failed responses, don't truncate strings within the object
|
|
214
|
+
const redactedBody = this.redactSensitiveDataAndTruncate(body, 5);
|
|
215
|
+
|
|
216
|
+
// For successful responses, truncate to first MAX_BODY_LOG_LENGTH chars
|
|
217
|
+
if (success) {
|
|
218
|
+
const bodyString = JSON.stringify(redactedBody);
|
|
219
|
+
if (bodyString.length > MAX_BODY_LOG_LENGTH) {
|
|
220
|
+
const truncated = bodyString.substring(0, MAX_BODY_LOG_LENGTH);
|
|
221
|
+
return `${truncated}... (truncated)`;
|
|
222
|
+
}
|
|
223
|
+
return redactedBody;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// For failed responses, return full redacted body
|
|
227
|
+
return redactedBody;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// For strings (plain text or unparseable JSON)
|
|
231
|
+
if (typeof body === 'string') {
|
|
232
|
+
// For successful responses, truncate to first 100 chars
|
|
233
|
+
if (success) {
|
|
234
|
+
if (body.length > MAX_BODY_LOG_LENGTH) {
|
|
235
|
+
return `${body.substring(0, MAX_BODY_LOG_LENGTH)}... (truncated)`;
|
|
236
|
+
}
|
|
237
|
+
return body;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// For failed responses, return full body
|
|
241
|
+
return body;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return body;
|
|
245
|
+
}
|
|
246
|
+
|
|
139
247
|
/**
|
|
140
248
|
* Logs an incoming request
|
|
141
249
|
*/
|
|
@@ -162,16 +270,22 @@ export class ToolLogger {
|
|
|
162
270
|
response: App.Response,
|
|
163
271
|
processingTimeMs?: number
|
|
164
272
|
): void {
|
|
165
|
-
const
|
|
273
|
+
const success = response.status >= 200 && response.status < 300;
|
|
274
|
+
const responseLog: any = {
|
|
166
275
|
event: 'opal_tool_response',
|
|
167
276
|
path: req.path,
|
|
168
277
|
duration: processingTimeMs ? `${processingTimeMs}ms` : undefined,
|
|
169
278
|
status: response.status,
|
|
170
279
|
contentType: response.headers?.get('content-type') || 'unknown',
|
|
171
280
|
contentLength: this.calculateContentLength(response),
|
|
172
|
-
success
|
|
281
|
+
success
|
|
173
282
|
};
|
|
174
283
|
|
|
284
|
+
const responseBodySummary = this.createResponseBodySummary(response, success);
|
|
285
|
+
if (responseBodySummary) {
|
|
286
|
+
responseLog.responseBody = responseBodySummary;
|
|
287
|
+
}
|
|
288
|
+
|
|
175
289
|
// Log with Zaius audience so developers only see requests for accounts they have access to
|
|
176
290
|
logger.info(LogVisibility.Zaius, JSON.stringify(responseLog));
|
|
177
291
|
}
|
package/src/types/Models.ts
CHANGED