From cdcbf74df4a8d38aa5ee2054b21e18e66d212752 Mon Sep 17 00:00:00 2001 From: Vasco Santos Date: Tue, 15 Feb 2022 14:28:59 +0100 Subject: [PATCH] feat: gateway tracking requested cids in database --- packages/gateway/package.json | 1 + .../src/durable-objects/summary-metrics.js | 220 +++++++++++++++--- packages/gateway/src/gateway.js | 97 +++++--- packages/gateway/src/metrics.js | 55 +++++ packages/gateway/src/utils/pins.js | 15 ++ packages/gateway/test/metrics.spec.js | 20 +- yarn.lock | 18 +- 7 files changed, 356 insertions(+), 70 deletions(-) create mode 100644 packages/gateway/src/utils/pins.js diff --git a/packages/gateway/package.json b/packages/gateway/package.json index e11d5d87da..19fdbaee4f 100644 --- a/packages/gateway/package.json +++ b/packages/gateway/package.json @@ -22,6 +22,7 @@ "itty-router": "^2.4.5", "multiformats": "^9.5.2", "nanoid": "^3.1.30", + "nft.storage": "^6.0.0", "p-any": "^4.0.0", "p-map": "^5.3.0", "p-some": "^5.0.0", diff --git a/packages/gateway/src/durable-objects/summary-metrics.js b/packages/gateway/src/durable-objects/summary-metrics.js index 3528892d21..0349201d96 100644 --- a/packages/gateway/src/durable-objects/summary-metrics.js +++ b/packages/gateway/src/durable-objects/summary-metrics.js @@ -1,22 +1,33 @@ +import { NFTStorage } from 'nft.storage' +import { PinStatusMap } from '../utils/pins.js' import { responseTimeHistogram, createResponseTimeHistogramObject, } from '../utils/histogram.js' /** + * @typedef {'Stored'|'NotStored'} ContentStatus + * @typedef {import('../utils/pins').PinStatus} PinStatus + * * @typedef {Object} SummaryMetrics * @property {number} totalWinnerResponseTime total response time of the requests * @property {number} totalWinnerSuccessfulRequests total number of successful requests * @property {number} totalCachedResponseTime total response time to forward cached responses * @property {number} totalCachedResponses total number of cached responses + * @property {number} totalErroredResponsesWithKnownContent total responses errored with content in NFT.storage * @property {BigInt} totalContentLengthBytes total content length of responses * @property {BigInt} totalCachedContentLengthBytes total content length of cached responses + * @property {Record} totalResponsesByContentStatus + * @property {Record} totalResponsesByPinStatus * @property {Record} contentLengthHistogram * @property {Record} responseTimeHistogram + * @property {Record>} responseTimeHistogramByPinStatus * * @typedef {Object} FetchStats - * @property {number} responseTime number of milliseconds to get response - * @property {number} contentLength content length header content + * @property {string} cid fetched CID + * @property {boolean} errored fetched CID request errored + * @property {number} [responseTime] number of milliseconds to get response + * @property {number} [contentLength] content length header content */ // Key to track total time for winner gateway to respond @@ -27,6 +38,9 @@ const TOTAL_WINNER_SUCCESSFUL_REQUESTS_ID = 'totalWinnerSuccessfulRequests' const TOTAL_CACHED_RESPONSE_TIME_ID = 'totalCachedResponseTime' // Key to track total cached requests const TOTAL_CACHED_RESPONSES_ID = 'totalCachedResponses' +// Key to track total errored requests with known content +const TOTAL_ERRORED_RESPONSES_WITH_KNOWN_CONTENT_ID = + 'totalErroredResponsesWithKnownContent' // Key to track total content length of responses const TOTAL_CONTENT_LENGTH_BYTES_ID = 'totalContentLengthBytes' // Key to track total cached content length of responses @@ -35,6 +49,13 @@ const TOTAL_CACHED_CONTENT_LENGTH_BYTES_ID = 'totalCachedContentLengthBytes' const CONTENT_LENGTH_HISTOGRAM_ID = 'contentLengthHistogram' // Key to track response time histogram const RESPONSE_TIME_HISTOGRAM_ID = 'responseTimeHistogram' +// Key to track response time histogram by pin status +const RESPONSE_TIME_HISTOGRAM_BY_PIN_STATUS_ID = + 'responseTimeHistogramByPinStatus' +// Key to track responses by content status +const TOTAL_RESPONSES_BY_CONTENT_STATUS_ID = 'totalResponsesByContentStatus' +// Key to track responses by pin status +const TOTAL_RESPONSES_BY_PIN_STATUS_ID = 'totalResponsesByPinStatus' /** * Durable Object for keeping summary metrics of gateway.nft.storage @@ -42,6 +63,8 @@ const RESPONSE_TIME_HISTOGRAM_ID = 'responseTimeHistogram' export class SummaryMetrics0 { constructor(state) { this.state = state + // @ts-ignore we don't need token just for check + this.nftStorageClient = new NFTStorage({}) // `blockConcurrencyWhile()` ensures no requests are delivered until initialization completes. this.state.blockConcurrencyWhile(async () => { @@ -57,22 +80,40 @@ export class SummaryMetrics0 { // Total cached requests this.totalCachedResponses = (await this.state.storage.get(TOTAL_CACHED_RESPONSES_ID)) || 0 - // Total content length responses + // Total errored requests with known content + this.totalErroredResponsesWithKnownContent = + (await this.state.storage.get( + TOTAL_ERRORED_RESPONSES_WITH_KNOWN_CONTENT_ID + )) || 0 + /** @type {BigInt} */ this.totalContentLengthBytes = (await this.state.storage.get(TOTAL_CONTENT_LENGTH_BYTES_ID)) || BigInt(0) - // Total cached content length responses + /** @type {BigInt} */ this.totalCachedContentLengthBytes = (await this.state.storage.get(TOTAL_CACHED_CONTENT_LENGTH_BYTES_ID)) || BigInt(0) - // Content length histogram + /** @type {Record} */ + this.totalResponsesByContentStatus = + (await this.state.storage.get(TOTAL_RESPONSES_BY_CONTENT_STATUS_ID)) || + createResponsesByContentStatusObject() + /** @type {Record} */ + this.totalResponsesByPinStatus = + (await this.state.storage.get(TOTAL_RESPONSES_BY_PIN_STATUS_ID)) || + createResponsesByPinStatusObject() + /** @type {Record} */ this.contentLengthHistogram = (await this.state.storage.get(CONTENT_LENGTH_HISTOGRAM_ID)) || createContentLengthHistogramObject() - // Response time histogram + /** @type {Record} */ this.responseTimeHistogram = (await this.state.storage.get(RESPONSE_TIME_HISTOGRAM_ID)) || createResponseTimeHistogramObject() + /** @type {Record>} */ + this.responseTimeHistogramByPinStatus = + (await this.state.storage.get( + RESPONSE_TIME_HISTOGRAM_BY_PIN_STATUS_ID + )) || createResponseTimeHistogramByPinStatusObject() }) } @@ -91,11 +132,17 @@ export class SummaryMetrics0 { totalWinnerSuccessfulRequests: this.totalWinnerSuccessfulRequests, totalCachedResponseTime: this.totalCachedResponseTime, totalCachedResponses: this.totalCachedResponses, + totalErroredResponsesWithKnownContent: + this.totalErroredResponsesWithKnownContent, totalContentLengthBytes: this.totalContentLengthBytes.toString(), totalCachedContentLengthBytes: this.totalCachedContentLengthBytes.toString(), + totalResponsesByContentStatus: this.totalResponsesByContentStatus, + totalResponsesByPinStatus: this.totalResponsesByPinStatus, contentLengthHistogram: this.contentLengthHistogram, responseTimeHistogram: this.responseTimeHistogram, + responseTimeHistogramByPinStatus: + this.responseTimeHistogramByPinStatus, }) ) default: @@ -106,15 +153,90 @@ export class SummaryMetrics0 { // POST /** @type {FetchStats} */ const data = await request.json() + await Promise.all([ + this._updateStatsMetrics(data, url), + this._updateCidMetrics(data), + ]) + + return new Response() + } + + /** + * @param {FetchStats} data + */ + async _updateCidMetrics({ cid, errored, responseTime }) { + try { + const statusRes = await this.nftStorageClient.check(cid) + + if (errored) { + this.totalErroredResponsesWithKnownContent += 1 + + await this.state.storage.put( + TOTAL_ERRORED_RESPONSES_WITH_KNOWN_CONTENT_ID, + this.totalErroredResponsesWithKnownContent + ) + } else { + this.totalResponsesByContentStatus['Stored'] += 1 + + const pinStatus = PinStatusMap[statusRes.pin?.status] + if (pinStatus) { + this.totalResponsesByPinStatus[pinStatus] += 1 + this.responseTimeHistogramByPinStatus[pinStatus] = + getUpdatedHistogram( + this.responseTimeHistogramByPinStatus[pinStatus], + responseTimeHistogram, + responseTime + ) + } + + await Promise.all([ + this.state.storage.put( + TOTAL_RESPONSES_BY_CONTENT_STATUS_ID, + this.totalResponsesByContentStatus + ), + pinStatus && + this.state.storage.put( + TOTAL_RESPONSES_BY_PIN_STATUS_ID, + this.totalResponsesByPinStatus + ), + this.state.storage.put( + RESPONSE_TIME_HISTOGRAM_BY_PIN_STATUS_ID, + this.responseTimeHistogramByPinStatus + ), + ]) + } + } catch (err) { + if (err.message === 'NFT not found') { + // Update not existing CID + this.totalResponsesByContentStatus['NotStored'] += 1 + + await this.state.storage.put( + TOTAL_RESPONSES_BY_CONTENT_STATUS_ID, + this.totalResponsesByContentStatus + ) + } + } + } + + /** + * @param {FetchStats} stats + * @param {URL} url + */ + async _updateStatsMetrics(stats, url) { + // Errored does not have winner/cache metrics to update + if (stats.errored) { + return + } + switch (url.pathname) { case '/metrics/winner': - await this._updateWinnerMetrics(data) - return new Response() + await this._updateWinnerMetrics(stats) + break case '/metrics/cache': - await this._updatedCacheMetrics(data) - return new Response() + await this._updatedCacheMetrics(stats) + break default: - return new Response('Not found', { status: 404 }) + throw new Error('Not found') } } @@ -196,42 +318,65 @@ export class SummaryMetrics0 { */ _updateContentLengthMetrics(stats) { this.totalContentLengthBytes += BigInt(stats.contentLength) - - // Update histogram - const tmpHistogram = { - ...this.contentLengthHistogram, - } - - // Get all the histogram buckets where the content size is smaller - const histogramCandidates = contentLengthHistogram.filter( - (h) => stats.contentLength < h + this.contentLengthHistogram = getUpdatedHistogram( + this.contentLengthHistogram, + contentLengthHistogram, + stats.contentLength ) - histogramCandidates.forEach((candidate) => { - tmpHistogram[candidate] += 1 - }) - - this.contentLengthHistogram = tmpHistogram } /** * @param {FetchStats} stats */ _updateResponseTimeHistogram(stats) { - const tmpHistogram = { - ...this.responseTimeHistogram, - } - - // Get all the histogram buckets where the response time is smaller - const histogramCandidates = responseTimeHistogram.filter( - (h) => stats.responseTime < h + this.responseTimeHistogram = getUpdatedHistogram( + this.responseTimeHistogram, + responseTimeHistogram, + stats.responseTime ) + } +} - histogramCandidates.forEach((candidate) => { - tmpHistogram[candidate] += 1 +function getUpdatedHistogram(histogramData, histogramBuckets, value) { + const updatedHistogram = { + ...histogramData, + } + // Update all the histogram buckets where the response time is smaller + histogramBuckets + .filter((h) => value < h) + .forEach((candidate) => { + updatedHistogram[candidate] += 1 }) - this.responseTimeHistogram = tmpHistogram - } + return updatedHistogram +} + +/** + * @return {Record} + */ +function createResponsesByPinStatusObject() { + const e = Object.values(PinStatusMap).map((t) => [t, 0]) + return Object.fromEntries(e) +} + +/** + * @return {Record} + */ +function createResponsesByContentStatusObject() { + const e = contentStatus.map((t) => [t, 0]) + return Object.fromEntries(e) +} + +/** + * @return {Record>} + */ +function createResponseTimeHistogramByPinStatusObject() { + const pinStatusEntry = Object.values(PinStatusMap).map((t) => [ + t, + createResponseTimeHistogramObject(), + ]) + + return Object.fromEntries(pinStatusEntry) } function createContentLengthHistogramObject() { @@ -239,6 +384,9 @@ function createContentLengthHistogramObject() { return Object.fromEntries(h) } +// Either CID is stored in NFT.storage or not +export const contentStatus = ['Stored', 'NotStored'] + // We will count occurences per bucket where content size is less or equal than bucket value export const contentLengthHistogram = [ 0.5, 1, 2, 5, 25, 50, 100, 500, 1000, 5000, 10000, 15000, 20000, 30000, 32000, diff --git a/packages/gateway/src/gateway.js b/packages/gateway/src/gateway.js index adb66fc3cb..d6f3a32d3b 100644 --- a/packages/gateway/src/gateway.js +++ b/packages/gateway/src/gateway.js @@ -37,6 +37,9 @@ import { */ export async function gatewayGet(request, env, ctx) { const startTs = Date.now() + const reqUrl = new URL(request.url) + const cid = getCidFromSubdomainUrl(reqUrl) + const cache = caches.default let res = await cache.match(request.url) @@ -44,14 +47,13 @@ export async function gatewayGet(request, env, ctx) { // Update cache metrics in background const responseTime = Date.now() - startTs - ctx.waitUntil(updateSummaryCacheMetrics(request, env, res, responseTime)) + ctx.waitUntil( + updateSummaryCacheMetrics(request, env, res, responseTime, cid) + ) return res } - const reqUrl = new URL(request.url) - const cid = getCidFromSubdomainUrl(reqUrl) const pathname = reqUrl.pathname - const gatewayReqs = env.ipfsGateways.map((gwUrl) => gatewayFetch(gwUrl, cid, request, env, { pathname, @@ -88,7 +90,7 @@ export async function gatewayGet(request, env, ctx) { ) await Promise.all([ - storeWinnerGwResponse(request, env, winnerGwResponse), + storeWinnerGwResponse(request, env, winnerGwResponse, cid), settleGatewayRequests(), // Cache request URL in Cloudflare CDN if smaller than CF_CACHE_MAX_OBJECT_SIZE contentLengthMb <= CF_CACHE_MAX_OBJECT_SIZE && @@ -100,6 +102,8 @@ export async function gatewayGet(request, env, ctx) { // forward winner gateway response return winnerGwResponse.response } catch (err) { + let candidateResponse, + wasTimeout = false const responses = await pSettle(gatewayReqs) // Redirect if all failed with rate limited error @@ -109,6 +113,20 @@ export async function gatewayGet(request, env, ctx) { r.value?.reason === REQUEST_PREVENTED_RATE_LIMIT_CODE ) + // Return the error response from gateway, error is not from nft.storage Gateway + if (err instanceof FilterError || err instanceof AggregateError) { + candidateResponse = responses.find((r) => r.value?.response) + + // Gateway timeout + if ( + !candidateResponse && + responses[0].value?.aborted && + responses[0].value?.reason == TIMEOUT_CODE + ) { + wasTimeout = true + } + } + ctx.waitUntil( (async () => { // Update metrics as all requests failed @@ -117,7 +135,13 @@ export async function gatewayGet(request, env, ctx) { updateGatewayMetrics(request, env, r.value, false) ) ) - wasRateLimited && updateGatewayRedirectCounter(request, env) + // Update errored if candidate response + candidateResponse && + (await updateSummaryWinnerMetrics(request, env, cid, { + errored: true, + })) + // Update redirect counter + wasRateLimited && (await updateGatewayRedirectCounter(request, env)) })() ) @@ -126,24 +150,17 @@ export async function gatewayGet(request, env, ctx) { return Response.redirect(`${ipfsUrl.toString()}/${cid}${pathname}`, 302) } - // Return the error response from gateway, error is not from nft.storage Gateway - if (err instanceof FilterError || err instanceof AggregateError) { - const candidateResponse = responses.find((r) => r.value?.response) - - // Return first response with upstream error - if (candidateResponse) { - return candidateResponse.value?.response - } + // Return first response with upstream error + if (candidateResponse) { + return candidateResponse.value?.response + } - // Gateway timeout - if ( - responses[0].value?.aborted && - responses[0].value?.reason == TIMEOUT_CODE - ) { - throw new TimeoutError() - } + // Return timeout error if timeout + if (wasTimeout) { + throw new TimeoutError() } + // Throw server error throw err } } @@ -153,12 +170,13 @@ export async function gatewayGet(request, env, ctx) { * * @param {Request} request * @param {Env} env - * @param {GatewayResponse} winnerGwResponse + * @param {GatewayResponse} gwResponse + * @param {string} cid */ -async function storeWinnerGwResponse(request, env, winnerGwResponse) { +async function storeWinnerGwResponse(request, env, gwResponse, cid) { await Promise.all([ - updateGatewayMetrics(request, env, winnerGwResponse, true), - updateSummaryWinnerMetrics(request, env, winnerGwResponse), + updateGatewayMetrics(request, env, gwResponse, true), + updateSummaryWinnerMetrics(request, env, cid, { gwResponse }), ]) } @@ -245,14 +263,23 @@ function getHeaders(request) { * @param {import('./env').Env} env * @param {Response} response * @param {number} responseTime + * @param {string} cid */ -async function updateSummaryCacheMetrics(request, env, response, responseTime) { +async function updateSummaryCacheMetrics( + request, + env, + response, + responseTime, + cid +) { // Get durable object for gateway const id = env.summaryMetricsDurable.idFromName(SUMMARY_METRICS_ID) const stub = env.summaryMetricsDurable.get(id) /** @type {import('./durable-objects/summary-metrics').FetchStats} */ const contentLengthStats = { + cid, + errored: false, contentLength: Number(response.headers.get('content-length')), responseTime, } @@ -295,17 +322,27 @@ async function getGatewayRateLimitState(request, env, gwUrl) { /** * @param {Request} request * @param {import('./env').Env} env - * @param {GatewayResponse} gwResponse + * @param {string} cid + * @param {Object} options + * @param {GatewayResponse} [options.gwResponse] + * @param {boolean} [options.errored] */ -async function updateSummaryWinnerMetrics(request, env, gwResponse) { +async function updateSummaryWinnerMetrics( + request, + env, + cid, + { gwResponse, errored = false } +) { // Get durable object for gateway const id = env.summaryMetricsDurable.idFromName(SUMMARY_METRICS_ID) const stub = env.summaryMetricsDurable.get(id) /** @type {import('./durable-objects/summary-metrics').FetchStats} */ const fetchStats = { - responseTime: gwResponse.responseTime, - contentLength: Number(gwResponse.response.headers.get('content-length')), + cid, + errored, + responseTime: gwResponse?.responseTime, + contentLength: Number(gwResponse?.response.headers.get('content-length')), } await stub.fetch(getDurableRequestUrl(request, 'metrics/winner', fetchStats)) diff --git a/packages/gateway/src/metrics.js b/packages/gateway/src/metrics.js index 5d4d180415..9632979b04 100644 --- a/packages/gateway/src/metrics.js +++ b/packages/gateway/src/metrics.js @@ -9,6 +9,7 @@ import { HTTP_STATUS_SUCCESS, } from './constants.js' import { responseTimeHistogram } from './utils/histogram.js' +import { PinStatusMap } from './utils/pins.js' import { contentLengthHistogram } from './durable-objects/summary-metrics.js' /** @@ -267,6 +268,60 @@ export async function metricsGet(request, env, ctx) { `# HELP nftgateway_redirect_total Total redirects to gateway.`, `# TYPE nftgateway_redirect_total counter`, `nftgateway_redirect_total{env="${env.ENV}"} ${metricsCollected.gatewayRedirectCount}`, + `# HELP nftgateway_errored_responses_with_known_content_total errored requests with known content in NFT.storage.`, + `# TYPE nftgateway_errored_responses_with_known_content_total counter`, + `nftgateway_errored_responses_with_known_content_total{env="${env.ENV}"} ${metricsCollected.summaryMetrics.totalErroredResponsesWithKnownContent}`, + `# HELP nftgateway_responses_by_content_status_total total of responses by content status. Either stored or not stored.`, + `# TYPE nftgateway_responses_by_content_status_total counter`, + Object.keys(metricsCollected.summaryMetrics.totalResponsesByContentStatus) + .map( + (status) => + `nftgateway_responses_by_content_status_total{env="${ + env.ENV + }",status="${status}"} ${ + metricsCollected.summaryMetrics.totalResponsesByContentStatus[ + status + ] || 0 + }` + ) + .join('\n'), + `# HELP nftgateway_responses_by_pin_status_total total of responses by pin status.`, + `# TYPE nftgateway_responses_by_pin_status_total counter`, + Object.keys(metricsCollected.summaryMetrics.totalResponsesByPinStatus) + .map( + (status) => + `nftgateway_responses_by_pin_status_total{env="${ + env.ENV + }",status="${status}"} ${ + metricsCollected.summaryMetrics.totalResponsesByPinStatus[status] || + 0 + }` + ) + .join('\n'), + `# HELP nftgateway_responses_per_time_by_status_total total of responses by status per response time bucket`, + `# TYPE nftgateway_responses_per_time_by_status_total histogram`, + ...responseTimeHistogram.map((t) => { + return Object.values(PinStatusMap) + .map( + (status) => + `nftgateway_responses_per_time_by_status_total{status="${status}",le="${msToS( + t + )}",env="${env.ENV}"} ${ + metricsCollected.summaryMetrics.responseTimeHistogramByPinStatus[ + status + ][t] + }` + ) + .join('\n') + }), + ...Object.values(PinStatusMap).map( + (status) => + `nftgateway_responses_per_time_by_status_total{status="${status}",le="+Inf",env="${ + env.ENV + }"} ${ + metricsCollected.summaryMetrics.totalResponsesByPinStatus[status] || 0 + }` + ), ].join('\n') res = new Response(metrics, { diff --git a/packages/gateway/src/utils/pins.js b/packages/gateway/src/utils/pins.js new file mode 100644 index 0000000000..80902b3a08 --- /dev/null +++ b/packages/gateway/src/utils/pins.js @@ -0,0 +1,15 @@ +/** + * @typedef { + * | 'PinError' + * | 'Pinned' + * | 'Pinning' + * | 'PinQueued'} PinStatus + */ + +/** @type {Record} */ +export const PinStatusMap = { + pin_error: 'PinError', + pinned: 'Pinned', + pinning: 'Pinning', + pin_queued: 'PinQueued', +} diff --git a/packages/gateway/test/metrics.spec.js b/packages/gateway/test/metrics.spec.js index 1bf0814408..cb6fe2e7cd 100644 --- a/packages/gateway/test/metrics.spec.js +++ b/packages/gateway/test/metrics.spec.js @@ -38,9 +38,23 @@ test('Gets Metrics content when empty state', async (t) => { ) t.is(metricsResponse.includes(`_responses_content_length_total{le=`), true) t.is( - metricsResponse.includes( - `_responses_content_length_bytes_total{env="test"} 0` - ), + metricsResponse.includes(`_responses_by_pin_status_total{env="test"`), + true + ) + t.is( + metricsResponse.includes(`_responses_by_content_status_total{env="test"`), + true + ) + t.is( + metricsResponse.includes('_errored_responses_with_known_content_total'), + true + ) + t.is( + metricsResponse.includes('nftgateway_winner_response_time_seconds_total'), + true + ) + t.is( + metricsResponse.includes('nftgateway_winner_response_time_seconds_total'), true ) gateways.forEach((gw) => { diff --git a/yarn.lock b/yarn.lock index 0859a125c1..a2d324995e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10056,6 +10056,23 @@ nft.storage@^3.3.0: p-retry "^4.6.1" streaming-iterables "^6.0.0" +nft.storage@^5.1.0: + version "5.2.5" + resolved "https://registry.yarnpkg.com/nft.storage/-/nft.storage-5.2.5.tgz#55c9f1b3cd40654cfeae006c3a4abb2da238049f" + integrity sha512-ITP9ETleKIZvSRsjpHi6JFFE/YVcp9jwwyi+Q1GtdNmFc5Xyu1g7it+yrk7m5+iSEMcOjSJGPQrktnUTe0qtAg== + dependencies: + "@ipld/car" "^3.2.3" + "@ipld/dag-cbor" "^6.0.13" + "@web-std/blob" "^3.0.1" + "@web-std/fetch" "^3.0.3" + "@web-std/file" "^3.0.0" + "@web-std/form-data" "^3.0.0" + carbites "^1.0.6" + ipfs-car "^0.6.2" + multiformats "^9.6.3" + p-retry "^4.6.1" + streaming-iterables "^6.0.0" + nice-try@^1.0.4: version "1.0.5" resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" @@ -10088,7 +10105,6 @@ node-fetch@^1.0.1: "node-fetch@https://registry.npmjs.org/@achingbrain/node-fetch/-/node-fetch-2.6.7.tgz": version "2.6.7" - uid "1b5d62978f2ed07b99444f64f0df39f960a6d34d" resolved "https://registry.npmjs.org/@achingbrain/node-fetch/-/node-fetch-2.6.7.tgz#1b5d62978f2ed07b99444f64f0df39f960a6d34d" node-forge@^1.2.0: