From 9c448b93c35c6a04224c47830bd2a88301083925 Mon Sep 17 00:00:00 2001 From: Mochamad Noor Syamsu Date: Wed, 29 May 2024 21:34:47 +0700 Subject: [PATCH 01/12] implement cache http response --- src/components/probe/prober/http/index.ts | 36 +++++++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/src/components/probe/prober/http/index.ts b/src/components/probe/prober/http/index.ts index 0d0541208..598e0ea39 100644 --- a/src/components/probe/prober/http/index.ts +++ b/src/components/probe/prober/http/index.ts @@ -22,6 +22,7 @@ * SOFTWARE. * **********************************************************************************/ +import { createHash } from 'node:crypto' import { BaseProber, NotificationType, type ProbeParams } from '..' import { getContext } from '../../../../context' import events from '../../../../events' @@ -40,6 +41,7 @@ import { addIncident } from '../../../incident' import { saveProbeRequestLog } from '../../../logger/history' import { logResponseTime } from '../../../logger/response-time-log' import { httpRequest } from './request' +import { getCache, putCache } from './response-cache' type ProbeResultMessageParams = { request: RequestConfig @@ -53,13 +55,41 @@ export class HTTPProber extends BaseProber { const responses: ProbeRequestResponse[] = [] for (const requestConfig of requests) { - responses.push( + // current request may be attempting to retry because of triggered alert + // if this is NOT first attempt, do real http request + if (incidentRetryAttempt > 0) { + responses.push( + // eslint-disable-next-line no-await-in-loop + await httpRequest({ + requestConfig: { ...requestConfig, signal }, + responses, + }) + ) + + // immediately process next request + continue + } + + // if this is first attempt, use cache where possible + const hashRequest = createHash('SHA1') + .update(JSON.stringify(requestConfig)) + .digest('hex') + + const cache = getCache(hashRequest) + let response: ProbeRequestResponse + if (cache) { + response = cache + } else { // eslint-disable-next-line no-await-in-loop - await httpRequest({ + response = await httpRequest({ requestConfig: { ...requestConfig, signal }, responses, }) - ) + + putCache(hashRequest, response) + } + + responses.push(response) } const hasFailedRequest = responses.find( From e6fe6f906afd83da6565ee0ffd13a3ab0717e34e Mon Sep 17 00:00:00 2001 From: Mochamad Noor Syamsu Date: Wed, 29 May 2024 21:37:14 +0700 Subject: [PATCH 02/12] forgot to stage file --- .../probe/prober/http/response-cache.ts | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 src/components/probe/prober/http/response-cache.ts diff --git a/src/components/probe/prober/http/response-cache.ts b/src/components/probe/prober/http/response-cache.ts new file mode 100644 index 000000000..adf9a799b --- /dev/null +++ b/src/components/probe/prober/http/response-cache.ts @@ -0,0 +1,97 @@ +/********************************************************************************** + * MIT License * + * * + * Copyright (c) 2021 Hyperjump Technology * + * * + * Permission is hereby granted, free of charge, to any person obtaining a copy * + * of this software and associated documentation files (the "Software"), to deal * + * in the Software without restriction, including without limitation the rights * + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * + * copies of the Software, and to permit persons to whom the Software is * + * furnished to do so, subject to the following conditions: * + * * + * The above copyright notice and this permission notice shall be included in all * + * copies or substantial portions of the Software. * + * * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * + * SOFTWARE. * + **********************************************************************************/ + +import { getContext } from '../../../../context' +import { ProbeRequestResponse } from 'src/interfaces/request' +import { log } from '../../../../utils/pino' +import { serialize } from 'v8' +/** + * Simple implementation for caching Monika HTTP responses. + * With default total cache size limited to 50 MB. + * And default time-to-live for each cache entries is 30s. + * + * About cache size 50 MB limit : + * Assuming a typical web page response is around 500 KB + * This cache can fit around 100 entries of web pages + * A typical response with empty body is around 300 bytes of headers + * That means, this cache can fit around 160K entries of empty body responses + */ +const DEFAULT_CACHE_LIMIT = 50_000_000 // 50 MB in bytes +const DEFAULT_TIME_TO_LIVE = 30_000 // 30s in ms +const responseCache = new Map< + string, + { expireAt: number; response: ProbeRequestResponse } +>() + +// ensureCacheSize ensures total size of cache is under DEFAULT_CACHE_LIMIT +function ensureCacheSize() { + const totalCacheSize = serialize(responseCache).byteLength + const firstKey = responseCache.keys().next().value + if (totalCacheSize > DEFAULT_CACHE_LIMIT && firstKey) { + responseCache.delete(firstKey) + // recursive until cache size is under limit + ensureCacheSize() + } +} + +// ensureCacheTtl ensures cache entries are within valid time-to-live +// this will delete already expired cache entries +function ensureCacheTtl() { + const now = Date.now() + for (const [key, { expireAt }] of responseCache.entries()) { + if (expireAt <= now) { + responseCache.delete(key) + } else { + // next items have valid time-to-live + // break out of loop to save time + break + } + } +} + +function put(key: string, value: ProbeRequestResponse) { + const expireAt = Date.now() + DEFAULT_TIME_TO_LIVE + responseCache.set(key, { expireAt, response: value }) + // after put into cache, ensure total cache size is under limit + ensureCacheSize() +} + +function get(key: string) { + // remove expired entries before actually getting cache + ensureCacheTtl() + const response = responseCache.get(key)?.response + const isVerbose = getContext().flags.verbose + const shortHash = key.slice(Math.max(0, key.length - 7)) + if (isVerbose && response) { + const time = new Date().toISOString() + log.info(`${time} - [${shortHash}] Cache HIT`) + } else if (isVerbose) { + const time = new Date().toISOString() + log.info(`${time} - [${shortHash}] Cache MISS`) + } + + return response +} + +export { responseCache, put as putCache, get as getCache } From 4c7010fc924a98f42faeb17cc3c8d3f6ae826dfd Mon Sep 17 00:00:00 2001 From: Mochamad Noor Syamsu Date: Wed, 29 May 2024 22:16:43 +0700 Subject: [PATCH 03/12] disable on tests --- src/components/probe/prober/http/response-cache.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/probe/prober/http/response-cache.ts b/src/components/probe/prober/http/response-cache.ts index adf9a799b..49494a8f4 100644 --- a/src/components/probe/prober/http/response-cache.ts +++ b/src/components/probe/prober/http/response-cache.ts @@ -71,6 +71,7 @@ function ensureCacheTtl() { } function put(key: string, value: ProbeRequestResponse) { + if (getContext().isTest) return const expireAt = Date.now() + DEFAULT_TIME_TO_LIVE responseCache.set(key, { expireAt, response: value }) // after put into cache, ensure total cache size is under limit From d754d0302c1616f08e6d59071f161b84838a9961 Mon Sep 17 00:00:00 2001 From: Mochamad Noor Syamsu Date: Wed, 29 May 2024 22:51:47 +0700 Subject: [PATCH 04/12] move to httpRequest --- docs/src/pages/guides/probes.md | 4 ++ src/components/probe/prober/http/index.ts | 37 ++----------- src/components/probe/prober/http/request.ts | 57 +++++++++++++++------ 3 files changed, 48 insertions(+), 50 deletions(-) diff --git a/docs/src/pages/guides/probes.md b/docs/src/pages/guides/probes.md index 9e96a23e2..e63eda94e 100644 --- a/docs/src/pages/guides/probes.md +++ b/docs/src/pages/guides/probes.md @@ -75,6 +75,10 @@ Details of the field are given in the table below. | allowUnauthorized (optional) | (boolean), If set to true, will make https agent to not check for ssl certificate validity | | followRedirects (optional) | The request follows redirects as many times as specified here. If unspecified, it will fallback to the value set by the [follow redirects flag](https://monika.hyperjump.tech/guides/cli-options#follow-redirects) | +### Good to know + +To reduce network usage, HTTP responses are cached with 30s time-to-live by default. + ## Request Body By default, the request body will be treated as-is. If the request header's `Content-Type` is set to `application/x-www-form-urlencoded`, it will be serialized into URL-safe string in UTF-8 encoding. Body payloads will vary on the specific probes being requested. For HTTP requests, the body and headers are defined like this: diff --git a/src/components/probe/prober/http/index.ts b/src/components/probe/prober/http/index.ts index 598e0ea39..4272973a5 100644 --- a/src/components/probe/prober/http/index.ts +++ b/src/components/probe/prober/http/index.ts @@ -22,7 +22,6 @@ * SOFTWARE. * **********************************************************************************/ -import { createHash } from 'node:crypto' import { BaseProber, NotificationType, type ProbeParams } from '..' import { getContext } from '../../../../context' import events from '../../../../events' @@ -41,7 +40,6 @@ import { addIncident } from '../../../incident' import { saveProbeRequestLog } from '../../../logger/history' import { logResponseTime } from '../../../logger/response-time-log' import { httpRequest } from './request' -import { getCache, putCache } from './response-cache' type ProbeResultMessageParams = { request: RequestConfig @@ -55,41 +53,14 @@ export class HTTPProber extends BaseProber { const responses: ProbeRequestResponse[] = [] for (const requestConfig of requests) { - // current request may be attempting to retry because of triggered alert - // if this is NOT first attempt, do real http request - if (incidentRetryAttempt > 0) { - responses.push( - // eslint-disable-next-line no-await-in-loop - await httpRequest({ - requestConfig: { ...requestConfig, signal }, - responses, - }) - ) - - // immediately process next request - continue - } - - // if this is first attempt, use cache where possible - const hashRequest = createHash('SHA1') - .update(JSON.stringify(requestConfig)) - .digest('hex') - - const cache = getCache(hashRequest) - let response: ProbeRequestResponse - if (cache) { - response = cache - } else { + responses.push( // eslint-disable-next-line no-await-in-loop - response = await httpRequest({ + await httpRequest({ + isRetrying: incidentRetryAttempt > 0, requestConfig: { ...requestConfig, signal }, responses, }) - - putCache(hashRequest, response) - } - - responses.push(response) + ) } const hasFailedRequest = responses.find( diff --git a/src/components/probe/prober/http/request.ts b/src/components/probe/prober/http/request.ts index dc506bfab..858f84fd5 100644 --- a/src/components/probe/prober/http/request.ts +++ b/src/components/probe/prober/http/request.ts @@ -41,6 +41,8 @@ import { sendHttpRequest, sendHttpRequestFetch } from '../../../../utils/http' import { log } from '../../../../utils/pino' import { AxiosError } from 'axios' import { getErrorMessage } from '../../../../utils/catch-error-handler' +import { createHash } from 'crypto' +import { getCache, putCache } from './response-cache' // Register Handlebars helpers registerFakes(Handlebars) @@ -48,6 +50,7 @@ registerFakes(Handlebars) type probingParams = { requestConfig: Omit // is a config object responses: Array // an array of previous responses + isRetrying?: boolean } const UndiciErrorValidator = Joi.object({ @@ -62,6 +65,7 @@ const UndiciErrorValidator = Joi.object({ export async function httpRequest({ requestConfig, responses, + isRetrying = false, }: probingParams): Promise { // Compile URL using handlebars to render URLs that uses previous responses data const { @@ -102,24 +106,43 @@ export async function httpRequest({ requestHeaders.set(key, value) } - // Do the request using compiled URL and compiled headers (if exists) - if (getContext().flags['native-fetch']) { - return await probeHttpFetch({ - startTime, - maxRedirects: followRedirects, - renderedURL, - requestParams: { ...newReq, headers: requestHeaders }, - allowUnauthorized, - }) + const hashRequest = createHash('SHA1') + .update( + JSON.stringify({ + maxRedirects: followRedirects, + renderedURL, + requestParams: { ...newReq, headers: requestHeaders }, + allowUnauthorized, + }) + ) + .digest('hex') + + // this request may be attempting to retry triggered by alerts + // use cache only if this not a retry + if (!isRetrying) { + const cache = getCache(hashRequest) + if (cache) return cache } - return await probeHttpAxios({ - startTime, - maxRedirects: followRedirects, - renderedURL, - requestParams: { ...newReq, headers: requestHeaders }, - allowUnauthorized, - }) + // Do the request using compiled URL and compiled headers (if exists) + const response = await (getContext().flags['native-fetch'] + ? probeHttpFetch({ + startTime, + maxRedirects: followRedirects, + renderedURL, + requestParams: { ...newReq, headers: requestHeaders }, + allowUnauthorized, + }) + : probeHttpAxios({ + startTime, + maxRedirects: followRedirects, + renderedURL, + requestParams: { ...newReq, headers: requestHeaders }, + allowUnauthorized, + })) + + putCache(hashRequest, response) + return response } catch (error: unknown) { const responseTime = Date.now() - startTime @@ -372,7 +395,7 @@ function transformContentByType( case 'multipart/form-data': { const form = new FormData() for (const contentKey of Object.keys(content)) { - form.append(contentKey, (content as any)[contentKey]) + form.append(contentKey, (content as never)[contentKey]) } return { content: form, contentType: form.getHeaders()['content-type'] } From 9557964006c8f9123c95d396bccd2677488acecc Mon Sep 17 00:00:00 2001 From: Mochamad Noor Syamsu Date: Wed, 29 May 2024 22:57:53 +0700 Subject: [PATCH 05/12] update doc --- docs/src/pages/guides/probes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/pages/guides/probes.md b/docs/src/pages/guides/probes.md index e63eda94e..960d99da6 100644 --- a/docs/src/pages/guides/probes.md +++ b/docs/src/pages/guides/probes.md @@ -77,7 +77,7 @@ Details of the field are given in the table below. ### Good to know -To reduce network usage, HTTP responses are cached with 30s time-to-live by default. +To reduce network usage, HTTP responses are cached with 30s time-to-live by default. This cache is then reused for requests with identical HTTP request config, e.g. headers, method, url. ## Request Body From 25f629cf7a4ec9e75eeb7eaaf33d2523dbc9d46f Mon Sep 17 00:00:00 2001 From: Mochamad Noor Syamsu Date: Wed, 29 May 2024 23:03:04 +0700 Subject: [PATCH 06/12] remove signal from hash composite --- src/components/probe/prober/http/request.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/probe/prober/http/request.ts b/src/components/probe/prober/http/request.ts index 858f84fd5..382947469 100644 --- a/src/components/probe/prober/http/request.ts +++ b/src/components/probe/prober/http/request.ts @@ -111,7 +111,11 @@ export async function httpRequest({ JSON.stringify({ maxRedirects: followRedirects, renderedURL, - requestParams: { ...newReq, headers: requestHeaders }, + requestParams: { + ...newReq, + signal: undefined, + headers: requestHeaders, + }, allowUnauthorized, }) ) From 0a5e48be74f7b67a7041b9851a6acfc43b72ce15 Mon Sep 17 00:00:00 2001 From: Mochamad Noor Syamsu Date: Fri, 31 May 2024 11:17:28 +0700 Subject: [PATCH 07/12] documentation --- src/components/probe/prober/http/response-cache.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/probe/prober/http/response-cache.ts b/src/components/probe/prober/http/response-cache.ts index 49494a8f4..e7caf39f4 100644 --- a/src/components/probe/prober/http/response-cache.ts +++ b/src/components/probe/prober/http/response-cache.ts @@ -59,12 +59,14 @@ function ensureCacheSize() { // this will delete already expired cache entries function ensureCacheTtl() { const now = Date.now() + // iterate over cache entries + // since we use map, the order of entries are based on insertion order for (const [key, { expireAt }] of responseCache.entries()) { if (expireAt <= now) { responseCache.delete(key) } else { // next items have valid time-to-live - // break out of loop to save time + // iteration is not necessary anymore break } } From 2fa89eb4bbc2af6d8e5b2d93aaf649d9d23ed67c Mon Sep 17 00:00:00 2001 From: Mochamad Noor Syamsu Date: Mon, 3 Jun 2024 20:35:53 +0700 Subject: [PATCH 08/12] use @isaac/ttl-cache and add flag --- docs/src/pages/guides/probes.md | 4 +- package-lock.json | 9 +++ package.json | 1 + src/components/probe/prober/http/index.ts | 41 +++++++--- src/components/probe/prober/http/request.ts | 27 ------- .../probe/prober/http/response-cache.ts | 74 +++++-------------- src/flag.ts | 6 ++ 7 files changed, 71 insertions(+), 91 deletions(-) diff --git a/docs/src/pages/guides/probes.md b/docs/src/pages/guides/probes.md index 960d99da6..5a307c263 100644 --- a/docs/src/pages/guides/probes.md +++ b/docs/src/pages/guides/probes.md @@ -77,7 +77,9 @@ Details of the field are given in the table below. ### Good to know -To reduce network usage, HTTP responses are cached with 30s time-to-live by default. This cache is then reused for requests with identical HTTP request config, e.g. headers, method, url. +To reduce network usage, HTTP responses are cached with 5 time-to-live by default. This cache is then reused for requests with identical HTTP request config, e.g. headers, method, url. + +This cache is usable for probes which does not have [chaining requests.](https://hyperjumptech.github.io/monika/guides/examples#requests-chaining) ## Request Body diff --git a/package-lock.json b/package-lock.json index 698b3b7d8..4e0ff590f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "dependencies": { "@faker-js/faker": "^7.4.0", "@hyperjumptech/monika-notification": "^1.18.0", + "@isaacs/ttlcache": "^1.4.1", "@oclif/core": "3.16.0", "@oclif/plugin-help": "^6.0.9", "@oclif/plugin-version": "^2.0.11", @@ -1638,6 +1639,14 @@ "integrity": "sha512-SQ7Kzhh9+D+ZW9MA0zkYv3VXhIDNx+LzM6EJ+/65I3QY+enU6Itte7E5XX7EWrqLW2FN4n06GWzBnPoC3th2aQ==", "dev": true }, + "node_modules/@isaacs/ttlcache": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz", + "integrity": "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==", + "engines": { + "node": ">=12" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", diff --git a/package.json b/package.json index 8718fc61d..0cbe85181 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "dependencies": { "@faker-js/faker": "^7.4.0", "@hyperjumptech/monika-notification": "^1.18.0", + "@isaacs/ttlcache": "^1.4.1", "@oclif/core": "3.16.0", "@oclif/plugin-help": "^6.0.9", "@oclif/plugin-version": "^2.0.11", diff --git a/src/components/probe/prober/http/index.ts b/src/components/probe/prober/http/index.ts index 4272973a5..9d8b4a544 100644 --- a/src/components/probe/prober/http/index.ts +++ b/src/components/probe/prober/http/index.ts @@ -40,6 +40,7 @@ import { addIncident } from '../../../incident' import { saveProbeRequestLog } from '../../../logger/history' import { logResponseTime } from '../../../logger/response-time-log' import { httpRequest } from './request' +import { getCache, putCache } from './response-cache' type ProbeResultMessageParams = { request: RequestConfig @@ -52,15 +53,26 @@ export class HTTPProber extends BaseProber { // sending multiple http requests for request chaining const responses: ProbeRequestResponse[] = [] - for (const requestConfig of requests) { - responses.push( - // eslint-disable-next-line no-await-in-loop - await httpRequest({ - isRetrying: incidentRetryAttempt > 0, - requestConfig: { ...requestConfig, signal }, - responses, - }) - ) + // do http request + // force fresh request if : + // - probe has chaining requests, OR + // - this is a retrying attempt + if (requests.length > 1 || incidentRetryAttempt > 0) { + for (const requestConfig of requests) { + responses.push( + // eslint-disable-next-line no-await-in-loop + await this.doRequest(requestConfig, signal, responses) + ) + } + } + // use cached response when possible + // or fallback to fresh request if cache expired + else { + const responseCache = getCache(requests[0]) + const response = + responseCache || (await this.doRequest(requests[0], signal, responses)) + if (!responseCache) putCache(requests[0], response) + responses.push(response) } const hasFailedRequest = responses.find( @@ -166,6 +178,17 @@ export class HTTPProber extends BaseProber { } } + private doRequest( + config: RequestConfig, + signal: AbortSignal | undefined, + responses: ProbeRequestResponse[] + ) { + return httpRequest({ + requestConfig: { ...config, signal }, + responses, + }) + } + generateVerboseStartupMessage(): string { const { description, id, interval, name } = this.probeConfig diff --git a/src/components/probe/prober/http/request.ts b/src/components/probe/prober/http/request.ts index 382947469..cd9217a0a 100644 --- a/src/components/probe/prober/http/request.ts +++ b/src/components/probe/prober/http/request.ts @@ -41,8 +41,6 @@ import { sendHttpRequest, sendHttpRequestFetch } from '../../../../utils/http' import { log } from '../../../../utils/pino' import { AxiosError } from 'axios' import { getErrorMessage } from '../../../../utils/catch-error-handler' -import { createHash } from 'crypto' -import { getCache, putCache } from './response-cache' // Register Handlebars helpers registerFakes(Handlebars) @@ -50,7 +48,6 @@ registerFakes(Handlebars) type probingParams = { requestConfig: Omit // is a config object responses: Array // an array of previous responses - isRetrying?: boolean } const UndiciErrorValidator = Joi.object({ @@ -65,7 +62,6 @@ const UndiciErrorValidator = Joi.object({ export async function httpRequest({ requestConfig, responses, - isRetrying = false, }: probingParams): Promise { // Compile URL using handlebars to render URLs that uses previous responses data const { @@ -106,28 +102,6 @@ export async function httpRequest({ requestHeaders.set(key, value) } - const hashRequest = createHash('SHA1') - .update( - JSON.stringify({ - maxRedirects: followRedirects, - renderedURL, - requestParams: { - ...newReq, - signal: undefined, - headers: requestHeaders, - }, - allowUnauthorized, - }) - ) - .digest('hex') - - // this request may be attempting to retry triggered by alerts - // use cache only if this not a retry - if (!isRetrying) { - const cache = getCache(hashRequest) - if (cache) return cache - } - // Do the request using compiled URL and compiled headers (if exists) const response = await (getContext().flags['native-fetch'] ? probeHttpFetch({ @@ -145,7 +119,6 @@ export async function httpRequest({ allowUnauthorized, })) - putCache(hashRequest, response) return response } catch (error: unknown) { const responseTime = Date.now() - startTime diff --git a/src/components/probe/prober/http/response-cache.ts b/src/components/probe/prober/http/response-cache.ts index e7caf39f4..969cc8a43 100644 --- a/src/components/probe/prober/http/response-cache.ts +++ b/src/components/probe/prober/http/response-cache.ts @@ -23,67 +23,33 @@ **********************************************************************************/ import { getContext } from '../../../../context' -import { ProbeRequestResponse } from 'src/interfaces/request' +import { ProbeRequestResponse, RequestConfig } from 'src/interfaces/request' import { log } from '../../../../utils/pino' -import { serialize } from 'v8' -/** - * Simple implementation for caching Monika HTTP responses. - * With default total cache size limited to 50 MB. - * And default time-to-live for each cache entries is 30s. - * - * About cache size 50 MB limit : - * Assuming a typical web page response is around 500 KB - * This cache can fit around 100 entries of web pages - * A typical response with empty body is around 300 bytes of headers - * That means, this cache can fit around 160K entries of empty body responses - */ -const DEFAULT_CACHE_LIMIT = 50_000_000 // 50 MB in bytes -const DEFAULT_TIME_TO_LIVE = 30_000 // 30s in ms -const responseCache = new Map< - string, - { expireAt: number; response: ProbeRequestResponse } ->() +import TTLCache from '@isaacs/ttlcache' +import { createHash } from 'crypto' -// ensureCacheSize ensures total size of cache is under DEFAULT_CACHE_LIMIT -function ensureCacheSize() { - const totalCacheSize = serialize(responseCache).byteLength - const firstKey = responseCache.keys().next().value - if (totalCacheSize > DEFAULT_CACHE_LIMIT && firstKey) { - responseCache.delete(firstKey) - // recursive until cache size is under limit - ensureCacheSize() - } -} +const ttlCache = new TTLCache({ ttl: getContext().flags['ttl-cache'] * 60_000 }) +const cacheHash = new Map() -// ensureCacheTtl ensures cache entries are within valid time-to-live -// this will delete already expired cache entries -function ensureCacheTtl() { - const now = Date.now() - // iterate over cache entries - // since we use map, the order of entries are based on insertion order - for (const [key, { expireAt }] of responseCache.entries()) { - if (expireAt <= now) { - responseCache.delete(key) - } else { - // next items have valid time-to-live - // iteration is not necessary anymore - break - } +function getOrCreateHash(config: RequestConfig) { + let hash = cacheHash.get(config) + if (!hash) { + hash = createHash('SHA1').update(JSON.stringify(config)).digest('hex') } + + return hash } -function put(key: string, value: ProbeRequestResponse) { +function put(config: RequestConfig, value: ProbeRequestResponse) { if (getContext().isTest) return - const expireAt = Date.now() + DEFAULT_TIME_TO_LIVE - responseCache.set(key, { expireAt, response: value }) - // after put into cache, ensure total cache size is under limit - ensureCacheSize() + const hash = getOrCreateHash(config) + ttlCache.set(hash, value) } -function get(key: string) { - // remove expired entries before actually getting cache - ensureCacheTtl() - const response = responseCache.get(key)?.response +function get(config: RequestConfig): ProbeRequestResponse | undefined { + if (getContext().isTest) return undefined + const key = getOrCreateHash(config) + const response = ttlCache.get(key) const isVerbose = getContext().flags.verbose const shortHash = key.slice(Math.max(0, key.length - 7)) if (isVerbose && response) { @@ -94,7 +60,7 @@ function get(key: string) { log.info(`${time} - [${shortHash}] Cache MISS`) } - return response + return response as ProbeRequestResponse | undefined } -export { responseCache, put as putCache, get as getCache } +export { put as putCache, get as getCache } diff --git a/src/flag.ts b/src/flag.ts index d7b37f83f..a33136dc4 100644 --- a/src/flag.ts +++ b/src/flag.ts @@ -67,6 +67,7 @@ export type MonikaFlags = { symonGetProbesIntervalMs: number symonUrl?: string text?: string + 'ttl-cache': number ignoreInvalidTLS: boolean verbose: boolean } @@ -98,6 +99,7 @@ export const monikaFlagsDefaultValue: MonikaFlags = { symonGetProbesIntervalMs: 60_000, symonReportInterval: DEFAULT_SYMON_REPORT_INTERVAL_MS, symonReportLimit: 100, + 'ttl-cache': 5, verbose: false, } @@ -276,6 +278,10 @@ export const flags = { description: 'Run Monika using a Simple text file', exclusive: ['postman', 'insomnia', 'sitemap', 'har'], }), + 'ttl-cache': Flags.integer({ + description: `Time-to-live for in-memory (HTTP) cache entries in minutes. Defaults to ${monikaFlagsDefaultValue['ttl-cache']} minutes.`, + default: monikaFlagsDefaultValue['ttl-cache'], + }), verbose: Flags.boolean({ default: monikaFlagsDefaultValue.verbose, description: 'Show verbose log messages', From e29e6680939bc3e469326b17d8949ab4c81ce52c Mon Sep 17 00:00:00 2001 From: Mochamad Noor Syamsu Date: Mon, 3 Jun 2024 20:50:01 +0700 Subject: [PATCH 09/12] add option to disable cache --- docs/src/pages/guides/cli-options.md | 9 +++++++++ src/components/probe/prober/http/response-cache.ts | 9 ++++++--- src/flag.ts | 2 +- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/src/pages/guides/cli-options.md b/docs/src/pages/guides/cli-options.md index 06830d02b..af0579338 100644 --- a/docs/src/pages/guides/cli-options.md +++ b/docs/src/pages/guides/cli-options.md @@ -336,6 +336,15 @@ If there is a probe with request(s) that uses HTTPS, Monika will show an error i monika --ignoreInvalidTLS ``` +## TTL Cache + +Time-to-live for in-memory (HTTP) cache entries in minutes. Defaults to 5 minutes. Setting to 0 means disabling this cache. + +```bash +# Set TTL cache for HTTP to 5 minutes +monika --ttl-cache 5 +``` + ## Verbose Like your app to be more chatty and honest revealing all its internal details? Use the `--verbose` flag. diff --git a/src/components/probe/prober/http/response-cache.ts b/src/components/probe/prober/http/response-cache.ts index 969cc8a43..a0dddffe3 100644 --- a/src/components/probe/prober/http/response-cache.ts +++ b/src/components/probe/prober/http/response-cache.ts @@ -28,7 +28,10 @@ import { log } from '../../../../utils/pino' import TTLCache from '@isaacs/ttlcache' import { createHash } from 'crypto' -const ttlCache = new TTLCache({ ttl: getContext().flags['ttl-cache'] * 60_000 }) +const ttlCache = + getContext().flags['ttl-cache'] > 0 + ? new TTLCache({ ttl: getContext().flags['ttl-cache'] * 60_000 }) + : undefined const cacheHash = new Map() function getOrCreateHash(config: RequestConfig) { @@ -41,13 +44,13 @@ function getOrCreateHash(config: RequestConfig) { } function put(config: RequestConfig, value: ProbeRequestResponse) { - if (getContext().isTest) return + if (!ttlCache || getContext().isTest) return const hash = getOrCreateHash(config) ttlCache.set(hash, value) } function get(config: RequestConfig): ProbeRequestResponse | undefined { - if (getContext().isTest) return undefined + if (!ttlCache || getContext().isTest) return undefined const key = getOrCreateHash(config) const response = ttlCache.get(key) const isVerbose = getContext().flags.verbose diff --git a/src/flag.ts b/src/flag.ts index a33136dc4..d8bba98a7 100644 --- a/src/flag.ts +++ b/src/flag.ts @@ -44,6 +44,7 @@ export type MonikaFlags = { force: boolean har?: string id?: string + ignoreInvalidTLS: boolean insomnia?: string 'keep-verbose-logs': boolean logs: boolean @@ -68,7 +69,6 @@ export type MonikaFlags = { symonUrl?: string text?: string 'ttl-cache': number - ignoreInvalidTLS: boolean verbose: boolean } From 46d770d181320d6d7840c6dd4cd4bfaca29de1b6 Mon Sep 17 00:00:00 2001 From: Mochamad Noor Syamsu Date: Mon, 3 Jun 2024 21:21:55 +0700 Subject: [PATCH 10/12] separate log cache cli flag --- docs/src/pages/guides/cli-options.md | 12 +++++++++++- src/components/probe/prober/http/response-cache.ts | 11 ++++------- src/flag.ts | 8 +++++++- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/docs/src/pages/guides/cli-options.md b/docs/src/pages/guides/cli-options.md index af0579338..5b6545268 100644 --- a/docs/src/pages/guides/cli-options.md +++ b/docs/src/pages/guides/cli-options.md @@ -338,7 +338,9 @@ monika --ignoreInvalidTLS ## TTL Cache -Time-to-live for in-memory (HTTP) cache entries in minutes. Defaults to 5 minutes. Setting to 0 means disabling this cache. +Time-to-live for in-memory (HTTP) cache entries in minutes. Defaults to 5 minutes. Setting to 0 means disabling this cache. This cache is used for requests with identical HTTP request config, e.g. headers, method, url. + +Only usable for probes which does not have [chaining requests.](https://hyperjumptech.github.io/monika/guides/examples#requests-chaining) ```bash # Set TTL cache for HTTP to 5 minutes @@ -353,6 +355,14 @@ Like your app to be more chatty and honest revealing all its internal details? U monika --verbose ``` +## Verbose Cache + +Show (HTTP) cache hit / miss messages to log + +```bash +monika --verbose-cache +``` + ## Version The `-v` or `--version` flag prints the current application version. diff --git a/src/components/probe/prober/http/response-cache.ts b/src/components/probe/prober/http/response-cache.ts index a0dddffe3..4890b17e1 100644 --- a/src/components/probe/prober/http/response-cache.ts +++ b/src/components/probe/prober/http/response-cache.ts @@ -28,10 +28,7 @@ import { log } from '../../../../utils/pino' import TTLCache from '@isaacs/ttlcache' import { createHash } from 'crypto' -const ttlCache = - getContext().flags['ttl-cache'] > 0 - ? new TTLCache({ ttl: getContext().flags['ttl-cache'] * 60_000 }) - : undefined +const ttlCache = new TTLCache({ ttl: getContext().flags['ttl-cache'] * 60_000 }) const cacheHash = new Map() function getOrCreateHash(config: RequestConfig) { @@ -44,16 +41,16 @@ function getOrCreateHash(config: RequestConfig) { } function put(config: RequestConfig, value: ProbeRequestResponse) { - if (!ttlCache || getContext().isTest) return + if (!getContext().flags['ttl-cache'] || getContext().isTest) return const hash = getOrCreateHash(config) ttlCache.set(hash, value) } function get(config: RequestConfig): ProbeRequestResponse | undefined { - if (!ttlCache || getContext().isTest) return undefined + if (!getContext().flags['ttl-cache'] || getContext().isTest) return undefined const key = getOrCreateHash(config) const response = ttlCache.get(key) - const isVerbose = getContext().flags.verbose + const isVerbose = getContext().flags['verbose-cache'] const shortHash = key.slice(Math.max(0, key.length - 7)) if (isVerbose && response) { const time = new Date().toISOString() diff --git a/src/flag.ts b/src/flag.ts index d8bba98a7..847f7a5b2 100644 --- a/src/flag.ts +++ b/src/flag.ts @@ -70,6 +70,7 @@ export type MonikaFlags = { text?: string 'ttl-cache': number verbose: boolean + 'verbose-cache': boolean } const DEFAULT_CONFIG_INTERVAL_SECONDS = 900 @@ -101,6 +102,7 @@ export const monikaFlagsDefaultValue: MonikaFlags = { symonReportLimit: 100, 'ttl-cache': 5, verbose: false, + 'verbose-cache': false, } function getDefaultConfig(): Array { @@ -279,13 +281,17 @@ export const flags = { exclusive: ['postman', 'insomnia', 'sitemap', 'har'], }), 'ttl-cache': Flags.integer({ - description: `Time-to-live for in-memory (HTTP) cache entries in minutes. Defaults to ${monikaFlagsDefaultValue['ttl-cache']} minutes.`, + description: `Time-to-live for in-memory (HTTP) cache entries in minutes. Defaults to ${monikaFlagsDefaultValue['ttl-cache']} minutes`, default: monikaFlagsDefaultValue['ttl-cache'], }), verbose: Flags.boolean({ default: monikaFlagsDefaultValue.verbose, description: 'Show verbose log messages', }), + 'verbose-cache': Flags.boolean({ + default: monikaFlagsDefaultValue.verbose, + description: 'Show cache hit / miss messages to log', + }), version: Flags.version({ char: 'v' }), } From 26dc0c93c3c833fdc274d8d9cddb0c3607520da0 Mon Sep 17 00:00:00 2001 From: Mochamad Noor Syamsu Date: Mon, 3 Jun 2024 21:33:13 +0700 Subject: [PATCH 11/12] fix actual ttl --- src/components/probe/prober/http/response-cache.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/probe/prober/http/response-cache.ts b/src/components/probe/prober/http/response-cache.ts index 4890b17e1..32abacd7d 100644 --- a/src/components/probe/prober/http/response-cache.ts +++ b/src/components/probe/prober/http/response-cache.ts @@ -28,7 +28,7 @@ import { log } from '../../../../utils/pino' import TTLCache from '@isaacs/ttlcache' import { createHash } from 'crypto' -const ttlCache = new TTLCache({ ttl: getContext().flags['ttl-cache'] * 60_000 }) +const ttlCache = new TTLCache() const cacheHash = new Map() function getOrCreateHash(config: RequestConfig) { @@ -43,7 +43,10 @@ function getOrCreateHash(config: RequestConfig) { function put(config: RequestConfig, value: ProbeRequestResponse) { if (!getContext().flags['ttl-cache'] || getContext().isTest) return const hash = getOrCreateHash(config) - ttlCache.set(hash, value) + // manually set time-to-live for each cache entry + // moved from "new TTLCache()" initialization above because corresponding flag is not yet parsed + const ttl = getContext().flags['ttl-cache'] * 60_000 + ttlCache.set(hash, value, { ttl }) } function get(config: RequestConfig): ProbeRequestResponse | undefined { From ab0442b70493d0c55e15440ced5deb2d3cce260b Mon Sep 17 00:00:00 2001 From: Mochamad Noor Syamsu Date: Tue, 11 Jun 2024 18:38:49 +0700 Subject: [PATCH 12/12] add redundant message doc --- docs/src/pages/guides/cli-options.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/src/pages/guides/cli-options.md b/docs/src/pages/guides/cli-options.md index 5b6545268..8268919e0 100644 --- a/docs/src/pages/guides/cli-options.md +++ b/docs/src/pages/guides/cli-options.md @@ -357,7 +357,9 @@ monika --verbose ## Verbose Cache -Show (HTTP) cache hit / miss messages to log +Show (HTTP) cache hit / miss messages to log. + +This will only show for probes which does not have [chaining requests.](https://hyperjumptech.github.io/monika/guides/examples#requests-chaining) ```bash monika --verbose-cache