Repository navigation
Track LMCache hit rate from linked vLLM/SGLang engines (Pro) #529
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
jamby77
wants to merge
90
commits into
kv-cache/footprint
Choose a base branch
from
kv-cache/engines
base: kv-cache/footprint
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
90 commits
Select commit
Hold shift + click to select a range
ac48b11
Share the webhook URL guard for other outbound requests
jamby77 faabdb1
Match only loopback addresses in the outbound URL guard
jamby77 5f8f1c8
Parse LMCache counters from Prometheus text
jamby77 f898b3c
Turn LMCache counters into per-minute deltas
jamby77 c3508d9
Reject inherited and non-finite metrics in the delta tracker
jamby77 0bc5ccd
Link LMCache engines by scrape URL or OTLP engine id
jamby77 181f130
Never send a cleared or stale scrape credential to a new URL
jamby77 6bdd021
Store per-minute LMCache samples and serve hit rate history
jamby77 0865ad6
Scrape LMCache counters from linked engines
jamby77 b4aa93c
Fall back to the default scrape interval for invalid values
jamby77 c2e6fac
Route LMCache OTLP metrics to linked KV cache engines
jamby77 898c913
Alert on low LMCache hit rate and KV cache eviction risk
jamby77 a9da862
Never alert for disabled KV cache engines
jamby77 6abf0b4
Re-export KV cache hit rate and footprint metrics
jamby77 6abe07e
Clear hit rate gauges when a connection's last engine is removed
jamby77 10c2e09
Delete engine samples on removal and reset baselines on disable or UR…
jamby77 e564f2c
Skip DNS for IP-literal outbound URLs and always block loopback
jamby77 f1f6485
Merge branch 'kv-cache/footprint' into kv-cache/engines
jamby77 31e6c56
Block IPv4-mapped IPv6 SSRF targets and require unique engine names
jamby77 ae5a147
Reserve engine names while a create or rename is in flight
jamby77 41edfd9
Tighten outbound URL checks and KV cache ingest edge cases
jamby77 46cdf33
Drop stale scrape errors for engines changed mid-fetch
jamby77 14c20df
Merge branch 'kv-cache/footprint' into kv-cache/engines
jamby77 c4d1e57
Merge branch 'kv-cache/footprint' into kv-cache/engines
jamby77 a2ff0bf
Serialize KV cache engine and sample writes
jamby77 49fdc63
Harden KV cache counter deltas and outbound URL guard
jamby77 578ecd7
Merge branch 'kv-cache/footprint' into kv-cache/engines
jamby77 cc6b5f5
Fix KV engine registry races and stuck hit-rate alerts
jamby77 707ce16
Merge kv-cache/footprint into kv-cache/engines
jamby77 2818149
Fix KV engine removal races and reject null in KV DTOs
jamby77 ec05919
Pin KV scrape addresses and restore webhook URL checks
jamby77 3bd51e8
Tidy KV cache series keys, OTLP ids and alert hysteresis
jamby77 d7cd65a
Clear pending-remove mark when a KV cache engine delete fails
jamby77 74a120e
Fold late KV cache observations into the open minute
jamby77 612e4bf
Merge kv-cache/footprint into kv-cache/engines
jamby77 fdca83c
Trigger KV cache removal listeners through the registry in tests
jamby77 071d209
Skip the unevictable KV cache check when TTL data is missing
jamby77 57e5924
Merge kv-cache/footprint into kv-cache/engines
jamby77 14b0da8
Clear KV cache eviction alerts when disabled or LMCache is gone
jamby77 54275b7
Merge kv-cache/footprint into kv-cache/engines
jamby77 928e2c5
Reject engine saves that land after a delete
jamby77 39270ad
Reject out-of-range scrape statuses instead of crashing
jamby77 3619349
Block private scrape targets in cloud and more metadata endpoints
jamby77 56aa769
Keep scrape URL credentials out of storage and responses
jamby77 2671431
Refuse KV cache engines for missing or removed connections
jamby77 fc90685
Rebaseline KV cache counters when the series start time moves
jamby77 7c6da06
Skip the DNS pre-check for scrapes so the timeout covers lookups
jamby77 3e2a7d9
Allow loopback answers in guardedLookup outside production
jamby77 3e9404a
Sum KV cache footprint gauges across dtypes for each model
jamby77 47e72cd
Clear exported KV cache series when the license lapses
jamby77 17312b9
Fail closed on unknown resolveDns values in the outbound URL guard
jamby77 ae0b9fa
Compute the KV cache range hit rate from SQL totals
jamby77 65b9882
Throttle KV cache engine row writes from scrape results
jamby77 82c6f6f
Expire KV cache counter baselines after an hour idle
jamby77 b00d060
Expire KV cache engine removal tombstones after an hour
jamby77 dcaa9dc
Share the OTLP engine id pattern and no-metrics message
jamby77 4de26dc
Use the shared encryption service accessor for scrape auth headers
jamby77 6eeefa0
Merge branch 'kv-cache/footprint' into kv-cache/engines
jamby77 5d1565e
Treat all of 0.0.0.0/8 like loopback in the outbound URL guard
jamby77 9ca0a04
Merge branch 'kv-cache/footprint' into kv-cache/engines
jamby77 3d4d06a
Merge branch 'kv-cache/footprint' into kv-cache/engines
jamby77 f726070
Skip the KV cache bytes gauge when the byte estimate is unknown
jamby77 9c28c11
Keep the KV evicting alert armed when the LMCache share is unknown
jamby77 67d9d3b
Merge branch 'kv-cache/footprint' into kv-cache/engines
jamby77 e275243
Reject engine saves whose write lands after a delete
jamby77 1390009
Merge branch 'kv-cache/footprint' into kv-cache/engines
jamby77 722f986
Merge kv-cache/footprint into kv-cache/engines
jamby77 180d725
Await in-flight KV sample saves when flushing with nothing to drain
jamby77 ffb8a18
Parse KV_CACHE_SCRAPE_BLOCK_PRIVATE leniently and fail closed on unkn…
jamby77 45dc452
Accept underscores in KV cache scrape URL hostnames
jamby77 971604f
Match private IP patterns only on IP literals in the outbound URL guard
jamby77 dadf61d
Harden KV alert evaluation against overlap, partial and unknown snaps…
jamby77 938f6cd
Validate the KV scrape interval and surface undecryptable auth headers
jamby77 7892a6b
Sum KV sample counters through a shared helper over METRIC_FIELDS
jamby77 5420d97
Bucket KV hit-rate chart samples in storage across the whole range
jamby77 3df2117
Keep Valkey metrics of an LMCache-tagged OTLP resource on the Valkey …
jamby77 dc490fa
Let a failed KV engine sample delete be retried by its connection
jamby77 16ace9a
Merge kv-cache/footprint into kv-cache/engines
jamby77 6eced4d
Hide blocked IPs from outbound URL errors and match localhost exactly
jamby77 cc94a12
Pin webhook delivery to the validated address and refuse redirects
jamby77 e3fc1dd
Send KV eviction risk webhooks without blocking footprint collection
jamby77 57a82eb
Report an undecryptable KV auth header when a scrape URL changes
jamby77 a4ffd09
Keep a URL change's probe status over an old-URL scrape result
jamby77 691d4a8
Mask query values in KV engine scrape URLs returned by the API
jamby77 5bcaa58
Label KV cache Prometheus series with the connection id
jamby77 3ff96db
Merge kv-cache/footprint into kv-cache/engines
jamby77 3f05869
Merge kv-cache/footprint into kv-cache/engines
jamby77 71d6799
Merge kv-cache/footprint into kv-cache/engines
jamby77 5b37557
Merge kv-cache/footprint into kv-cache/engines
jamby77 5ffebe8
Merge kv-cache/footprint into kv-cache/engines
jamby77 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
294 changes: 294 additions & 0 deletions
294
apps/api/src/common/utils/__tests__/outbound-url-guard.spec.ts
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,158 @@ | ||
| import { BadRequestException, Logger } from '@nestjs/common'; | ||
| import { promises as dns } from 'dns'; | ||
| import { isIP, type LookupFunction } from 'net'; | ||
|
|
||
| export interface OutboundUrlOptions { | ||
| label: string; | ||
| allowPrivateNetworks?: boolean; | ||
| resolveDns?: 'always' | 'production' | 'never'; | ||
| } | ||
|
|
||
| export class BlockedAddressError extends Error {} | ||
|
|
||
| const logger = new Logger('OutboundUrlGuard'); | ||
|
|
||
| const BLOCKED_IP_PATTERNS = [ | ||
| /^127\./, | ||
| /^10\./, | ||
| /^172\.(1[6-9]|2[0-9]|3[01])\./, | ||
| /^192\.168\./, | ||
| /^100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\./, | ||
| /^169\.254\./, | ||
| /^::1?$/, | ||
| /^0\./, | ||
| /^fe[89ab][0-9a-f]:/i, | ||
| /^f[cd][0-9a-f]{2}:/i, | ||
| ]; | ||
|
|
||
| const ALWAYS_BLOCKED = [/^169\.254\./, /^fe[89ab][0-9a-f]:/i, /^fd00:ec2::254$/, /^100\.100\.100\.200$/, /^192\.0\.0\.192$/]; | ||
|
|
||
| const LOOPBACK = [/^127\./, /^0\./, /^::1?$/]; | ||
|
|
||
| const MAPPED_DOTTED = /^::ffff:(?:0:)?(\d{1,3}(?:\.\d{1,3}){3})$/; | ||
| const MAPPED_HEX = /^::ffff:(?:0:)?([0-9a-f]{1,4}):([0-9a-f]{1,4})$/; | ||
|
|
||
| const bareHost = (hostname: string) => hostname.replace(/^\[|\]$/g, '').toLowerCase(); | ||
|
|
||
| function canonicalIp(ip: string): string { | ||
| const address = bareHost(ip); | ||
| if (isIP(address) !== 6) return address; | ||
| try { | ||
| return bareHost(new URL(`http://[${address}]`).hostname); | ||
| } catch { | ||
| return address; | ||
| } | ||
| } | ||
|
|
||
| function unmapIpv4(address: string): string { | ||
| const dotted = MAPPED_DOTTED.exec(address); | ||
| if (dotted) return dotted[1]; | ||
| const hex = MAPPED_HEX.exec(address); | ||
| if (!hex) return address; | ||
| const high = parseInt(hex[1], 16); | ||
| const low = parseInt(hex[2], 16); | ||
| return `${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`; | ||
| } | ||
|
|
||
| const capitalize = (text: string) => text.charAt(0).toUpperCase() + text.slice(1); | ||
|
|
||
| const isLocalhostName = (hostname: string) => /(^|\.)localhost\.?$/.test(hostname); | ||
|
|
||
| function blockedAddressMessage(label: string, hostname: string, address: string): string { | ||
| logger.warn(`${capitalize(label)} ${hostname} resolves to blocked IP address ${address}`); | ||
| return `${capitalize(label)} resolves to a blocked address`; | ||
| } | ||
|
|
||
| export function isBlockedIp(ip: string, allowPrivateNetworks = false): boolean { | ||
| const address = unmapIpv4(canonicalIp(ip)); | ||
| if (ALWAYS_BLOCKED.some((pattern) => pattern.test(address))) return true; | ||
| if (allowPrivateNetworks) return false; | ||
| return BLOCKED_IP_PATTERNS.some((pattern) => pattern.test(address)); | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
| function isLoopback(ip: string): boolean { | ||
| const address = unmapIpv4(canonicalIp(ip)); | ||
| return LOOPBACK.some((pattern) => pattern.test(address)); | ||
| } | ||
|
|
||
| export async function assertSafeOutboundUrl(rawUrl: string, options: OutboundUrlOptions): Promise<URL> { | ||
| const { label, allowPrivateNetworks, resolveDns = 'always' } = options; | ||
| try { | ||
| const parsed = new URL(rawUrl); | ||
|
|
||
| if (!['http:', 'https:'].includes(parsed.protocol)) { | ||
| throw new BadRequestException('Only HTTP and HTTPS protocols are allowed'); | ||
| } | ||
|
|
||
| if (parsed.username || parsed.password) { | ||
| logger.warn(`${capitalize(label)} contains credentials, consider using custom headers instead: ${parsed.hostname}`); | ||
| } | ||
|
|
||
| const isProduction = process.env.NODE_ENV === 'production'; | ||
| const host = unmapIpv4(bareHost(parsed.hostname)); | ||
| const isLocalhost = host === 'localhost' || LOOPBACK.some((pattern) => pattern.test(host)); | ||
|
|
||
| if (isLocalhost && (!isProduction || allowPrivateNetworks)) { | ||
| logger.debug(`Allowing localhost ${label} in ${process.env.NODE_ENV || 'development'} mode: ${rawUrl}`); | ||
| return parsed; | ||
| } | ||
|
|
||
| if (parsed.hostname === 'localhost' || parsed.hostname === '0.0.0.0') { | ||
| throw new BadRequestException(`Cannot use localhost or 0.0.0.0 as ${label} in production`); | ||
| } | ||
|
|
||
| if (isIP(host) && isBlockedIp(host, allowPrivateNetworks)) { | ||
| throw new BadRequestException(`Cannot use private IP addresses as ${label}`); | ||
| } | ||
|
|
||
| if (!allowPrivateNetworks && (parsed.hostname.startsWith('127.') || isLocalhostName(host))) { | ||
| throw new BadRequestException('Suspicious hostname detected'); | ||
| } | ||
|
|
||
| if (!isIP(host) && resolveDns !== 'never' && (resolveDns !== 'production' || isProduction)) { | ||
| try { | ||
| const answers = await dns.lookup(host, { all: true, verbatim: true }); | ||
| for (const { address: addr } of answers) { | ||
| if (isBlockedIp(addr, allowPrivateNetworks)) { | ||
| throw new BadRequestException(blockedAddressMessage(label, host, addr)); | ||
| } | ||
| } | ||
| } catch (dnsError: any) { | ||
| if (dnsError instanceof BadRequestException) { | ||
| throw dnsError; | ||
| } | ||
| logger.warn(`Failed to resolve DNS for ${label}: ${parsed.hostname}`); | ||
| throw new BadRequestException(`Failed to resolve ${label} hostname`); | ||
| } | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
| return parsed; | ||
| } catch (error) { | ||
| if (error instanceof BadRequestException) { | ||
| throw error; | ||
| } | ||
| throw new BadRequestException(`Invalid ${label}`); | ||
| } | ||
| } | ||
|
|
||
| export function guardedLookup(options: Omit<OutboundUrlOptions, 'resolveDns'>): LookupFunction { | ||
| const { label, allowPrivateNetworks } = options; | ||
| return (hostname, lookupOptions, callback) => { | ||
| dns.lookup(hostname, { family: lookupOptions.family, hints: lookupOptions.hints, all: true, verbatim: true }).then( | ||
| (answers) => { | ||
| const allowLoopback = allowPrivateNetworks || process.env.NODE_ENV !== 'production'; | ||
| const blocked = answers.find(({ address }) => isBlockedIp(address, allowPrivateNetworks) && !(allowLoopback && isLoopback(address))); | ||
| if (blocked) { | ||
| callback(new BlockedAddressError(blockedAddressMessage(label, hostname, blocked.address)), '', 0); | ||
| } else if (answers.length === 0) { | ||
| callback(Object.assign(new Error(`getaddrinfo ENOTFOUND ${hostname}`), { code: 'ENOTFOUND' }), '', 0); | ||
| } else if (lookupOptions.all) { | ||
| (callback as unknown as (error: null, addresses: typeof answers) => void)(null, answers); | ||
| } else { | ||
| callback(null, answers[0].address, answers[0].family); | ||
| } | ||
| }, | ||
| (error: NodeJS.ErrnoException) => callback(error, '', 0), | ||
| ); | ||
| }; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| import { request as httpRequest, type IncomingMessage } from 'http'; | ||
| import { request as httpsRequest } from 'https'; | ||
| import type { LookupFunction } from 'net'; | ||
| import { Readable } from 'stream'; | ||
|
|
||
| export interface PinnedRequestInit { | ||
| method?: string; | ||
| headers: Record<string, string>; | ||
| body?: string; | ||
| signal: AbortSignal; | ||
| lookup?: LookupFunction; | ||
| } | ||
|
|
||
| export class UnexpectedStatusError extends Error {} | ||
|
|
||
| const NULL_BODY_STATUSES = new Set([204, 205, 304]); | ||
|
|
||
| function toResponse(message: IncomingMessage): Response { | ||
| const headers = new Headers(); | ||
| for (const [name, value] of Object.entries(message.headers)) { | ||
| if (value === undefined) continue; | ||
| for (const item of Array.isArray(value) ? value : [value]) headers.append(name, item); | ||
| } | ||
| const status = message.statusCode ?? 502; | ||
| if (status < 200 || status > 599) throw new UnexpectedStatusError(`unexpected HTTP status ${status}`); | ||
| if (NULL_BODY_STATUSES.has(status)) { | ||
| message.resume(); | ||
| return new Response(null, { status, headers }); | ||
| } | ||
| return new Response(Readable.toWeb(message) as ReadableStream<Uint8Array>, { status, headers }); | ||
| } | ||
|
|
||
| export const pinnedRequest = (url: string, init: PinnedRequestInit): Promise<Response> => | ||
| new Promise((resolve, reject) => { | ||
| const target = new URL(url); | ||
| const send = target.protocol === 'https:' ? httpsRequest : httpRequest; | ||
| const headers = init.body === undefined ? init.headers : { 'Content-Length': String(Buffer.byteLength(init.body)), ...init.headers }; | ||
| const req = send( | ||
| target, | ||
| { | ||
| method: init.method ?? 'GET', | ||
| agent: false, | ||
| headers, | ||
| signal: init.signal, | ||
| ...(init.lookup ? { lookup: init.lookup } : {}), | ||
| }, | ||
| (message) => { | ||
| try { | ||
| resolve(toResponse(message)); | ||
| } catch (error) { | ||
| message.destroy(); | ||
| reject(error); | ||
| } | ||
| }, | ||
| ); | ||
| req.on('error', reject); | ||
| req.end(init.body); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import type { DropReason, OtlpMetric } from './otlp-metrics-types'; | ||
|
|
||
| export const KV_CACHE_OTLP_SINK = Symbol('KV_CACHE_OTLP_SINK'); | ||
| export const LMCACHE_ENGINE_ATTRIBUTE = 'betterdb.lmcache.engine'; | ||
|
|
||
| export interface KvCacheOtlpSinkResult { | ||
| accepted: number; | ||
| dropped: Partial<Record<DropReason, number>>; | ||
| } | ||
|
|
||
| export interface KvCacheOtlpSink { | ||
| ingest(resourceAttributes: Record<string, string>, metrics: OtlpMetric[], nowMs: number): KvCacheOtlpSinkResult; | ||
| } | ||
|
|
||
| export function isKvCacheResource(resourceAttributes: Record<string, string>, metrics: OtlpMetric[]): boolean { | ||
| if (resourceAttributes[LMCACHE_ENGINE_ATTRIBUTE] !== undefined) return true; | ||
| return metrics.length > 0 && metrics.every((metric) => (metric.name ?? '').startsWith('lmcache:')); | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.