Skip to content

Commit

Permalink
feat(http prober): #1285 implement cache http response (#1296)
Browse files Browse the repository at this point in the history
  • Loading branch information
syamsudotdev authored Jun 11, 2024
1 parent e054551 commit 18ec55c
Show file tree
Hide file tree
Showing 8 changed files with 169 additions and 27 deletions.
21 changes: 21 additions & 0 deletions docs/src/pages/guides/cli-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,17 @@ 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. 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
monika --ttl-cache 5
```

## Verbose

Like your app to be more chatty and honest revealing all its internal details? Use the `--verbose` flag.
Expand All @@ -344,6 +355,16 @@ 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.

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
```

## Version

The `-v` or `--version` flag prints the current application version.
Expand Down
6 changes: 6 additions & 0 deletions docs/src/pages/guides/probes.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ 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 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

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:
Expand Down
9 changes: 9 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
40 changes: 32 additions & 8 deletions src/components/probe/prober/http/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -52,14 +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({
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(
Expand Down Expand Up @@ -165,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

Expand Down
36 changes: 18 additions & 18 deletions src/components/probe/prober/http/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,23 +103,23 @@ export async function httpRequest({
}

// 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,
})
}

return await probeHttpAxios({
startTime,
maxRedirects: followRedirects,
renderedURL,
requestParams: { ...newReq, headers: requestHeaders },
allowUnauthorized,
})
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,
}))

return response
} catch (error: unknown) {
const responseTime = Date.now() - startTime

Expand Down Expand Up @@ -372,7 +372,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'] }
Expand Down
69 changes: 69 additions & 0 deletions src/components/probe/prober/http/response-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**********************************************************************************
* 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, RequestConfig } from 'src/interfaces/request'
import { log } from '../../../../utils/pino'
import TTLCache from '@isaacs/ttlcache'
import { createHash } from 'crypto'

const ttlCache = new TTLCache()
const cacheHash = new Map<RequestConfig, string>()

function getOrCreateHash(config: RequestConfig) {
let hash = cacheHash.get(config)
if (!hash) {
hash = createHash('SHA1').update(JSON.stringify(config)).digest('hex')
}

return hash
}

function put(config: RequestConfig, value: ProbeRequestResponse) {
if (!getContext().flags['ttl-cache'] || getContext().isTest) return
const hash = getOrCreateHash(config)
// 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 {
if (!getContext().flags['ttl-cache'] || getContext().isTest) return undefined
const key = getOrCreateHash(config)
const response = ttlCache.get(key)
const isVerbose = getContext().flags['verbose-cache']
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 as ProbeRequestResponse | undefined
}

export { put as putCache, get as getCache }
14 changes: 13 additions & 1 deletion src/flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export type MonikaFlags = {
force: boolean
har?: string
id?: string
ignoreInvalidTLS: boolean
insomnia?: string
'keep-verbose-logs': boolean
logs: boolean
Expand All @@ -67,8 +68,9 @@ export type MonikaFlags = {
symonGetProbesIntervalMs: number
symonUrl?: string
text?: string
ignoreInvalidTLS: boolean
'ttl-cache': number
verbose: boolean
'verbose-cache': boolean
}

const DEFAULT_CONFIG_INTERVAL_SECONDS = 900
Expand Down Expand Up @@ -98,7 +100,9 @@ export const monikaFlagsDefaultValue: MonikaFlags = {
symonGetProbesIntervalMs: 60_000,
symonReportInterval: DEFAULT_SYMON_REPORT_INTERVAL_MS,
symonReportLimit: 100,
'ttl-cache': 5,
verbose: false,
'verbose-cache': false,
}

function getDefaultConfig(): Array<string> {
Expand Down Expand Up @@ -276,10 +280,18 @@ 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',
}),
'verbose-cache': Flags.boolean({
default: monikaFlagsDefaultValue.verbose,
description: 'Show cache hit / miss messages to log',
}),
version: Flags.version({ char: 'v' }),
}

Expand Down

0 comments on commit 18ec55c

Please sign in to comment.