openvibers/openvibe.shared metrics.js
metrics.js
| 1 | ; |
| 2 | // ═══════════════════════════════════════════════════════════════ |
| 3 | // openvibe-shared/metrics — Prometheus text-format metrics with no dependencies (roadmap Track O, |
| 4 | // §15.19): a registry (counter, gauge, histogram), golden-signal HTTP middleware keyed by ROUTE |
| 5 | // TEMPLATE, process metrics, release_info, and a /metrics handler only loopback callers reach. |
| 6 | // |
| 7 | // const metrics = require('openvibe-shared/metrics'); |
| 8 | // const m = metrics.instrument(app, { service: 'community', release: release.release }); |
| 9 | // // mounted: the HTTP middleware (call this before any route) and GET /metrics (loopback only) |
| 10 | // const depth = m.registry.gauge({ name: 'jobs', help: 'Jobs by state', labelNames: ['state'], |
| 11 | // collect: () => db.prepare('SELECT state, COUNT(*) n FROM jobs GROUP BY state').all() |
| 12 | // .map(r => ({ labels: { state: r.state }, value: r.n })) }); |
| 13 | // |
| 14 | // Route labels are templates, never raw URLs: `req.baseUrl` (with id-like segments replaced by |
| 15 | // `:id`) + `req.route.path`, or what `normalize(req)` returns. A request no route matched is |
| 16 | // `unmatched`. Every metric caps its label combinations (`maxSeries`); past the cap new |
| 17 | // combinations collapse into one `_overflow` series and metrics_series_overflow_total counts them. |
| 18 | // ═══════════════════════════════════════════════════════════════ |
| 19 | |
| 20 | const NAME_RE = /^[a-zA-Z_:][a-zA-Z0-9_:]*$/; |
| 21 | const LABEL_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/; |
| 22 | const DEFAULT_BUCKETS = Object.freeze([0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]); |
| 23 | const DEFAULT_MAX_SERIES = 500; |
| 24 | const OVERFLOW = '_overflow'; |
| 25 | const MAX_LABEL_VALUE = 120; |
| 26 | |
| 27 | const escHelp = (s) => String(s).replace(/\\/g, '\\\\').replace(/\n/g, '\\n'); |
| 28 | const escValue = (s) => String(s).replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/"/g, '\\"'); |
| 29 | function fmt(n) { |
| 30 | if (n === Infinity) return '+Inf'; |
| 31 | if (n === -Infinity) return '-Inf'; |
| 32 | if (Number.isNaN(n)) return 'NaN'; |
| 33 | return String(n); |
| 34 | } |
| 35 | const labelText = (names, values) => (names.length ? `{${names.map((n, i) => `${n}="${escValue(values[i])}"`).join(',')}}` : ''); |
| 36 | |
| 37 | function createRegistry({ maxSeries = DEFAULT_MAX_SERIES } = {}) { |
| 38 | const metrics = new Map(); |
| 39 | let overflow = null; // counter, created on first use so an untroubled registry stays quiet |
| 40 | |
| 41 | function noteOverflow(name) { |
| 42 | if (!overflow) overflow = counter({ name: 'metrics_series_overflow_total', help: 'Label combinations collapsed into the _overflow series because a metric reached its series cap', labelNames: ['metric'] }); |
| 43 | overflow.inc({ metric: name }); |
| 44 | } |
| 45 | |
| 46 | function base(kind, { name, help, labelNames = [], maxSeries: cap = maxSeries }) { |
| 47 | if (!NAME_RE.test(String(name || ''))) throw new TypeError(`metrics: invalid metric name ${name}`); |
| 48 | if (metrics.has(name)) throw new Error(`metrics: ${name} is already registered`); |
| 49 | if (!help) throw new TypeError(`metrics: ${name} needs help text`); |
| 50 | for (const l of labelNames) { |
| 51 | if (!LABEL_RE.test(l) || l.startsWith('__') || (kind === 'histogram' && l === 'le')) throw new TypeError(`metrics: invalid label ${l} on ${name}`); |
| 52 | } |
| 53 | const series = new Map(); // key -> { values: [..], ...state } |
| 54 | function key(labels = {}) { |
| 55 | const values = labelNames.map((l) => { |
| 56 | const v = labels[l]; |
| 57 | return v === undefined || v === null ? '' : String(v).slice(0, MAX_LABEL_VALUE); |
| 58 | }); |
| 59 | return { k: values.join('\u0000'), values }; |
| 60 | } |
| 61 | function get(labels, init) { |
| 62 | let { k, values } = key(labels); |
| 63 | let s = series.get(k); |
| 64 | if (s) return s; |
| 65 | if (series.size >= cap && labelNames.length) { |
| 66 | noteOverflow(name); |
| 67 | values = labelNames.map(() => OVERFLOW); |
| 68 | k = values.join('\u0000'); |
| 69 | s = series.get(k); |
| 70 | if (s) return s; |
| 71 | } |
| 72 | s = { values, ...init() }; |
| 73 | series.set(k, s); |
| 74 | return s; |
| 75 | } |
| 76 | return { name, help, labelNames, series, get, kind }; |
| 77 | } |
| 78 | |
| 79 | function counter(opts) { |
| 80 | const m = base('counter', opts); |
| 81 | const api = { |
| 82 | name: m.name, |
| 83 | inc(labels, value = 1) { |
| 84 | if (typeof labels === 'number') { value = labels; labels = {}; } |
| 85 | if (!(value >= 0)) throw new RangeError(`metrics: ${m.name} can only increase`); |
| 86 | m.get(labels, () => ({ value: 0 })).value += value; |
| 87 | }, |
| 88 | get: (labels) => (m.series.get(labelsKey(m.labelNames, labels)) || { value: 0 }).value, |
| 89 | reset: () => m.series.clear(), |
| 90 | render() { |
| 91 | const out = [`# HELP ${m.name} ${escHelp(m.help)}`, `# TYPE ${m.name} counter`]; |
| 92 | for (const s of m.series.values()) out.push(`${m.name}${labelText(m.labelNames, s.values)} ${fmt(s.value)}`); |
| 93 | return out; |
| 94 | }, |
| 95 | }; |
| 96 | metrics.set(m.name, api); |
| 97 | return api; |
| 98 | } |
| 99 | |
| 100 | // collect(): a number (no labels) or [{ labels, value }] read at scrape time, or a promise of one (a |
| 101 | // database read): the /metrics handler awaits it (up to 2 s). A throwing or rejecting collect leaves the |
| 102 | // gauge out of that scrape rather than reporting a stale or invented value; so does a promise seen by the |
| 103 | // synchronous registry.metrics(). |
| 104 | function gauge(opts) { |
| 105 | const m = base('gauge', opts); |
| 106 | const collect = typeof opts.collect === 'function' ? opts.collect : null; |
| 107 | const api = { |
| 108 | name: m.name, |
| 109 | set(labels, value) { |
| 110 | if (typeof labels === 'number') { value = labels; labels = {}; } |
| 111 | m.get(labels, () => ({ value: 0 })).value = Number(value); |
| 112 | }, |
| 113 | inc(labels, value = 1) { |
| 114 | if (typeof labels === 'number') { value = labels; labels = {}; } |
| 115 | m.get(labels, () => ({ value: 0 })).value += value; |
| 116 | }, |
| 117 | dec(labels, value = 1) { |
| 118 | if (typeof labels === 'number') { value = labels; labels = {}; } |
| 119 | m.get(labels, () => ({ value: 0 })).value -= value; |
| 120 | }, |
| 121 | get: (labels) => (m.series.get(labelsKey(m.labelNames, labels)) || { value: undefined }).value, |
| 122 | reset: () => m.series.clear(), |
| 123 | /** Collect now; → the value, or a promise of it for an async collect. */ |
| 124 | collect: collect ? () => collect() : null, |
| 125 | render(given) { |
| 126 | if (collect) { |
| 127 | let got = given; |
| 128 | if (given === undefined) { try { got = collect(); } catch { return []; } } |
| 129 | if (got === undefined || got === null) return []; |
| 130 | if (typeof got.then === 'function') { got.then(() => {}, () => {}); return []; } |
| 131 | m.series.clear(); |
| 132 | if (typeof got === 'number') api.set({}, got); |
| 133 | else for (const row of got) api.set(row.labels || {}, row.value); |
| 134 | } |
| 135 | const out = [`# HELP ${m.name} ${escHelp(m.help)}`, `# TYPE ${m.name} gauge`]; |
| 136 | for (const s of m.series.values()) out.push(`${m.name}${labelText(m.labelNames, s.values)} ${fmt(s.value)}`); |
| 137 | return out; |
| 138 | }, |
| 139 | }; |
| 140 | metrics.set(m.name, api); |
| 141 | return api; |
| 142 | } |
| 143 | |
| 144 | function histogram(opts) { |
| 145 | const m = base('histogram', opts); |
| 146 | const buckets = [...(opts.buckets || DEFAULT_BUCKETS)].map(Number).sort((a, b) => a - b); |
| 147 | if (!buckets.length || buckets.some((b) => !Number.isFinite(b))) throw new TypeError(`metrics: ${m.name} needs finite buckets`); |
| 148 | const init = () => ({ counts: new Array(buckets.length).fill(0), sum: 0, count: 0 }); |
| 149 | const api = { |
| 150 | name: m.name, |
| 151 | buckets, |
| 152 | observe(labels, value) { |
| 153 | if (typeof labels === 'number') { value = labels; labels = {}; } |
| 154 | if (!Number.isFinite(value)) return; |
| 155 | const s = m.get(labels, init); |
| 156 | for (let i = 0; i < buckets.length; i++) if (value <= buckets[i]) s.counts[i]++; |
| 157 | s.sum += value; |
| 158 | s.count++; |
| 159 | }, |
| 160 | startTimer(labels = {}) { |
| 161 | const t0 = process.hrtime.bigint(); |
| 162 | return (more = {}) => { |
| 163 | const seconds = Number(process.hrtime.bigint() - t0) / 1e9; |
| 164 | api.observe({ ...labels, ...more }, seconds); |
| 165 | return seconds; |
| 166 | }; |
| 167 | }, |
| 168 | get: (labels) => { |
| 169 | const s = m.series.get(labelsKey(m.labelNames, labels)); |
| 170 | return s ? { count: s.count, sum: s.sum, buckets: Object.fromEntries(buckets.map((b, i) => [b, s.counts[i]])) } : null; |
| 171 | }, |
| 172 | reset: () => m.series.clear(), |
| 173 | render() { |
| 174 | const out = [`# HELP ${m.name} ${escHelp(m.help)}`, `# TYPE ${m.name} histogram`]; |
| 175 | const names = [...m.labelNames, 'le']; |
| 176 | for (const s of m.series.values()) { |
| 177 | buckets.forEach((b, i) => out.push(`${m.name}_bucket${labelText(names, [...s.values, fmt(b)])} ${s.counts[i]}`)); |
| 178 | out.push(`${m.name}_bucket${labelText(names, [...s.values, '+Inf'])} ${s.count}`); |
| 179 | out.push(`${m.name}_sum${labelText(m.labelNames, s.values)} ${fmt(s.sum)}`); |
| 180 | out.push(`${m.name}_count${labelText(m.labelNames, s.values)} ${s.count}`); |
| 181 | } |
| 182 | return out; |
| 183 | }, |
| 184 | }; |
| 185 | metrics.set(m.name, api); |
| 186 | return api; |
| 187 | } |
| 188 | |
| 189 | // Callbacks run just before a scrape renders (process metrics sample themselves here). |
| 190 | const beforeScrape = []; |
| 191 | function metricsText() { |
| 192 | for (const f of beforeScrape) { try { f(); } catch { /* a sampler never breaks the scrape */ } } |
| 193 | const lines = []; |
| 194 | for (const m of metrics.values()) lines.push(...m.render()); |
| 195 | return lines.join('\n') + '\n'; |
| 196 | } |
| 197 | |
| 198 | /** The same text, after awaiting every async collect (together, each within timeoutMs). */ |
| 199 | async function metricsTextAsync({ timeoutMs = 2000 } = {}) { |
| 200 | for (const f of beforeScrape) { try { f(); } catch { /* a sampler never breaks the scrape */ } } |
| 201 | const list = [...metrics.values()]; |
| 202 | const values = await Promise.all(list.map(async (m) => { |
| 203 | if (!m.collect) return undefined; |
| 204 | let timer; |
| 205 | try { |
| 206 | const got = m.collect(); |
| 207 | if (!got || typeof got.then !== 'function') return { got }; |
| 208 | return { got: await Promise.race([got, new Promise((_, no) => { timer = setTimeout(() => no(new Error('timeout')), timeoutMs); })]) }; |
| 209 | } catch { return { failed: true }; } finally { clearTimeout(timer); } |
| 210 | })); |
| 211 | const lines = []; |
| 212 | list.forEach((m, i) => { |
| 213 | const v = values[i]; |
| 214 | if (v && v.failed) return; |
| 215 | lines.push(...m.render(v ? (v.got === undefined ? null : v.got) : undefined)); |
| 216 | }); |
| 217 | return lines.join('\n') + '\n'; |
| 218 | } |
| 219 | |
| 220 | return { |
| 221 | counter, gauge, histogram, |
| 222 | getMetric: (name) => metrics.get(name) || null, |
| 223 | onScrape: (f) => { beforeScrape.push(f); }, |
| 224 | metrics: metricsText, |
| 225 | metricsAsync: metricsTextAsync, |
| 226 | contentType: 'text/plain; version=0.0.4; charset=utf-8', |
| 227 | }; |
| 228 | } |
| 229 | |
| 230 | function labelsKey(names, labels = {}) { |
| 231 | return names.map((l) => (labels[l] === undefined || labels[l] === null ? '' : String(labels[l]).slice(0, MAX_LABEL_VALUE))).join('\u0000'); |
| 232 | } |
| 233 | |
| 234 | // ── Route templates ───────────────────────────────────────────── |
| 235 | |
| 236 | // A path segment that is an identifier rather than a route word: numbers, UUIDs, ULIDs, prefixed |
| 237 | // subject ids (usr_…), hex digests, and long mixed tokens. |
| 238 | const ID_SEGMENT = /^(?:\d+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9A-HJKMNP-TV-Z]{26}|[a-z]{2,5}_[0-9A-Za-z]{8,}|[0-9a-f]{12,}|(?=[^/]*\d)[A-Za-z0-9_-]{16,})$/i; |
| 239 | |
| 240 | /** Replace id-like segments of a concrete path with `:id` (query strings are dropped). */ |
| 241 | function templatePath(p) { |
| 242 | const clean = String(p || '').split('?')[0]; |
| 243 | return clean.split('/').map((seg) => (seg && ID_SEGMENT.test(seg) ? ':id' : seg)).join('/') || '/'; |
| 244 | } |
| 245 | |
| 246 | /** The route label of a finished request: the matched route's template, or 'unmatched'. */ |
| 247 | function routeLabel(req) { |
| 248 | if (!req.route || req.route.path === undefined) return 'unmatched'; |
| 249 | const rp = req.route.path; |
| 250 | // An array route ('/a', '/b') is labelled by the member that matched when it is a plain path. |
| 251 | const tail = rp instanceof RegExp ? '(regex)' : Array.isArray(rp) ? String(rp.find((p) => typeof p === 'string' && p === req.path) || rp[0]) : String(rp); |
| 252 | const base = req.baseUrl ? templatePath(req.baseUrl) : ''; |
| 253 | const joined = `${base}${tail === '/' && base ? '' : tail}`; |
| 254 | return (joined || '/').slice(0, MAX_LABEL_VALUE); |
| 255 | } |
| 256 | |
| 257 | // ── HTTP golden signals ───────────────────────────────────────── |
| 258 | |
| 259 | // skip(req): requests never recorded (long-lived streams such as SSE, whose "duration" is a session). |
| 260 | function httpMetrics(registry, { buckets = DEFAULT_BUCKETS, normalize = null, skip = null, maxRoutes = 300 } = {}) { |
| 261 | const requests = registry.counter({ name: 'http_requests_total', help: 'HTTP requests by method, route template and status class', labelNames: ['method', 'route', 'status_class'], maxSeries: maxRoutes * 4 }); |
| 262 | const duration = registry.histogram({ name: 'http_request_duration_seconds', help: 'HTTP request duration by method and route template', labelNames: ['method', 'route'], buckets, maxSeries: maxRoutes }); |
| 263 | const inFlight = registry.gauge({ name: 'http_requests_in_flight', help: 'HTTP requests being served' }); |
| 264 | inFlight.set(0); |
| 265 | const METHODS = new Set(['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']); |
| 266 | |
| 267 | function middleware(req, res, next) { |
| 268 | try { if (skip && skip(req)) return next(); } catch { /* record it */ } |
| 269 | const t0 = process.hrtime.bigint(); |
| 270 | inFlight.inc(); |
| 271 | let done = false; |
| 272 | const finish = () => { |
| 273 | if (done) return; |
| 274 | done = true; |
| 275 | inFlight.dec(); |
| 276 | let route; |
| 277 | try { route = normalize ? normalize(req, res) : null; } catch { route = null; } |
| 278 | if (!route) route = routeLabel(req); |
| 279 | const method = METHODS.has(req.method) ? req.method : 'OTHER'; |
| 280 | // Closed before the response finished: the client went away (or a stream was cut). |
| 281 | const status = res.writableFinished || res.finished ? `${Math.floor((res.statusCode || 0) / 100)}xx` : 'aborted'; |
| 282 | requests.inc({ method, route, status_class: status }); |
| 283 | duration.observe({ method, route }, Number(process.hrtime.bigint() - t0) / 1e9); |
| 284 | }; |
| 285 | res.once('finish', finish); |
| 286 | res.once('close', finish); |
| 287 | next(); |
| 288 | } |
| 289 | return { middleware, requests, duration, inFlight }; |
| 290 | } |
| 291 | |
| 292 | // ── Process metrics ───────────────────────────────────────────── |
| 293 | |
| 294 | function processMetrics(registry, { eventLoopResolutionMs = 20 } = {}) { |
| 295 | let loop = null; |
| 296 | try { |
| 297 | loop = require('perf_hooks').monitorEventLoopDelay({ resolution: eventLoopResolutionMs }); |
| 298 | loop.enable(); |
| 299 | } catch { loop = null; } |
| 300 | const startSeconds = Math.round((Date.now() - process.uptime() * 1000) / 1000); |
| 301 | const rss = registry.gauge({ name: 'process_resident_memory_bytes', help: 'Resident set size in bytes' }); |
| 302 | const heapUsed = registry.gauge({ name: 'nodejs_heap_used_bytes', help: 'V8 heap in use in bytes' }); |
| 303 | const heapTotal = registry.gauge({ name: 'nodejs_heap_total_bytes', help: 'V8 heap allocated in bytes' }); |
| 304 | const cpu = registry.gauge({ name: 'process_cpu_seconds_total', help: 'User and system CPU time spent, in seconds' }); |
| 305 | const uptime = registry.gauge({ name: 'process_uptime_seconds', help: 'Seconds since the process started' }); |
| 306 | registry.gauge({ name: 'process_start_time_seconds', help: 'Start time of the process since the Unix epoch, in seconds' }).set(startSeconds); |
| 307 | const lag = loop && registry.gauge({ name: 'nodejs_eventloop_lag_seconds', help: 'Event-loop delay since the previous scrape', labelNames: ['stat'] }); |
| 308 | registry.onScrape(() => { |
| 309 | const mem = process.memoryUsage(); |
| 310 | rss.set(mem.rss); |
| 311 | heapUsed.set(mem.heapUsed); |
| 312 | heapTotal.set(mem.heapTotal); |
| 313 | const c = process.cpuUsage(); |
| 314 | cpu.set((c.user + c.system) / 1e6); |
| 315 | uptime.set(Math.round(process.uptime())); |
| 316 | if (loop && lag) { |
| 317 | // The histogram is in nanoseconds and each sample includes the sampling interval itself |
| 318 | // (an idle loop reads ~resolution), so the interval is subtracted: what is left is lag. |
| 319 | // With no samples yet it reports NaN/huge values, so nothing is claimed. |
| 320 | const ok = loop.count > 0 || loop.max > 0; |
| 321 | const lagOf = (ns) => Math.max(0, ns / 1e9 - eventLoopResolutionMs / 1000); |
| 322 | if (ok) { |
| 323 | lag.set({ stat: 'mean' }, lagOf(loop.mean)); |
| 324 | lag.set({ stat: 'p99' }, lagOf(loop.percentile(99))); |
| 325 | lag.set({ stat: 'max' }, lagOf(loop.max)); |
| 326 | } |
| 327 | loop.reset(); |
| 328 | } |
| 329 | }); |
| 330 | return { stop: () => { if (loop) loop.disable(); } }; |
| 331 | } |
| 332 | |
| 333 | /** release_info{service,release} 1 — the deployed release, from openvibe-shared/release. */ |
| 334 | function releaseInfo(registry, { service, release }) { |
| 335 | const g = registry.gauge({ name: 'release_info', help: 'The release this process runs (value is always 1)', labelNames: ['service', 'release'] }); |
| 336 | g.set({ service: String(service || 'unknown'), release: String(release || 'unknown') }, 1); |
| 337 | return g; |
| 338 | } |
| 339 | |
| 340 | // ── /metrics ─────────────────────────────────────────────────── |
| 341 | |
| 342 | const LOOPBACK = /^(?:127\.\d+\.\d+\.\d+|::1|::ffff:127\.\d+\.\d+\.\d+)$/; |
| 343 | const PROXY_HEADERS = ['x-forwarded-for', 'x-real-ip', 'forwarded', 'cf-connecting-ip', 'true-client-ip']; |
| 344 | |
| 345 | /** True only for a direct loopback connection that no proxy relayed. */ |
| 346 | function isLoopbackDirect(req) { |
| 347 | const addr = String((req.socket && req.socket.remoteAddress) || (req.connection && req.connection.remoteAddress) || ''); |
| 348 | if (!LOOPBACK.test(addr)) return false; |
| 349 | const h = req.headers || {}; |
| 350 | return !PROXY_HEADERS.some((name) => h[name] !== undefined); |
| 351 | } |
| 352 | |
| 353 | function metricsHandler(registry) { |
| 354 | return async (req, res) => { |
| 355 | if (!isLoopbackDirect(req)) { |
| 356 | res.statusCode = 404; |
| 357 | res.setHeader('Content-Type', 'application/json; charset=utf-8'); |
| 358 | return res.end('{"error":"Not found"}'); |
| 359 | } |
| 360 | let body; |
| 361 | try { body = registry.metricsAsync ? await registry.metricsAsync() : registry.metrics(); } catch (err) { |
| 362 | res.statusCode = 500; |
| 363 | res.setHeader('Content-Type', 'text/plain; charset=utf-8'); |
| 364 | return res.end(`# metrics failed: ${String(err && err.message).slice(0, 200)}\n`); |
| 365 | } |
| 366 | res.statusCode = 200; |
| 367 | res.setHeader('Content-Type', registry.contentType); |
| 368 | res.setHeader('Cache-Control', 'no-store'); |
| 369 | res.end(body); |
| 370 | }; |
| 371 | } |
| 372 | |
| 373 | /** |
| 374 | * Everything at once, for an Express app: a registry with process metrics and release_info, the HTTP |
| 375 | * middleware mounted first, and GET <path> (default /metrics) for loopback callers. |
| 376 | * Call it before any route or body parser is mounted. |
| 377 | */ |
| 378 | function instrument(app, { service, release, path = '/metrics', normalize = null, skip = null, buckets, maxRoutes, registry = createRegistry() } = {}) { |
| 379 | const http = httpMetrics(registry, { normalize, skip, buckets, maxRoutes }); |
| 380 | const proc = processMetrics(registry); |
| 381 | releaseInfo(registry, { service, release }); |
| 382 | app.use(http.middleware); |
| 383 | app.get(path, metricsHandler(registry)); |
| 384 | // Every service instruments its app: an async handler's rejection reaches next(err) instead of ending the process. |
| 385 | require('./express-async').routeAsyncErrors(app); |
| 386 | return { registry, http, stop: proc.stop }; |
| 387 | } |
| 388 | |
| 389 | module.exports = { |
| 390 | createRegistry, |
| 391 | httpMetrics, |
| 392 | processMetrics, |
| 393 | releaseInfo, |
| 394 | metricsHandler, |
| 395 | isLoopbackDirect, |
| 396 | instrument, |
| 397 | routeLabel, |
| 398 | templatePath, |
| 399 | DEFAULT_BUCKETS, |
| 400 | }; |
| 401 |