@elisra-devops/docgen-data-provider 1.142.0 → 1.144.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/bin/helpers/requestContext.d.ts +15 -0
- package/bin/helpers/requestContext.js +39 -2
- package/bin/helpers/requestContext.js.map +1 -1
- package/bin/helpers/tfs.js +9 -2
- package/bin/helpers/tfs.js.map +1 -1
- package/bin/modules/GitDataProvider.js +1 -1
- package/bin/modules/GitDataProvider.js.map +1 -1
- package/bin/modules/JfrogDataProvider.js +1 -1
- package/bin/modules/JfrogDataProvider.js.map +1 -1
- package/bin/modules/PipelinesDataProvider.js +3 -3
- package/bin/modules/PipelinesDataProvider.js.map +1 -1
- package/bin/modules/ResultDataProvider.js +10 -10
- package/bin/modules/ResultDataProvider.js.map +1 -1
- package/bin/modules/TestDataProvider.js +3 -3
- package/bin/modules/TestDataProvider.js.map +1 -1
- package/bin/modules/TicketsDataProvider.js +11 -11
- package/bin/modules/TicketsDataProvider.js.map +1 -1
- package/bin/tests/helpers/requestContext.test.js +39 -0
- package/bin/tests/helpers/requestContext.test.js.map +1 -1
- package/bin/tests/helpers/tfs.test.js +21 -2
- package/bin/tests/helpers/tfs.test.js.map +1 -1
- package/bin/tests/modules/ResultDataProvider.test.js +1 -0
- package/bin/tests/modules/ResultDataProvider.test.js.map +1 -1
- package/bin/tests/utils/logger.test.js +26 -0
- package/bin/tests/utils/logger.test.js.map +1 -1
- package/bin/tests/utils/runContextPropagation.test.d.ts +1 -0
- package/bin/tests/utils/runContextPropagation.test.js +81 -0
- package/bin/tests/utils/runContextPropagation.test.js.map +1 -0
- package/bin/utils/logger.d.ts +2 -0
- package/bin/utils/logger.js +48 -9
- package/bin/utils/logger.js.map +1 -1
- package/package.json +1 -1
- package/src/helpers/requestContext.ts +39 -3
- package/src/helpers/tfs.ts +11 -3
- package/src/modules/GitDataProvider.ts +2 -2
- package/src/modules/JfrogDataProvider.ts +2 -2
- package/src/modules/PipelinesDataProvider.ts +4 -4
- package/src/modules/ResultDataProvider.ts +11 -11
- package/src/modules/TestDataProvider.ts +4 -4
- package/src/modules/TicketsDataProvider.ts +12 -12
- package/src/tests/helpers/requestContext.test.ts +50 -0
- package/src/tests/helpers/tfs.test.ts +23 -2
- package/src/tests/modules/ResultDataProvider.test.ts +1 -0
- package/src/tests/utils/logger.test.ts +35 -1
- package/src/tests/utils/runContextPropagation.test.ts +93 -0
- package/src/utils/logger.ts +42 -4
|
@@ -22,7 +22,7 @@ import type {
|
|
|
22
22
|
MewpRunStatus,
|
|
23
23
|
} from '../models/mewp-reporting';
|
|
24
24
|
import logger from '../utils/logger';
|
|
25
|
-
import { describeError } from '../helpers/requestContext';
|
|
25
|
+
import { describeError, logCaughtError } from '../helpers/requestContext';
|
|
26
26
|
import MewpExternalIngestionUtils from '../utils/mewpExternalIngestionUtils';
|
|
27
27
|
import MewpExternalTableUtils, {
|
|
28
28
|
MewpExternalFileValidationError,
|
|
@@ -140,7 +140,7 @@ export default class ResultDataProvider {
|
|
|
140
140
|
this.fetchTestPoints(projectName, testPlanId, suite.testSuiteId)
|
|
141
141
|
.then((testPointsItems) => ({ ...suite, testPointsItems }))
|
|
142
142
|
.catch((error: any) => {
|
|
143
|
-
|
|
143
|
+
logCaughtError(`Error occurred for suite ${suite.testSuiteId}:`, error);
|
|
144
144
|
return { ...suite, testPointsItems: [] };
|
|
145
145
|
})
|
|
146
146
|
)
|
|
@@ -403,7 +403,7 @@ export default class ResultDataProvider {
|
|
|
403
403
|
|
|
404
404
|
return fetchedTestResults;
|
|
405
405
|
} catch (error: any) {
|
|
406
|
-
|
|
406
|
+
logCaughtError('Error during getTestReporterResults:', error);
|
|
407
407
|
}
|
|
408
408
|
}
|
|
409
409
|
|
|
@@ -444,7 +444,7 @@ export default class ResultDataProvider {
|
|
|
444
444
|
|
|
445
445
|
return { planId: testPlanId, planName, rows: rows || [] };
|
|
446
446
|
} catch (error: any) {
|
|
447
|
-
|
|
447
|
+
logCaughtError('Error during getTestReporterFlatResults:', error);
|
|
448
448
|
return { planId: testPlanId, planName: '', rows: [] };
|
|
449
449
|
}
|
|
450
450
|
}
|
|
@@ -746,7 +746,7 @@ export default class ResultDataProvider {
|
|
|
746
746
|
rows,
|
|
747
747
|
};
|
|
748
748
|
} catch (error: any) {
|
|
749
|
-
|
|
749
|
+
logCaughtError('Error during getMewpL2CoverageFlatResults:', error);
|
|
750
750
|
if (error instanceof MewpExternalFileValidationError) {
|
|
751
751
|
throw error;
|
|
752
752
|
}
|
|
@@ -1123,7 +1123,7 @@ export default class ResultDataProvider {
|
|
|
1123
1123
|
rows,
|
|
1124
1124
|
};
|
|
1125
1125
|
} catch (error: any) {
|
|
1126
|
-
|
|
1126
|
+
logCaughtError('Error during getMewpInternalValidationFlatResults:', error);
|
|
1127
1127
|
return defaultPayload;
|
|
1128
1128
|
}
|
|
1129
1129
|
}
|
|
@@ -3072,7 +3072,7 @@ export default class ResultDataProvider {
|
|
|
3072
3072
|
const testPlan = await TFSServices.getItemContent(url, this.token);
|
|
3073
3073
|
return testPlan.name;
|
|
3074
3074
|
} catch (error: any) {
|
|
3075
|
-
|
|
3075
|
+
logCaughtError('Error during fetching Test Plan Name:', error);
|
|
3076
3076
|
return '';
|
|
3077
3077
|
}
|
|
3078
3078
|
}
|
|
@@ -3120,7 +3120,7 @@ export default class ResultDataProvider {
|
|
|
3120
3120
|
};
|
|
3121
3121
|
});
|
|
3122
3122
|
} catch (error: any) {
|
|
3123
|
-
|
|
3123
|
+
logCaughtError('Error during fetching Test Suites:', error);
|
|
3124
3124
|
return [];
|
|
3125
3125
|
}
|
|
3126
3126
|
}
|
|
@@ -3293,7 +3293,7 @@ export default class ResultDataProvider {
|
|
|
3293
3293
|
|
|
3294
3294
|
return count !== 0 ? testPoints.map((testPoint: any) => this.mapTestPoint(testPoint, projectName)) : [];
|
|
3295
3295
|
} catch (error: any) {
|
|
3296
|
-
|
|
3296
|
+
logCaughtError('Error during fetching Test Points:', error);
|
|
3297
3297
|
return [];
|
|
3298
3298
|
}
|
|
3299
3299
|
}
|
|
@@ -4474,7 +4474,7 @@ export default class ResultDataProvider {
|
|
|
4474
4474
|
relatedCRs.push({ id, title, workItemType: 'Change Request', url });
|
|
4475
4475
|
}
|
|
4476
4476
|
} catch (err: any) {
|
|
4477
|
-
|
|
4477
|
+
logCaughtError(`Could not append related work item to test case ${wiByRevision.id}:`, err);
|
|
4478
4478
|
}
|
|
4479
4479
|
}
|
|
4480
4480
|
}
|
|
@@ -4857,7 +4857,7 @@ export default class ResultDataProvider {
|
|
|
4857
4857
|
|
|
4858
4858
|
return { ...suite, testPointsItems, testCasesItems };
|
|
4859
4859
|
} catch (error: any) {
|
|
4860
|
-
|
|
4860
|
+
logCaughtError(`Error occurred for suite ${suite.testSuiteId}:`, error);
|
|
4861
4861
|
return suite;
|
|
4862
4862
|
}
|
|
4863
4863
|
})
|
|
@@ -4,7 +4,7 @@ import { TestSteps, createMomRelation, createRequirementRelation } from '../mode
|
|
|
4
4
|
import { TestCase } from '../models/tfs-data';
|
|
5
5
|
import * as xml2js from 'xml2js';
|
|
6
6
|
import logger from '../utils/logger';
|
|
7
|
-
import {
|
|
7
|
+
import { logCaughtError } from '../helpers/requestContext';
|
|
8
8
|
import Utils from '../utils/testStepParserHelper';
|
|
9
9
|
import DataProviderUtils from '../utils/DataProviderUtils';
|
|
10
10
|
const pLimit = require('p-limit');
|
|
@@ -57,7 +57,7 @@ export default class TestDataProvider {
|
|
|
57
57
|
|
|
58
58
|
return result;
|
|
59
59
|
} catch (error: any) {
|
|
60
|
-
|
|
60
|
+
logCaughtError(`Error fetching ${url}:`, error);
|
|
61
61
|
throw error;
|
|
62
62
|
}
|
|
63
63
|
}
|
|
@@ -188,7 +188,7 @@ export default class TestDataProvider {
|
|
|
188
188
|
try {
|
|
189
189
|
return await this.fetchWithCache(testsuitesUrl);
|
|
190
190
|
} catch (e: any) {
|
|
191
|
-
|
|
191
|
+
logCaughtError('Failed to get test suites:', e);
|
|
192
192
|
return null;
|
|
193
193
|
}
|
|
194
194
|
}
|
|
@@ -600,7 +600,7 @@ export default class TestDataProvider {
|
|
|
600
600
|
}
|
|
601
601
|
}
|
|
602
602
|
} catch (err: any) {
|
|
603
|
-
|
|
603
|
+
logCaughtError(`Error while trying to structure testCases for test suite ${suite.id}:`, err);
|
|
604
604
|
}
|
|
605
605
|
|
|
606
606
|
return testCasesUrlList;
|
|
@@ -10,7 +10,7 @@ import { TestSteps } from '../models/tfs-data';
|
|
|
10
10
|
import TestStepParserHelper from '../utils/testStepParserHelper';
|
|
11
11
|
|
|
12
12
|
import logger from '../utils/logger';
|
|
13
|
-
import { describeError } from '../helpers/requestContext';
|
|
13
|
+
import { describeError, logCaughtError } from '../helpers/requestContext';
|
|
14
14
|
const pLimit = require('p-limit');
|
|
15
15
|
|
|
16
16
|
type FallbackFetchOutcome = {
|
|
@@ -492,7 +492,7 @@ export default class TicketsDataProvider {
|
|
|
492
492
|
break;
|
|
493
493
|
}
|
|
494
494
|
} catch (err: any) {
|
|
495
|
-
|
|
495
|
+
logCaughtError('Error occurred during fetching shared queries:', err);
|
|
496
496
|
throw err;
|
|
497
497
|
}
|
|
498
498
|
}
|
|
@@ -528,7 +528,7 @@ export default class TicketsDataProvider {
|
|
|
528
528
|
})
|
|
529
529
|
);
|
|
530
530
|
} catch (err: any) {
|
|
531
|
-
|
|
531
|
+
logCaughtError('Error occurred during fetching fields by type:', err);
|
|
532
532
|
throw err;
|
|
533
533
|
}
|
|
534
534
|
}
|
|
@@ -559,7 +559,7 @@ export default class TicketsDataProvider {
|
|
|
559
559
|
}
|
|
560
560
|
return { types, states: Array.from(stateSet) };
|
|
561
561
|
} catch (err: any) {
|
|
562
|
-
|
|
562
|
+
logCaughtError('Error fetching work item type states:', err);
|
|
563
563
|
throw err;
|
|
564
564
|
}
|
|
565
565
|
}
|
|
@@ -1287,7 +1287,7 @@ export default class TicketsDataProvider {
|
|
|
1287
1287
|
break;
|
|
1288
1288
|
}
|
|
1289
1289
|
} catch (err: any) {
|
|
1290
|
-
|
|
1290
|
+
logCaughtError(`Could not fetch query results for ${wiqlHref}:`, err);
|
|
1291
1291
|
}
|
|
1292
1292
|
}
|
|
1293
1293
|
|
|
@@ -1663,7 +1663,7 @@ export default class TicketsDataProvider {
|
|
|
1663
1663
|
}
|
|
1664
1664
|
return [...workItemsResultMap.values()];
|
|
1665
1665
|
} catch (error: any) {
|
|
1666
|
-
|
|
1666
|
+
logCaughtError('could not parse requested flat query:', error);
|
|
1667
1667
|
}
|
|
1668
1668
|
}
|
|
1669
1669
|
|
|
@@ -3563,7 +3563,7 @@ export default class TicketsDataProvider {
|
|
|
3563
3563
|
|
|
3564
3564
|
item.fields = { ...parsedFields };
|
|
3565
3565
|
} catch (err: any) {
|
|
3566
|
-
|
|
3566
|
+
logCaughtError('Cannot filter columns:', err);
|
|
3567
3567
|
throw err;
|
|
3568
3568
|
}
|
|
3569
3569
|
}
|
|
@@ -3618,7 +3618,7 @@ export default class TicketsDataProvider {
|
|
|
3618
3618
|
|
|
3619
3619
|
return workItemTypesWithIcons;
|
|
3620
3620
|
} catch (err: any) {
|
|
3621
|
-
|
|
3621
|
+
logCaughtError('Error occurred during fetching work item types:', err);
|
|
3622
3622
|
throw err;
|
|
3623
3623
|
}
|
|
3624
3624
|
}
|
|
@@ -3793,7 +3793,7 @@ export default class TicketsDataProvider {
|
|
|
3793
3793
|
totalCount: workItemIds.length,
|
|
3794
3794
|
};
|
|
3795
3795
|
} catch (err: any) {
|
|
3796
|
-
|
|
3796
|
+
logCaughtError('Could not fetch categorized requirements:', err);
|
|
3797
3797
|
throw err;
|
|
3798
3798
|
}
|
|
3799
3799
|
}
|
|
@@ -3888,7 +3888,7 @@ export default class TicketsDataProvider {
|
|
|
3888
3888
|
const relations = qr?.workItemRelations || [];
|
|
3889
3889
|
result['req-test'] = await resolveQuerySides(cols, relations, true);
|
|
3890
3890
|
} catch (err: any) {
|
|
3891
|
-
|
|
3891
|
+
logCaughtError('GetTraceColumnsByType[req-test] failed:', err);
|
|
3892
3892
|
result['req-test'] = { Requirement: [], 'Test Case': [] };
|
|
3893
3893
|
}
|
|
3894
3894
|
}
|
|
@@ -3900,14 +3900,14 @@ export default class TicketsDataProvider {
|
|
|
3900
3900
|
const relations = qr?.workItemRelations || [];
|
|
3901
3901
|
result['test-req'] = await resolveQuerySides(cols, relations, false);
|
|
3902
3902
|
} catch (err: any) {
|
|
3903
|
-
|
|
3903
|
+
logCaughtError('GetTraceColumnsByType[test-req] failed:', err);
|
|
3904
3904
|
result['test-req'] = { Requirement: [], 'Test Case': [] };
|
|
3905
3905
|
}
|
|
3906
3906
|
}
|
|
3907
3907
|
|
|
3908
3908
|
return result;
|
|
3909
3909
|
} catch (err: any) {
|
|
3910
|
-
|
|
3910
|
+
logCaughtError('GetTraceColumnsByType failed:', err);
|
|
3911
3911
|
return {};
|
|
3912
3912
|
}
|
|
3913
3913
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import logger from '../../utils/logger';
|
|
1
2
|
import {
|
|
2
3
|
sanitizeUrl,
|
|
3
4
|
summarizeBody,
|
|
@@ -5,6 +6,9 @@ import {
|
|
|
5
6
|
buildAdoRequestContext,
|
|
6
7
|
annotateAdoError,
|
|
7
8
|
maskWiqlLiterals,
|
|
9
|
+
templatePath,
|
|
10
|
+
describeError,
|
|
11
|
+
logCaughtError,
|
|
8
12
|
} from '../../helpers/requestContext';
|
|
9
13
|
|
|
10
14
|
describe('maskWiqlLiterals', () => {
|
|
@@ -200,3 +204,49 @@ describe('annotateAdoError', () => {
|
|
|
200
204
|
expect(() => annotateAdoError(undefined as any, { url: 'u' })).not.toThrow();
|
|
201
205
|
});
|
|
202
206
|
});
|
|
207
|
+
|
|
208
|
+
describe('templatePath', () => {
|
|
209
|
+
it('drops origin and query and collapses numeric / guid segments', () => {
|
|
210
|
+
expect(templatePath('https://dev.azure.com/org/p/_apis/wit/workitems/123?api-version=7')).toBe(
|
|
211
|
+
'/org/p/_apis/wit/workitems/{id}'
|
|
212
|
+
);
|
|
213
|
+
expect(templatePath('https://h/a/3f2504e0-4f89-11d3-9a0c-0305e82c3301/b')).toBe('/a/{id}/b');
|
|
214
|
+
});
|
|
215
|
+
it('copes with a relative or missing url', () => {
|
|
216
|
+
expect(templatePath('/rel/42?x=1')).toBe('/rel/{id}');
|
|
217
|
+
expect(templatePath(undefined)).toBe('(unknown url)');
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe('describeError', () => {
|
|
222
|
+
it('carries the original message separately so the dashboard err.message is not the combined line', () => {
|
|
223
|
+
expect(describeError(new Error('boom'))).toMatchObject({ message: 'boom', errMessage: 'boom' });
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe('logCaughtError', () => {
|
|
228
|
+
beforeEach(() => {
|
|
229
|
+
jest.spyOn(logger, 'warn').mockImplementation((() => logger) as any);
|
|
230
|
+
jest.spyOn(logger, 'error').mockImplementation((() => logger) as any);
|
|
231
|
+
});
|
|
232
|
+
afterEach(() => jest.restoreAllMocks());
|
|
233
|
+
|
|
234
|
+
it('logs at warn when TFSServices already reported the error', () => {
|
|
235
|
+
const err = annotateAdoError(new Error('x'), { method: 'GET', url: 'https://h/y', reported: true });
|
|
236
|
+
logCaughtError('Could not fetch Y:', err);
|
|
237
|
+
expect(logger.warn).toHaveBeenCalledWith('Could not fetch Y:', expect.objectContaining({ message: 'x' }));
|
|
238
|
+
expect(logger.error).not.toHaveBeenCalled();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('stays at error when the error was annotated but not reported (printError=false)', () => {
|
|
242
|
+
const err = annotateAdoError(new Error('x'), { method: 'GET', url: 'https://h/y', reported: false });
|
|
243
|
+
logCaughtError('Could not fetch Y:', err);
|
|
244
|
+
expect(logger.error).toHaveBeenCalledTimes(1);
|
|
245
|
+
expect(logger.warn).not.toHaveBeenCalled();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('stays at error for a non-ADO error', () => {
|
|
249
|
+
logCaughtError('Could not fetch Y:', new Error('plain'));
|
|
250
|
+
expect(logger.error).toHaveBeenCalledTimes(1);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
@@ -1115,6 +1115,7 @@ describe('TFSServices request-context annotation', () => {
|
|
|
1115
1115
|
url: 'https://dev.azure.com/org/_apis/wit/queries/q1',
|
|
1116
1116
|
status: 404,
|
|
1117
1117
|
attempt: 1,
|
|
1118
|
+
reported: false,
|
|
1118
1119
|
responseExcerpt: 'TF401232: Work item 5 does not exist',
|
|
1119
1120
|
});
|
|
1120
1121
|
expect(logger.error).not.toHaveBeenCalled();
|
|
@@ -1127,13 +1128,33 @@ describe('TFSServices request-context annotation', () => {
|
|
|
1127
1128
|
|
|
1128
1129
|
expect(logger.error).toHaveBeenCalledTimes(1);
|
|
1129
1130
|
expect(logger.error).toHaveBeenCalledWith(
|
|
1130
|
-
'ADO request failed',
|
|
1131
|
+
'ADO request failed: GET /org/_apis/x -> 404',
|
|
1131
1132
|
expect.objectContaining({
|
|
1132
|
-
adoRequest: expect.objectContaining({
|
|
1133
|
+
adoRequest: expect.objectContaining({
|
|
1134
|
+
method: 'GET',
|
|
1135
|
+
url: 'https://dev.azure.com/org/_apis/x',
|
|
1136
|
+
status: 404,
|
|
1137
|
+
reported: true,
|
|
1138
|
+
}),
|
|
1133
1139
|
})
|
|
1134
1140
|
);
|
|
1135
1141
|
});
|
|
1136
1142
|
|
|
1143
|
+
it('puts status and templated path in the message so 401 and 404 group separately', async () => {
|
|
1144
|
+
const e401: any = new Error('Request failed with status code 401');
|
|
1145
|
+
e401.response = { status: 401, data: {} };
|
|
1146
|
+
mockAxiosInstance.request.mockRejectedValueOnce(e401);
|
|
1147
|
+
await TFSServices.getItemContent('https://dev.azure.com/org/p/_apis/wit/workitems/123', 'pat').catch(() => undefined);
|
|
1148
|
+
mockAxiosInstance.request.mockRejectedValueOnce(notFound());
|
|
1149
|
+
await TFSServices.getItemContent('https://dev.azure.com/org/p/_apis/wit/workitems/456', 'pat').catch(() => undefined);
|
|
1150
|
+
|
|
1151
|
+
const messages = (logger.error as jest.Mock).mock.calls.map((c) => c[0]);
|
|
1152
|
+
expect(messages).toEqual([
|
|
1153
|
+
'ADO request failed: GET /org/p/_apis/wit/workitems/{id} -> 401',
|
|
1154
|
+
'ADO request failed: GET /org/p/_apis/wit/workitems/{id} -> 404',
|
|
1155
|
+
]);
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1137
1158
|
it('never leaks the PAT: credentials are applied to the axios config, not the description', async () => {
|
|
1138
1159
|
mockAxiosInstance.request.mockRejectedValue(notFound());
|
|
1139
1160
|
|
|
@@ -6032,6 +6032,7 @@ describe('ResultDataProvider', () => {
|
|
|
6032
6032
|
// record with the response summary as meta (docgen-log-redaction / Phase 2/4).
|
|
6033
6033
|
expect(logger.error).toHaveBeenCalledWith('Error during getCombinedResultsSummary:', {
|
|
6034
6034
|
message: 'boom',
|
|
6035
|
+
errMessage: 'boom',
|
|
6035
6036
|
stack: expect.any(String),
|
|
6036
6037
|
responseSummary: '{"detail":"bad"}',
|
|
6037
6038
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as winston from 'winston';
|
|
2
2
|
import Transport = require('winston-transport');
|
|
3
|
-
import { redact, DiagnosticsTransport } from '../../utils/logger';
|
|
3
|
+
import { redact, DiagnosticsTransport, isSensitiveKey, skipUncaptured } from '../../utils/logger';
|
|
4
4
|
import { installLogSink, LogSink, DiagnosticEvent } from '../../utils/logSink';
|
|
5
5
|
import { runContextStore } from '../../utils/runContext';
|
|
6
6
|
|
|
@@ -379,3 +379,37 @@ describe('DiagnosticsTransport capture policy (Phase 6b)', () => {
|
|
|
379
379
|
expect(events[0].message).toBe('verbose debug');
|
|
380
380
|
});
|
|
381
381
|
});
|
|
382
|
+
|
|
383
|
+
describe('isSensitiveKey', () => {
|
|
384
|
+
it.each(['token', 'accessToken', 'x-docgen-ingest-token', 'PAT', 'pat', 'password', 'DB_PASSWORD', 'Authorization', 'minioSecretKey', 'minioAccessKey', 'apiKey', 'clientSecret', 'cookie'])(
|
|
385
|
+
'redacts %s',
|
|
386
|
+
(key) => expect(isSensitiveKey(key)).toBe(true)
|
|
387
|
+
);
|
|
388
|
+
it.each(['areaPath', 'IterationPath', 'path', 'filePath', 'patch', 'dispatch', 'compatibility', 'tokenCount', 'System.AreaPath', 'url'])(
|
|
389
|
+
'keeps %s',
|
|
390
|
+
(key) => expect(isSensitiveKey(key)).toBe(false)
|
|
391
|
+
);
|
|
392
|
+
it('redact() leaves areaPath visible but scrubs a token', () => {
|
|
393
|
+
const out = applyRedact({ message: 'm', areaPath: 'P\\Area', accessToken: 'abc' });
|
|
394
|
+
expect(out.areaPath).toBe('P\\Area');
|
|
395
|
+
expect(out.accessToken).toBe('[REDACTED]');
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
describe('skipUncaptured', () => {
|
|
400
|
+
const run = (level: string) => (skipUncaptured() as any).transform({ level, message: 'm' });
|
|
401
|
+
it('keeps what stdout prints and always keeps warn/error', () => {
|
|
402
|
+
expect(run('error')).toBeTruthy();
|
|
403
|
+
expect(run('warn')).toBeTruthy();
|
|
404
|
+
expect(run('info')).toBeTruthy();
|
|
405
|
+
});
|
|
406
|
+
it('drops debug in normal mode', () => {
|
|
407
|
+
expect(run('debug')).toBe(false);
|
|
408
|
+
});
|
|
409
|
+
it('keeps debug inside a verbose run', () => {
|
|
410
|
+
const { runContextStore } = require('../../utils/runContext');
|
|
411
|
+
runContextStore.run({ runId: 'r1', captureMode: 'verbose' }, () => {
|
|
412
|
+
expect(run('debug')).toBeTruthy();
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Guards the run context (runId / docType / project) that every log record is stamped from.
|
|
2
|
+
// Prod showed data-provider records with a run id but no project / doc type; these tests pin
|
|
3
|
+
// down what must hold in this package for a record to be attributed to the right run.
|
|
4
|
+
import * as fs from 'fs';
|
|
5
|
+
import * as path from 'path';
|
|
6
|
+
import * as winston from 'winston';
|
|
7
|
+
import { DiagnosticsTransport, withRunContext } from '../../utils/logger';
|
|
8
|
+
import { installLogSink, LogSink, DiagnosticEvent } from '../../utils/logSink';
|
|
9
|
+
import { runContextStore } from '../../utils/runContext';
|
|
10
|
+
|
|
11
|
+
const pLimit = require('p-limit');
|
|
12
|
+
const pause = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
13
|
+
|
|
14
|
+
function makeLogger() {
|
|
15
|
+
const events: DiagnosticEvent[] = [];
|
|
16
|
+
installLogSink({ push: (e: DiagnosticEvent) => events.push(e), flush: async () => undefined } as LogSink);
|
|
17
|
+
const logger = winston.createLogger({
|
|
18
|
+
level: 'debug',
|
|
19
|
+
format: winston.format.combine(withRunContext(), winston.format.json()),
|
|
20
|
+
transports: [new DiagnosticsTransport()],
|
|
21
|
+
});
|
|
22
|
+
return { logger, events };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('run context reaches log events from the places the data provider logs from', () => {
|
|
26
|
+
test('from tasks queued behind a per-instance p-limit, and from a retry timer', async () => {
|
|
27
|
+
const { logger, events } = makeLogger();
|
|
28
|
+
const limit = pLimit(2); // per request, like the providers' `private limit = pLimit(10)` fields
|
|
29
|
+
await runContextStore.run({ runId: 'run-1', docType: 'STD', project: 'MEWP' }, async () => {
|
|
30
|
+
await Promise.all(
|
|
31
|
+
[1, 2, 3, 4, 5, 6].map((i) =>
|
|
32
|
+
limit(async () => {
|
|
33
|
+
await pause(3);
|
|
34
|
+
logger.error(`queued task ${i}`);
|
|
35
|
+
})
|
|
36
|
+
)
|
|
37
|
+
);
|
|
38
|
+
await new Promise<void>((resolve) =>
|
|
39
|
+
setTimeout(() => {
|
|
40
|
+
logger.warn('Request failed. Retrying');
|
|
41
|
+
resolve();
|
|
42
|
+
}, 5)
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
expect(events).toHaveLength(7);
|
|
46
|
+
events.forEach((e) => expect(e).toMatchObject({ runId: 'run-1', docType: 'STD', project: 'MEWP' }));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('concurrent runs, each with its own limiter, never see each other\'s context', async () => {
|
|
50
|
+
const { logger, events } = makeLogger();
|
|
51
|
+
await Promise.all(
|
|
52
|
+
['A', 'B', 'C', 'D'].map((id) =>
|
|
53
|
+
runContextStore.run({ runId: `run-${id}`, docType: id === 'A' ? 'STD' : 'SVD', project: `P-${id}` }, async () => {
|
|
54
|
+
const limit = pLimit(2);
|
|
55
|
+
await Promise.all([1, 2, 3, 4].map((i) => limit(async () => { await pause(i); logger.error(`${id}:${i}`); })));
|
|
56
|
+
})
|
|
57
|
+
)
|
|
58
|
+
);
|
|
59
|
+
expect(events).toHaveLength(16);
|
|
60
|
+
events.forEach((e) => {
|
|
61
|
+
const id = e.message.split(':')[0];
|
|
62
|
+
expect(e).toMatchObject({ runId: `run-${id}`, project: `P-${id}` });
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// A p-limit queue shared by two concurrent runs resumes a queued task from whichever task last
|
|
68
|
+
// finished — i.e. in the OTHER run's async context — so its log lines are attributed to the wrong
|
|
69
|
+
// run (measured: half the tasks of two interleaved runs). Per-instance limiters are safe because
|
|
70
|
+
// a provider instance serves one request. If you need a limiter, make it a per-request/instance
|
|
71
|
+
// field; do not hoist it to module or class-static scope.
|
|
72
|
+
describe('no process-wide p-limit queue', () => {
|
|
73
|
+
const walk = (dir: string): string[] =>
|
|
74
|
+
fs.readdirSync(dir, { withFileTypes: true }).flatMap((d) => {
|
|
75
|
+
const p = path.join(dir, d.name);
|
|
76
|
+
if (d.isDirectory()) return d.name === 'tests' ? [] : walk(p);
|
|
77
|
+
return p.endsWith('.ts') ? [p] : [];
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('no pLimit(...) at module scope or in a static member', () => {
|
|
81
|
+
const offenders: string[] = [];
|
|
82
|
+
for (const file of walk(path.resolve(__dirname, '../..'))) {
|
|
83
|
+
fs.readFileSync(file, 'utf8')
|
|
84
|
+
.split('\n')
|
|
85
|
+
.forEach((line, i) => {
|
|
86
|
+
const moduleScope = /^(export\s+)?(const|let|var)\s+\w+\s*=\s*pLimit\(/.test(line);
|
|
87
|
+
const staticMember = /\bstatic\b[^=]*=\s*pLimit\(/.test(line);
|
|
88
|
+
if (moduleScope || staticMember) offenders.push(`${path.relative(process.cwd(), file)}:${i + 1}: ${line.trim()}`);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
expect(offenders).toEqual([]);
|
|
92
|
+
});
|
|
93
|
+
});
|
package/src/utils/logger.ts
CHANGED
|
@@ -29,7 +29,24 @@ export const withRunContext = winston.format((info) => {
|
|
|
29
29
|
// "accesskey" (not just "minioaccesskey") so this also catches an *AccessKeyId-style field —
|
|
30
30
|
// a real gap found in api-gate's copy during the Phase 5 manifest work: only the *SecretKey
|
|
31
31
|
// sibling was covered before, via "secret". Applied here too to keep the four copies in sync.
|
|
32
|
-
|
|
32
|
+
// Word-based, not substring: the key is split into lower-case words (camelCase, kebab-case and
|
|
33
|
+
// snake_case alike) and judged by how it *ends*. A substring test matched "pat" inside
|
|
34
|
+
// "areaPath" / "path" / "patch" and blanked ordinary ADO fields; this keeps `accessToken`,
|
|
35
|
+
// `x-docgen-ingest-token`, `minioSecretKey`, `PAT`, `password` redacted while `areaPath` stays.
|
|
36
|
+
const SENSITIVE_TAILS = ['token', 'password', 'secret', 'authorization', 'accesskey', 'secretkey', 'apikey'];
|
|
37
|
+
const SENSITIVE_LAST_WORDS = new Set(['pat', 'pwd', 'cookie', 'tokens', 'passwords', 'secrets']);
|
|
38
|
+
export function isSensitiveKey(key: string): boolean {
|
|
39
|
+
const words = key
|
|
40
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
41
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
|
42
|
+
.toLowerCase()
|
|
43
|
+
.split(/[^a-z0-9]+/)
|
|
44
|
+
.filter(Boolean);
|
|
45
|
+
if (words.length === 0) return false;
|
|
46
|
+
if (SENSITIVE_LAST_WORDS.has(words[words.length - 1])) return true;
|
|
47
|
+
const tail = words.slice(-2).join('');
|
|
48
|
+
return SENSITIVE_TAILS.some((t) => tail.endsWith(t));
|
|
49
|
+
}
|
|
33
50
|
|
|
34
51
|
// A per-key try/catch on the *read*, not just around the whole loop: a getter that throws
|
|
35
52
|
// (e.g. `{ get boom() { throw ... } }`) would otherwise abort redaction for every remaining
|
|
@@ -56,7 +73,7 @@ export function redactValue(value: unknown, depth = 0, seen = new WeakSet<object
|
|
|
56
73
|
out[key] = '[Unreadable property]';
|
|
57
74
|
continue;
|
|
58
75
|
}
|
|
59
|
-
out[key] =
|
|
76
|
+
out[key] = isSensitiveKey(key) ? '[REDACTED]' : redactValue(read.value, depth + 1, seen);
|
|
60
77
|
}
|
|
61
78
|
return out;
|
|
62
79
|
}
|
|
@@ -75,7 +92,7 @@ export const redact = winston.format((info) => {
|
|
|
75
92
|
? '[Unreadable property]'
|
|
76
93
|
: // A top-level primitive (e.g. token: "abc") has no children for redactValue to walk into —
|
|
77
94
|
// the key itself has to be checked here too, not just inside the recursive object walk.
|
|
78
|
-
|
|
95
|
+
isSensitiveKey(key)
|
|
79
96
|
? '[REDACTED]'
|
|
80
97
|
: redactValue(read.value);
|
|
81
98
|
try {
|
|
@@ -216,7 +233,9 @@ export class DiagnosticsTransport extends Transport {
|
|
|
216
233
|
message,
|
|
217
234
|
err: hasErr
|
|
218
235
|
? {
|
|
219
|
-
message
|
|
236
|
+
// The error's own message when the call site supplied it (describeError); else
|
|
237
|
+
// the combined winston message, which is all a bare logger.error(err) leaves.
|
|
238
|
+
message: clamp(info.errMessage, MAX_MESSAGE_LEN) ?? message,
|
|
220
239
|
code: typeof info.code === 'string' ? info.code : undefined,
|
|
221
240
|
stack: clamp(info.stack, MAX_STACK_LEN),
|
|
222
241
|
}
|
|
@@ -248,11 +267,29 @@ const useJson = (process.env.LOG_FORMAT || 'text').toLowerCase() === 'json';
|
|
|
248
267
|
// for free; info/warn/error volume (Phase 4's focus) is unaffected either way.
|
|
249
268
|
const CONSOLE_LOG_LEVEL = process.env.LOG_LEVEL || 'info';
|
|
250
269
|
|
|
270
|
+
// Drops a record before the (comparatively expensive) redact/splat/format work when nothing
|
|
271
|
+
// would consume it: stdout wouldn't print it, and DiagnosticsTransport wouldn't persist it
|
|
272
|
+
// (only debug/info under a verbose/retain-on-failure run are captured below warn). The logger's
|
|
273
|
+
// own level gate must stay 'debug' for that capture, so this is the cheap early exit instead.
|
|
274
|
+
const LEVEL_RANK: Record<string, number> = { error: 0, warn: 1, info: 2, http: 3, verbose: 4, debug: 5, silly: 6 };
|
|
275
|
+
export const skipUncaptured = winston.format((info) => {
|
|
276
|
+
const printRank = LEVEL_RANK[CONSOLE_LOG_LEVEL];
|
|
277
|
+
const rank = LEVEL_RANK[String(info.level)];
|
|
278
|
+
if (printRank === undefined || rank === undefined || rank <= printRank) return info;
|
|
279
|
+
const mode = runContextStore.getStore()?.captureMode;
|
|
280
|
+
const captured =
|
|
281
|
+
DIAGNOSTICS_CAPTURE_ENABLED &&
|
|
282
|
+
(info.level === 'debug' || info.level === 'info') &&
|
|
283
|
+
(mode === 'verbose' || mode === 'retain-on-failure');
|
|
284
|
+
return captured ? info : false;
|
|
285
|
+
});
|
|
286
|
+
|
|
251
287
|
logger = winston.createLogger({
|
|
252
288
|
level: 'debug',
|
|
253
289
|
defaultMeta: { service: '@elisra-devops/docgen-data-provider', version: readOwnVersion() },
|
|
254
290
|
format: useJson
|
|
255
291
|
? winston.format.combine(
|
|
292
|
+
skipUncaptured(),
|
|
256
293
|
winston.format.errors({ stack: true }),
|
|
257
294
|
winston.format.timestamp(),
|
|
258
295
|
withRunContext(),
|
|
@@ -261,6 +298,7 @@ logger = winston.createLogger({
|
|
|
261
298
|
winston.format.json()
|
|
262
299
|
)
|
|
263
300
|
: winston.format.combine(
|
|
301
|
+
skipUncaptured(),
|
|
264
302
|
winston.format.errors({ stack: true }),
|
|
265
303
|
winston.format.timestamp(),
|
|
266
304
|
withRunContext(),
|