From e636be442de6e12fb4fdafb1a2a1f3ba129b0f62 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Tue, 8 Sep 2026 09:54:37 -0400 Subject: [PATCH 1/2] feat: add aggregate search performance telemetry --- .env.example | 2 + README.md | 10 +- config/custom-environment-variables.jsonc | 4 +- config/default.jsonc | 6 +- config/runtime-config.test.js | 4 + controllers/searchController.js | 54 +++- controllers/searchController.test.js | 57 +++- utils/constants.js | 4 + utils/searchPerformanceTelemetry.js | 358 ++++++++++++++++++++++ utils/searchPerformanceTelemetry.test.js | 201 ++++++++++++ 10 files changed, 681 insertions(+), 19 deletions(-) create mode 100644 utils/searchPerformanceTelemetry.js create mode 100644 utils/searchPerformanceTelemetry.test.js diff --git a/.env.example b/.env.example index a6ac790..82a3cbd 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,8 @@ OpenSearchDomainEndpoint= SearchOpenSearchRequestTimeoutMs=20000 SearchRequestDeadlineMs=30000 SearchReadinessTimeoutMs=5000 +SearchLogPerformanceTimings=false +SearchPerformanceTelemetryIntervalMs=10000 RequestLogSuccessSampleRate=0.1 RequestLogHealthChecks=false WebSearchEnabled=true diff --git a/README.md b/README.md index eb51bd5..de2d1d1 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ Rename `.env.example` to `.env` and replace or check the following values. - `SearchOpenSearchRequestTimeoutMs` - optional timeout for each OpenSearch request, default `20000` - `SearchRequestDeadlineMs` - optional overall `/search` processing deadline, default `30000` - `SearchReadinessTimeoutMs` - optional timeout for the OpenSearch index readiness check, default `5000` +- `SearchLogPerformanceTimings` - optional aggregate `/search` performance telemetry flag, default `false` +- `SearchPerformanceTelemetryIntervalMs` - optional telemetry aggregation interval, default `10000` and minimum `1000` - `RequestLogSuccessSampleRate` - optional fraction of successful non-health-check access logs to retain, default `0.1` - `RequestLogHealthChecks` - optional flag to retain successful health-check access logs, default `false` - `WebSearchEnabled` - optional `/webSearch` availability flag, default `true` @@ -272,6 +274,12 @@ Every request receives an `X-Request-ID` response header. A caller-provided `X-R Access logs omit query strings and request bodies. By default, the API retains 10% of successful non-health-check access logs, suppresses successful `/api/health-check` and `/api/ready-check` access logs, and retains every HTTP 4xx or 5xx access log. Set `RequestLogSuccessSampleRate` from `0` through `1` to change successful-request sampling. Set `RequestLogHealthChecks=true` to retain successful health-check logs. +Set `SearchLogPerformanceTimings=true` during controlled performance diagnostics to emit one structured `search_performance_summary` JSON event per active aggregation interval. The interval defaults to 10 seconds and can be changed with `SearchPerformanceTelemetryIntervalMs`; values below 1 second fall back to the default. Telemetry is disabled by default and does not record request bodies, filter values, credentials, or returned records. + +Each event reports process memory and CPU, event-loop delay and utilization, searches started and completed during the interval, searches still active at its end, maximum in-flight searches, and measurements grouped by search type. Starts and completions are separate because a search can cross an interval boundary. `processCpu.percentOfOneCore` measures the process's CPU time relative to one fully occupied core and can exceed 100 when native work uses multiple cores. Direct searches report `standardOpenSearchMs`. Semantic searches report `candidateOpenSearchMs`, `candidateCount`, `postFilterMs`, `matchedCandidateCount`, and `pageFetchOpenSearchMs` when a page fetch is required. The corresponding `standardOpenSearchTookMs`, `candidateOpenSearchTookMs`, and `pageFetchOpenSearchTookMs` measurements contain OpenSearch's server-reported `took` values, allowing them to be compared with API wall-clock durations. Every completed search reports `totalRequestMs` and an outcome such as `success`, `timeout`, `client_disconnected`, `opensearch_unavailable`, or `upstream_error`. Each measurement includes its count, sampled count, average, minimum, p50, p95, p99, and maximum for the interval. Average, minimum, and maximum use every observation; percentile calculation uses a bounded reservoir of at most 2,000 observations per measurement and interval. + +Use these fields together when diagnosing load behavior. If an OpenSearch wall-clock measurement and its corresponding `TookMs` measurement rise together, execution or queueing inside OpenSearch is contributing to latency. If wall time rises while `TookMs` remains stable, transport or application-side waiting is contributing. Growth in `postFilterMs` and event-loop delay indicates application-side semantic processing pressure; growth in `activeRequests` with stage timings stable indicates waiting elsewhere in the request lifecycle. Summaries describe one Node process, so aggregate events from every ECS task log stream before comparing `startedRequests` with the external load generator's delivered request count. Correlate each event's timestamps with load-balancer, ECS, and OpenSearch metrics, then disable telemetry after the controlled run. + Each `/search` request owns a lifecycle cancellation signal. When its overall deadline expires, the API aborts the active OpenSearch transport request before returning HTTP 504. When the caller disconnects, the API aborts work that is needed only by that caller and does not attempt to write a response. This prevents timed-out or abandoned standard searches from continuing until the transport timeout. ### Semantic Post-Filtering @@ -287,7 +295,7 @@ The reported total and pagination are complete only within that bounded candidat `/search` orders OpenSearch results by CVE ID so repeated shallow-page requests use a deterministic sort. The response `pageSize` preserves the requested `resultsPerPage` value, while `resultsReturned` reports the number of records actually returned and may be smaller for a partial or out-of-range page. A stable sort does not provide a point-in-time snapshot, so index updates between separate page requests can still shift results until PIT-based pagination is implemented. -The API does not cache, coalesce, queue, or rate-limit searches in process. Each request executes its own required OpenSearch operations. Public rate enforcement, admission control, shared caching, and performance telemetry must be supplied by the deployment infrastructure when those capabilities are enabled. +The API does not cache, coalesce, queue, or rate-limit searches in process. Each request executes its own required OpenSearch operations. Public rate enforcement, admission control, shared caching, and durable metrics collection must be supplied by the deployment infrastructure. The optional aggregate telemetry described above writes diagnostic events to standard output for collection by the deployment logging system. OpenSearch operations use one attempt and return a sanitized HTTP 504 `SEARCH_TIMEOUT` response when they exceed the 20-second default timeout. The same response is returned when a `/search` request exceeds the 30-second default overall deadline, and the active OpenSearch request is aborted. diff --git a/config/custom-environment-variables.jsonc b/config/custom-environment-variables.jsonc index 4eb1457..61a7e76 100644 --- a/config/custom-environment-variables.jsonc +++ b/config/custom-environment-variables.jsonc @@ -13,7 +13,9 @@ "allowUnknownSslCerts": "OpenSearchAllowUnknownSslCerts", "openSearchRequestTimeoutMs": "SearchOpenSearchRequestTimeoutMs", "requestDeadlineMs": "SearchRequestDeadlineMs", - "readinessTimeoutMs": "SearchReadinessTimeoutMs" + "readinessTimeoutMs": "SearchReadinessTimeoutMs", + "performanceTelemetryEnabled": "SearchLogPerformanceTimings", + "performanceTelemetryIntervalMs": "SearchPerformanceTelemetryIntervalMs" }, "requestLogging": { "successSampleRate": "RequestLogSuccessSampleRate", diff --git a/config/default.jsonc b/config/default.jsonc index 45d6a0a..4870cb2 100644 --- a/config/default.jsonc +++ b/config/default.jsonc @@ -17,7 +17,11 @@ // Limits total processing time for a /search request. "requestDeadlineMs": "30000", // Limits the minimal OpenSearch request used by /api/ready-check. - "readinessTimeoutMs": "5000" + "readinessTimeoutMs": "5000", + // Emits aggregate search-stage timing summaries for controlled diagnostics. + "performanceTelemetryEnabled": "FALSE", + // Controls how often enabled performance summaries are emitted. + "performanceTelemetryIntervalMs": "10000" }, // Access logging settings. Error responses are always logged. "requestLogging": { diff --git a/config/runtime-config.test.js b/config/runtime-config.test.js index c94173e..214b632 100644 --- a/config/runtime-config.test.js +++ b/config/runtime-config.test.js @@ -91,6 +91,10 @@ describe('runtime container and compose configuration', () => { ); expect(customEnvironmentVariables).toContain('"requestDeadlineMs": "SearchRequestDeadlineMs"'); expect(customEnvironmentVariables).toContain('"readinessTimeoutMs": "SearchReadinessTimeoutMs"'); + expect(customEnvironmentVariables).toContain('"performanceTelemetryEnabled": "SearchLogPerformanceTimings"'); + expect(customEnvironmentVariables).toContain( + '"performanceTelemetryIntervalMs": "SearchPerformanceTelemetryIntervalMs"' + ); expect(customEnvironmentVariables).toContain('"successSampleRate": "RequestLogSuccessSampleRate"'); expect(customEnvironmentVariables).toContain('"healthChecks": "RequestLogHealthChecks"'); expect(customEnvironmentVariables).toContain('"webSearchEnabled": "WebSearchEnabled"'); diff --git a/controllers/searchController.js b/controllers/searchController.js index 6cd002c..c31fa77 100644 --- a/controllers/searchController.js +++ b/controllers/searchController.js @@ -4,6 +4,7 @@ const searchDeadline = require('../utils/searchDeadline'); const searchProvider = require('../utils/searchProvider'); const searchQueryBuilder = require('../utils/searchQueryBuilder'); const searchPostFilters = require('../utils/searchPostFilters'); +const searchPerformanceTelemetry = require('../utils/searchPerformanceTelemetry'); const error = new errors.errors(); const openSearchUnavailableErrorNames = new Set(['ConnectionError', 'NoLivingConnectionsError']); const openSearchUnavailableStatusCodes = new Set([502, 503, 504]); @@ -119,9 +120,10 @@ async function executeOpenSearch(body, signal) { return searchProvider.searchOpenSearch(body, { signal }); } -async function executeStandardSearch(searchOptions, queryStringObjects, signal) { +async function executeStandardSearch(searchOptions, queryStringObjects, signal, telemetry) { const body = searchQueryBuilder.buildOpenSearchBody(searchOptions, queryStringObjects); - const searchData = await executeOpenSearch(body, signal); + const searchData = await telemetry.measureAsync('standardOpenSearchMs', () => executeOpenSearch(body, signal)); + telemetry.recordValue('standardOpenSearchTookMs', searchData?.took); return { status: 'ok', @@ -129,12 +131,13 @@ async function executeStandardSearch(searchOptions, queryStringObjects, signal) }; } -async function fetchFullHitsByCveIds(cveIds, signal) { +async function fetchFullHitsByCveIds(cveIds, signal, telemetry) { if (!cveIds.length) return { hits: [], took: undefined }; const { searchOptions, queryStringObjects } = searchQueryBuilder.buildCveIdsSearchRequest(cveIds); const body = searchQueryBuilder.buildOpenSearchBody(searchOptions, queryStringObjects); - const pageData = await executeOpenSearch(body, signal); + const pageData = await telemetry.measureAsync('pageFetchOpenSearchMs', () => executeOpenSearch(body, signal)); + telemetry.recordValue('pageFetchOpenSearchTookMs', pageData?.took); return { hits: orderHitsByCveIds(pageData?.hits?.hits ?? [], cveIds), @@ -142,13 +145,20 @@ async function fetchFullHitsByCveIds(cveIds, signal) { }; } -async function buildPostFilterCandidateResult(candidateBody, postFilters, signal) { - const candidateData = await executeOpenSearch(candidateBody, signal); +async function buildPostFilterCandidateResult(candidateBody, postFilters, signal, telemetry) { + const candidateData = await telemetry.measureAsync('candidateOpenSearchMs', () => + executeOpenSearch(candidateBody, signal) + ); + telemetry.recordValue('candidateOpenSearchTookMs', candidateData?.took); const candidateResp = { data: candidateData }; - const { filteredHits } = searchPostFilters.filterSearchHits(candidateResp, postFilters); + const { candidateCount, filteredHits } = telemetry.measureSync('postFilterMs', () => + searchPostFilters.filterSearchHits(candidateResp, postFilters) + ); + telemetry.recordValue('candidateCount', candidateCount); + telemetry.recordValue('matchedCandidateCount', filteredHits.length); return { filteredCount: filteredHits.length, @@ -157,18 +167,25 @@ async function buildPostFilterCandidateResult(candidateBody, postFilters, signal }; } -async function executePostFilterSearch(searchOptions, queryStringObjects, postFilters, responseOptions, signal) { +async function executePostFilterSearch( + searchOptions, + queryStringObjects, + postFilters, + responseOptions, + signal, + telemetry +) { const candidateBody = searchQueryBuilder.buildPostFilterCandidateSearchBody( searchOptions, queryStringObjects, postFilters ); - const candidateResult = await buildPostFilterCandidateResult(candidateBody, postFilters, signal); + const candidateResult = await buildPostFilterCandidateResult(candidateBody, postFilters, signal, telemetry); searchDeadline.throwIfAborted(signal); const pageCveIds = paginateHits(candidateResult.filteredCveIds, responseOptions); const filteredCount = candidateResult.filteredCount ?? candidateResult.filteredCveIds.length; - const pageResult = await fetchFullHitsByCveIds(pageCveIds, signal); + const pageResult = await fetchFullHitsByCveIds(pageCveIds, signal, telemetry); const response = { status: 'ok', data: { @@ -185,12 +202,12 @@ async function executePostFilterSearch(searchOptions, queryStringObjects, postFi return buildPagedSearchResponse(response, filteredCount, pageResult.hits); } -async function executeSearch(searchOptions, queryStringObjects, postFilters, responseOptions, signal) { +async function executeSearch(searchOptions, queryStringObjects, postFilters, responseOptions, signal, telemetry) { if (!searchPostFilters.hasSearchPostFilters(postFilters)) { - return executeStandardSearch(searchOptions, queryStringObjects, signal); + return executeStandardSearch(searchOptions, queryStringObjects, signal, telemetry); } - return executePostFilterSearch(searchOptions, queryStringObjects, postFilters, responseOptions, signal); + return executePostFilterSearch(searchOptions, queryStringObjects, postFilters, responseOptions, signal, telemetry); } async function searchHandler(req, res, next) { @@ -200,19 +217,23 @@ async function searchHandler(req, res, next) { res, searchProvider.getSearchRequestDeadlineMs() ); + const telemetry = searchPerformanceTelemetry.startSearchTelemetry(); + let telemetryOutcome = 'success'; try { const searchRequest = searchQueryBuilder.buildSearchRequest(params); const searchOptions = searchRequest.searchOptions; const postFilters = searchRequest.postFilters; const responseOptions = searchRequest.responseOptions; + telemetry.setSearchType(postFilters); const resp = await executeSearch( searchOptions, searchRequest.queryStringObjects, postFilters, responseOptions, - lifecycle.signal + lifecycle.signal, + telemetry ); lifecycle.throwIfAborted(); const pagingOptions = responseOptions ?? searchOptions; @@ -233,21 +254,26 @@ async function searchHandler(req, res, next) { }); } catch (err) { if (searchDeadline.isSearchClientDisconnectedError(err)) { + telemetryOutcome = 'client_disconnected'; return; } if (searchDeadline.isSearchTimeoutError(err)) { + telemetryOutcome = 'timeout'; return res.status(504).json(error.searchTimeoutError()); } if (isOpenSearchUnavailableError(err)) { + telemetryOutcome = 'opensearch_unavailable'; if (typeof res.set === 'function') res.set('Retry-After', '5'); return res.status(503).json(error.openSearchError()); } + telemetryOutcome = 'upstream_error'; return res.status(502).json(error.upstreamSearchError()); } finally { + telemetry.complete(telemetryOutcome); lifecycle.complete(); } } diff --git a/controllers/searchController.test.js b/controllers/searchController.test.js index f5da845..d4b1e32 100644 --- a/controllers/searchController.test.js +++ b/controllers/searchController.test.js @@ -13,6 +13,14 @@ const mockIsSearchProviderContractError = jest.fn((err) => err?.code === 'SEARCH const mockGetBasicSearchManager = jest.fn(() => ({ search: mockBasicSearch })); +const mockSearchTelemetry = { + complete: jest.fn(), + measureAsync: jest.fn((_name, operation) => operation()), + measureSync: jest.fn((_name, operation) => operation()), + recordValue: jest.fn(), + setSearchType: jest.fn() +}; +const mockStartSearchTelemetry = jest.fn(() => mockSearchTelemetry); jest.mock('cve-core', () => { return { @@ -29,6 +37,10 @@ jest.mock('../utils/searchProvider', () => ({ searchOpenSearch: mockSearchOpenSearch })); +jest.mock('../utils/searchPerformanceTelemetry', () => ({ + startSearchTelemetry: mockStartSearchTelemetry +})); + const controller = require('./searchController.js'); function createResponse() { @@ -134,6 +146,10 @@ describe('Search controller SEARCH handler', () => { mockGetSearchRequestDeadlineMs.mockClear(); mockIsSearchProviderContractError.mockClear(); mockGetSearchRequestDeadlineMs.mockReturnValue(30000); + mockStartSearchTelemetry.mockClear(); + Object.values(mockSearchTelemetry).forEach((mock) => mock.mockClear()); + mockSearchTelemetry.measureAsync.mockImplementation((_name, operation) => operation()); + mockSearchTelemetry.measureSync.mockImplementation((_name, operation) => operation()); res = createResponse(); next = jest.fn(); @@ -141,6 +157,20 @@ describe('Search controller SEARCH handler', () => { afterEach(() => jest.restoreAllMocks()); + test('SEARCH records standard OpenSearch and total request telemetry', async () => { + const response = advancedResponse(0); + response.data.took = 12; + mockApiSearch.mockResolvedValue(response); + + await controller.SEARCH({ query: {} }, res, next); + + expect(mockStartSearchTelemetry).toHaveBeenCalledTimes(1); + expect(mockSearchTelemetry.setSearchType).toHaveBeenCalledWith(undefined); + expect(mockSearchTelemetry.measureAsync).toHaveBeenCalledWith('standardOpenSearchMs', expect.any(Function)); + expect(mockSearchTelemetry.recordValue).toHaveBeenCalledWith('standardOpenSearchTookMs', 12); + expect(mockSearchTelemetry.complete).toHaveBeenCalledWith('success'); + }); + test('SEARCH builds filters, query strings, date ranges, and pagination options', async () => { const resp = advancedResponse(25, [{ _id: 'CVE-2024-0001' }]); mockApiSearch.mockResolvedValue(resp); @@ -476,8 +506,12 @@ describe('Search controller SEARCH handler', () => { } }; - mockSearchRaw.mockResolvedValue(rawResponse(2, [applicable, notApplicable])); - mockApiSearch.mockResolvedValue(advancedResponse(1, [fullApplicable])); + const candidateResponse = rawResponse(2, [applicable, notApplicable]); + candidateResponse.took = 18; + const pageResponse = advancedResponse(1, [fullApplicable]); + pageResponse.data.took = 4; + mockSearchRaw.mockResolvedValue(candidateResponse); + mockApiSearch.mockResolvedValue(pageResponse); await controller.SEARCH( { @@ -528,6 +562,7 @@ describe('Search controller SEARCH handler', () => { totalPages: 1, status: 'ok', data: { + took: 22, hits: { total: { value: 1 @@ -536,6 +571,21 @@ describe('Search controller SEARCH handler', () => { } } }); + expect(mockSearchTelemetry.setSearchType).toHaveBeenCalledWith( + expect.objectContaining({ + cpeName: expect.any(Object) + }) + ); + expect(mockSearchTelemetry.measureAsync.mock.calls.map(([name]) => name)).toEqual([ + 'candidateOpenSearchMs', + 'pageFetchOpenSearchMs' + ]); + expect(mockSearchTelemetry.measureSync).toHaveBeenCalledWith('postFilterMs', expect.any(Function)); + expect(mockSearchTelemetry.recordValue).toHaveBeenCalledWith('candidateCount', 2); + expect(mockSearchTelemetry.recordValue).toHaveBeenCalledWith('matchedCandidateCount', 1); + expect(mockSearchTelemetry.recordValue).toHaveBeenCalledWith('candidateOpenSearchTookMs', 18); + expect(mockSearchTelemetry.recordValue).toHaveBeenCalledWith('pageFetchOpenSearchTookMs', 4); + expect(mockSearchTelemetry.complete).toHaveBeenCalledWith('success'); }); test('SEARCH semantically filters SSVC candidates and fetches full records for the requested page', async () => { @@ -1109,6 +1159,7 @@ describe('Search controller SEARCH handler', () => { message: 'OpenSearch refused connection, please check that OpenSearch is running and accessible then try again.' }); + expect(mockSearchTelemetry.complete).toHaveBeenCalledWith('opensearch_unavailable'); }); test('SEARCH returns a sanitized 504 when OpenSearch times out', async () => { @@ -1131,6 +1182,7 @@ describe('Search controller SEARCH handler', () => { error: 'SEARCH_TIMEOUT', message: 'Search request timed out. Please try again later.' }); + expect(mockSearchTelemetry.complete).toHaveBeenCalledWith('timeout'); }); test('SEARCH returns a sanitized 504 when the overall request deadline is exceeded', async () => { @@ -1181,6 +1233,7 @@ describe('Search controller SEARCH handler', () => { expect(res.status).not.toHaveBeenCalled(); expect(res.json).not.toHaveBeenCalled(); + expect(mockSearchTelemetry.complete).toHaveBeenCalledWith('client_disconnected'); }); test('SEARCH does not start a page fetch after a client disconnects from a CPE search', async () => { diff --git a/utils/constants.js b/utils/constants.js index a86aecc..3e400ef 100644 --- a/utils/constants.js +++ b/utils/constants.js @@ -8,6 +8,8 @@ const MAX_CVE_IDS = 100; const MAX_CVE_ID_LENGTH = 64; const MAX_KEYWORD_SEARCH_TOKENS = 64; const MIN_REFERENCE_URL_LENGTH = 3; +const SEARCH_PERFORMANCE_TELEMETRY_INTERVAL_MS = 10000; +const SEARCH_PERFORMANCE_TELEMETRY_MIN_INTERVAL_MS = 1000; const searchScope = require('./searchScope'); const SEARCH_STRING_LENGTH_LIMITS = Object.freeze({ keywordSearch: 512, @@ -130,6 +132,8 @@ module.exports = { MAX_CVE_ID_LENGTH, MAX_KEYWORD_SEARCH_TOKENS, MIN_REFERENCE_URL_LENGTH, + SEARCH_PERFORMANCE_TELEMETRY_INTERVAL_MS, + SEARCH_PERFORMANCE_TELEMETRY_MIN_INTERVAL_MS, SEARCH_STRING_LENGTH_LIMITS, SEARCH_OPEN_SEARCH_REQUEST_TIMEOUT_MS, SEARCH_REQUEST_DEADLINE_MS, diff --git a/utils/searchPerformanceTelemetry.js b/utils/searchPerformanceTelemetry.js new file mode 100644 index 0000000..b00d537 --- /dev/null +++ b/utils/searchPerformanceTelemetry.js @@ -0,0 +1,358 @@ +const { monitorEventLoopDelay, performance } = require('perf_hooks'); +const { AppConfig } = require('cve-core'); +const constants = require('./constants'); + +const TELEMETRY_EVENT = 'search_performance_summary'; +const MAX_SAMPLES_PER_DISTRIBUTION = 2000; +const TRACKED_MEASUREMENTS = new Set([ + 'candidateCount', + 'candidateOpenSearchMs', + 'candidateOpenSearchTookMs', + 'matchedCandidateCount', + 'pageFetchOpenSearchMs', + 'pageFetchOpenSearchTookMs', + 'postFilterMs', + 'standardOpenSearchMs', + 'standardOpenSearchTookMs', + 'totalRequestMs' +]); +const SEARCH_TYPES = Object.freeze(['cpeName', 'virtualMatchString', 'virtualMatchStringRange', 'cvss', 'ssvc', 'kev']); + +let telemetryConfig; +let telemetryState; +let writeTelemetry = (event) => process.stdout.write(`${JSON.stringify(event)}\n`); + +function normalizeBoolean(value, defaultValue = false) { + if (value === undefined || value === null || value === '') return defaultValue; + if (typeof value === 'boolean') return value; + if (typeof value === 'number') return value !== 0; + + const normalized = String(value).trim().toLowerCase(); + if (['true', '1', 'yes', 'y', 'on'].includes(normalized)) return true; + if (['false', '0', 'no', 'n', 'off'].includes(normalized)) return false; + + return defaultValue; +} + +function readInteger(path, defaultValue, min) { + if (!AppConfig.has(path)) return defaultValue; + + const value = Number(AppConfig.get(path)); + return Number.isInteger(value) && value >= min ? value : defaultValue; +} + +function getTelemetryConfig() { + if (!telemetryConfig) { + const enabled = AppConfig.has('search.performanceTelemetryEnabled') + ? normalizeBoolean(AppConfig.get('search.performanceTelemetryEnabled')) + : false; + const intervalMs = readInteger( + 'search.performanceTelemetryIntervalMs', + constants.SEARCH_PERFORMANCE_TELEMETRY_INTERVAL_MS, + constants.SEARCH_PERFORMANCE_TELEMETRY_MIN_INTERVAL_MS + ); + + telemetryConfig = Object.freeze({ enabled, intervalMs }); + } + + return telemetryConfig; +} + +function createDistribution() { + return { + count: 0, + max: Number.NEGATIVE_INFINITY, + min: Number.POSITIVE_INFINITY, + samples: [], + total: 0 + }; +} + +function recordDistribution(distribution, value) { + if (!Number.isFinite(value) || value < 0) return; + + distribution.count += 1; + distribution.max = Math.max(distribution.max, value); + distribution.min = Math.min(distribution.min, value); + distribution.total += value; + + if (distribution.samples.length < MAX_SAMPLES_PER_DISTRIBUTION) { + distribution.samples.push(value); + return; + } + + const replacementIndex = Math.floor(Math.random() * distribution.count); + if (replacementIndex < MAX_SAMPLES_PER_DISTRIBUTION) { + distribution.samples[replacementIndex] = value; + } +} + +function round(value) { + return Math.round(value * 1000) / 1000; +} + +function percentile(sortedValues, percentileValue) { + if (!sortedValues.length) return undefined; + + const index = Math.max(0, Math.ceil((percentileValue / 100) * sortedValues.length) - 1); + return sortedValues[index]; +} + +function summarizeDistribution(distribution) { + if (!distribution?.count) return undefined; + + const sortedValues = [...distribution.samples].sort((left, right) => left - right); + + return { + count: distribution.count, + sampledCount: sortedValues.length, + avg: round(distribution.total / distribution.count), + min: round(distribution.min), + p50: round(percentile(sortedValues, 50)), + p95: round(percentile(sortedValues, 95)), + p99: round(percentile(sortedValues, 99)), + max: round(distribution.max) + }; +} + +function createSearchBucket(searchType) { + return { + outcomes: {}, + requests: 0, + searchType, + measurements: new Map() + }; +} + +function recordSearch(bucket, outcome, measurements) { + bucket.requests += 1; + bucket.outcomes[outcome] = (bucket.outcomes[outcome] ?? 0) + 1; + + Object.entries(measurements).forEach(([name, value]) => { + if (!TRACKED_MEASUREMENTS.has(name)) return; + + if (!bucket.measurements.has(name)) { + bucket.measurements.set(name, createDistribution()); + } + recordDistribution(bucket.measurements.get(name), value); + }); +} + +function summarizeBucket(bucket) { + return { + searchType: bucket.searchType, + requests: bucket.requests, + outcomes: bucket.outcomes, + measurements: Object.fromEntries( + [...bucket.measurements.entries()].map(([name, distribution]) => [ + name, + summarizeDistribution(distribution) + ]) + ) + }; +} + +function eventLoopNanosecondsToMilliseconds(value) { + return Number.isFinite(value) ? round(value / 1e6) : null; +} + +function createTelemetryState(intervalMs) { + const eventLoopDelay = monitorEventLoopDelay({ resolution: 20 }); + eventLoopDelay.enable(); + + const state = { + activeRequests: 0, + buckets: new Map(), + eventLoopDelay, + eventLoopUtilization: performance.eventLoopUtilization(), + intervalCpuUsage: process.cpuUsage(), + intervalMs, + intervalStartedAt: Date.now(), + maxActiveRequests: 0, + startedRequests: 0 + }; + state.timer = setInterval(() => { + try { + flushTelemetry(); + } catch (_err) { + // Diagnostics must never affect search availability. + } + }, intervalMs); + state.timer.unref?.(); + + return state; +} + +function ensureTelemetryState() { + if (!telemetryState) { + telemetryState = createTelemetryState(getTelemetryConfig().intervalMs); + } + + return telemetryState; +} + +function buildEventLoopSummary(histogram) { + return { + meanMs: eventLoopNanosecondsToMilliseconds(histogram.mean), + p50Ms: eventLoopNanosecondsToMilliseconds(histogram.percentile(50)), + p95Ms: eventLoopNanosecondsToMilliseconds(histogram.percentile(95)), + p99Ms: eventLoopNanosecondsToMilliseconds(histogram.percentile(99)), + maxMs: eventLoopNanosecondsToMilliseconds(histogram.max) + }; +} + +function flushTelemetry() { + const state = telemetryState; + if (!state) return undefined; + + const intervalEndedAt = Date.now(); + const searches = [...state.buckets.values()].map(summarizeBucket); + const completedRequests = searches.reduce((total, search) => total + search.requests, 0); + const shouldWrite = state.startedRequests > 0 || state.activeRequests > 0 || searches.length > 0; + const memory = process.memoryUsage(); + const cpuUsage = process.cpuUsage(state.intervalCpuUsage); + const eventLoopUtilization = performance.eventLoopUtilization(state.eventLoopUtilization); + const intervalElapsedMs = intervalEndedAt - state.intervalStartedAt; + const cpuTotalMs = (cpuUsage.user + cpuUsage.system) / 1000; + const event = { + event: TELEMETRY_EVENT, + version: 1, + intervalStartedAt: new Date(state.intervalStartedAt).toISOString(), + intervalEndedAt: new Date(intervalEndedAt).toISOString(), + intervalElapsedMs, + startedRequests: state.startedRequests, + completedRequests, + activeRequests: state.activeRequests, + maxActiveRequests: state.maxActiveRequests, + eventLoopDelay: buildEventLoopSummary(state.eventLoopDelay), + eventLoopUtilization: round(eventLoopUtilization.utilization), + processCpu: { + userMs: round(cpuUsage.user / 1000), + systemMs: round(cpuUsage.system / 1000), + totalMs: round(cpuTotalMs), + percentOfOneCore: intervalElapsedMs > 0 ? round((cpuTotalMs / intervalElapsedMs) * 100) : null + }, + memoryBytes: { + rss: memory.rss, + heapUsed: memory.heapUsed, + heapTotal: memory.heapTotal + }, + searches + }; + + state.buckets = new Map(); + state.eventLoopUtilization = performance.eventLoopUtilization(); + state.intervalCpuUsage = process.cpuUsage(); + state.intervalStartedAt = intervalEndedAt; + state.maxActiveRequests = state.activeRequests; + state.startedRequests = 0; + state.eventLoopDelay.reset(); + + if (shouldWrite) { + try { + writeTelemetry(event); + } catch (_err) { + // Diagnostics must never affect search availability. + } + } + + return event; +} + +function classifySearch(postFilters) { + const types = SEARCH_TYPES.filter((type) => Boolean(postFilters?.[type])); + return types.length ? types.join('+') : 'standard'; +} + +const disabledTracker = Object.freeze({ + complete() {}, + measureAsync(_name, operation) { + return operation(); + }, + measureSync(_name, operation) { + return operation(); + }, + recordValue() {}, + setSearchType() {} +}); + +function startSearchTelemetry() { + try { + if (!getTelemetryConfig().enabled) return disabledTracker; + + const state = ensureTelemetryState(); + const startedAt = performance.now(); + const measurements = {}; + let completed = false; + let searchType = 'unclassified'; + + state.activeRequests += 1; + state.startedRequests += 1; + state.maxActiveRequests = Math.max(state.maxActiveRequests, state.activeRequests); + + return { + async measureAsync(name, operation) { + const stageStartedAt = performance.now(); + try { + return await operation(); + } finally { + measurements[name] = (measurements[name] ?? 0) + (performance.now() - stageStartedAt); + } + }, + measureSync(name, operation) { + const stageStartedAt = performance.now(); + try { + return operation(); + } finally { + measurements[name] = (measurements[name] ?? 0) + (performance.now() - stageStartedAt); + } + }, + recordValue(name, value) { + measurements[name] = value; + }, + setSearchType(postFilters) { + searchType = classifySearch(postFilters); + }, + complete(outcome = 'success') { + if (completed) return; + + completed = true; + measurements.totalRequestMs = performance.now() - startedAt; + state.activeRequests = Math.max(0, state.activeRequests - 1); + + try { + if (!state.buckets.has(searchType)) { + state.buckets.set(searchType, createSearchBucket(searchType)); + } + recordSearch(state.buckets.get(searchType), outcome, measurements); + } catch (_err) { + // Diagnostics must never affect search availability. + } + } + }; + } catch (_err) { + return disabledTracker; + } +} + +function resetForTests() { + if (telemetryState?.timer) clearInterval(telemetryState.timer); + telemetryState?.eventLoopDelay?.disable(); + telemetryConfig = undefined; + telemetryState = undefined; + writeTelemetry = (event) => process.stdout.write(`${JSON.stringify(event)}\n`); +} + +function setOutputForTests(output) { + writeTelemetry = output; +} + +module.exports = { + classifySearch, + flushTelemetry, + getTelemetryConfig, + resetForTests, + setOutputForTests, + startSearchTelemetry, + TELEMETRY_EVENT +}; diff --git a/utils/searchPerformanceTelemetry.test.js b/utils/searchPerformanceTelemetry.test.js new file mode 100644 index 0000000..aabdf5d --- /dev/null +++ b/utils/searchPerformanceTelemetry.test.js @@ -0,0 +1,201 @@ +const mockAppConfigHas = jest.fn(); +const mockAppConfigGet = jest.fn(); + +jest.mock('cve-core', () => ({ + AppConfig: { + get: mockAppConfigGet, + has: mockAppConfigHas + } +})); + +const telemetry = require('./searchPerformanceTelemetry'); +const constants = require('./constants'); + +function configure(values = {}) { + mockAppConfigHas.mockImplementation((path) => Object.prototype.hasOwnProperty.call(values, path)); + mockAppConfigGet.mockImplementation((path) => values[path]); +} + +describe('searchPerformanceTelemetry', () => { + beforeEach(() => { + telemetry.resetForTests(); + mockAppConfigHas.mockReset(); + mockAppConfigGet.mockReset(); + configure(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + telemetry.resetForTests(); + }); + + test('is disabled by default and preserves wrapped operation behavior', async () => { + const operation = jest.fn(() => 'result'); + const tracker = telemetry.startSearchTelemetry(); + + expect(tracker.measureSync('postFilterMs', operation)).toBe('result'); + await expect(tracker.measureAsync('standardOpenSearchMs', async () => 'async result')).resolves.toBe( + 'async result' + ); + expect(() => tracker.complete()).not.toThrow(); + expect(telemetry.flushTelemetry()).toBeUndefined(); + expect(operation).toHaveBeenCalledTimes(1); + }); + + test('normalizes enabled and interval configuration with safe defaults', () => { + configure({ + 'search.performanceTelemetryEnabled': 'TRUE', + 'search.performanceTelemetryIntervalMs': '2500' + }); + + expect(telemetry.getTelemetryConfig()).toStrictEqual({ + enabled: true, + intervalMs: 2500 + }); + + telemetry.resetForTests(); + configure({ + 'search.performanceTelemetryEnabled': 'not-a-boolean', + 'search.performanceTelemetryIntervalMs': '10' + }); + + expect(telemetry.getTelemetryConfig()).toStrictEqual({ + enabled: false, + intervalMs: constants.SEARCH_PERFORMANCE_TELEMETRY_INTERVAL_MS + }); + }); + + test('classifies standard, individual, and combined semantic searches without filter values', () => { + expect(telemetry.classifySearch()).toBe('standard'); + expect(telemetry.classifySearch({ cpeName: { version: '1.0' } })).toBe('cpeName'); + expect( + telemetry.classifySearch({ + cpeName: { version: '1.0' }, + cvss: { vector: 'AV:N' }, + ignored: { sensitive: 'value' } + }) + ).toBe('cpeName+cvss'); + }); + + test('emits aggregate stage measurements without request inputs', async () => { + configure({ + 'search.performanceTelemetryEnabled': true, + 'search.performanceTelemetryIntervalMs': 60000 + }); + const output = jest.fn(); + telemetry.setOutputForTests(output); + const tracker = telemetry.startSearchTelemetry(); + + tracker.setSearchType({ cpeName: { version: 'sensitive-version' } }); + await tracker.measureAsync('candidateOpenSearchMs', async () => 'candidate response'); + tracker.measureSync('postFilterMs', () => 'filtered response'); + tracker.recordValue('candidateCount', 10000); + tracker.recordValue('matchedCandidateCount', 25); + tracker.recordValue('untrackedSensitiveValue', 123); + tracker.complete('success'); + + const event = telemetry.flushTelemetry(); + const summary = event.searches[0]; + + expect(output).toHaveBeenCalledWith(event); + expect(event).toMatchObject({ + event: telemetry.TELEMETRY_EVENT, + version: 1, + startedRequests: 1, + completedRequests: 1, + activeRequests: 0, + maxActiveRequests: 1, + eventLoopUtilization: expect.any(Number), + processCpu: { + userMs: expect.any(Number), + systemMs: expect.any(Number), + totalMs: expect.any(Number) + } + }); + expect(event.processCpu.percentOfOneCore === null || Number.isFinite(event.processCpu.percentOfOneCore)).toBe( + true + ); + expect(summary).toMatchObject({ + searchType: 'cpeName', + requests: 1, + outcomes: { success: 1 } + }); + expect(summary.measurements.candidateOpenSearchMs.count).toBe(1); + expect(summary.measurements.postFilterMs.count).toBe(1); + expect(summary.measurements.candidateCount.avg).toBe(10000); + expect(summary.measurements.matchedCandidateCount.avg).toBe(25); + expect(summary.measurements.totalRequestMs.count).toBe(1); + expect(summary.measurements.untrackedSensitiveValue).toBeUndefined(); + expect(JSON.stringify(event)).not.toContain('sensitive-version'); + }); + + test('reports in-flight pressure across interval boundaries and completes each request once', () => { + configure({ + 'search.performanceTelemetryEnabled': true, + 'search.performanceTelemetryIntervalMs': 60000 + }); + telemetry.setOutputForTests(jest.fn()); + const first = telemetry.startSearchTelemetry(); + const second = telemetry.startSearchTelemetry(); + + first.setSearchType(); + first.complete('timeout'); + first.complete('success'); + + const firstEvent = telemetry.flushTelemetry(); + expect(firstEvent).toMatchObject({ + startedRequests: 2, + completedRequests: 1, + activeRequests: 1, + maxActiveRequests: 2 + }); + expect(firstEvent.searches[0]).toMatchObject({ + requests: 1, + outcomes: { timeout: 1 } + }); + + second.setSearchType({ virtualMatchString: {} }); + second.complete('client_disconnected'); + const secondEvent = telemetry.flushTelemetry(); + + expect(secondEvent.activeRequests).toBe(0); + expect(secondEvent.completedRequests).toBe(1); + expect(secondEvent.searches[0]).toMatchObject({ + searchType: 'virtualMatchString', + requests: 1, + outcomes: { client_disconnected: 1 } + }); + }); + + test('bounds percentile samples while retaining exact observation counts', () => { + configure({ + 'search.performanceTelemetryEnabled': true, + 'search.performanceTelemetryIntervalMs': 60000 + }); + telemetry.setOutputForTests(jest.fn()); + jest.spyOn(Math, 'random').mockReturnValue(0); + + for (let index = 0; index < 2001; index += 1) { + telemetry.startSearchTelemetry().complete('success'); + } + + const summary = telemetry.flushTelemetry().searches[0].measurements.totalRequestMs; + expect(summary.count).toBe(2001); + expect(summary.sampledCount).toBe(2000); + }); + + test('does not let telemetry output failures affect request processing', () => { + configure({ + 'search.performanceTelemetryEnabled': true, + 'search.performanceTelemetryIntervalMs': 60000 + }); + telemetry.setOutputForTests(() => { + throw new Error('logging unavailable'); + }); + const tracker = telemetry.startSearchTelemetry(); + + tracker.complete('success'); + + expect(() => telemetry.flushTelemetry()).not.toThrow(); + }); +}); From 16e21ef2d1cedef968fb897ae61723cc1fbd4a06 Mon Sep 17 00:00:00 2001 From: Andrew Foote Date: Tue, 8 Sep 2026 10:07:42 -0400 Subject: [PATCH 2/2] fix: remediate dependency security alerts --- package-lock.json | 73 ++++++++++++++++++++++++----------------------- package.json | 2 +- 2 files changed, 38 insertions(+), 37 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6c4a65c..5c88041 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1749,9 +1749,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.40", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", - "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", + "version": "2.11.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1877,9 +1877,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", - "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", "dev": true, "funding": [ { @@ -1897,11 +1897,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.38", - "caniuse-lite": "^1.0.30001799", - "electron-to-chromium": "^1.5.376", - "node-releases": "^2.0.48", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" }, "bin": { "browserslist": "cli.js" @@ -1986,9 +1986,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001799", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", - "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "dev": true, "funding": [ { @@ -2419,9 +2419,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.381", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.381.tgz", - "integrity": "sha512-n9Wa6yB+vDsGuA8AKbl/0z7HbvWqt5jxIdvr1IUicd0ryPrk7/xzwqLv8D9AbbvZ6avVNtXYLTfmgFHkwkyelg==", + "version": "1.5.423", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.423.tgz", + "integrity": "sha512-rRZfTSY8ptHYMQxa+uIycJMFKmY1T0GIApNMXJYGehguTZa56TEEl19pKPCoBqk5Gpf7QizZn/jt7xur+DYxag==", "dev": true, "license": "ISC" }, @@ -4873,9 +4873,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.50", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", - "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", "dev": true, "license": "MIT", "engines": { @@ -5369,12 +5369,13 @@ } }, "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -5709,14 +5710,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -5728,13 +5729,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -6277,9 +6278,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "dev": true, "funding": [ { diff --git a/package.json b/package.json index 1027f78..1280649 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "lodash.unset": "4.18.0", "path-to-regexp": "0.1.13", "picomatch": "2.3.2", - "qs": "6.15.2", + "qs": "6.16.0", "simple-git": "3.36.0" } }