🔧 Add exporter metrics

This commit is contained in:
Elena Torro 2026-07-22 15:30:04 +02:00
parent 432de87a7e
commit c43969535b
4 changed files with 163 additions and 11 deletions

View File

@ -50,7 +50,11 @@
[:wasm-dir {:optional true} :string]
;; Byte budget (in MB) for the WASM image cache; least-recently-used
;; images are evicted between requests once the store exceeds it.
[:wasm-image-cache-mb {:optional true} ::sm/int]])
[:wasm-image-cache-mb {:optional true} ::sm/int]
;; Expose unauthenticated /metrics (Prometheus text format) with
;; per-render instrumentation. Development/measurement aid; off by
;; default so production deployments are unaffected.
[:metrics {:optional true} :boolean]])
(def ^:private decode-config
(sm/decoder schema:config sm/string-transformer))

View File

@ -15,6 +15,7 @@
[app.common.transit :as t]
[app.config :as cf]
[app.handlers :as handlers]
[app.metrics :as mtx]
[cuerdas.core :as str]
[lambdaisland.uri :as u]
[promesa.core :as p]))
@ -135,6 +136,21 @@
:response/body "OK")
(handler exchange))))
(defn- wrap-metrics
"Serve in-process render metrics on /metrics (Prometheus text format).
Unauthenticated, so it is opt-in via the `:metrics` config (PENPOT_METRICS
env var); when disabled the handler chain is left untouched."
[handler]
(if-not (cf/get :metrics)
handler
(fn [{:keys [:request/path] :as exchange}]
(if (= path "/metrics")
(assoc exchange
:response/status 200
:response/headers {"content-type" "text/plain; version=0.0.4"}
:response/body (mtx/export-text))
(handler exchange)))))
(defn- create-adapter
[handler]
(fn [req res]
@ -161,6 +177,7 @@
[]
(let [handler (-> handlers/handler
(wrap-health)
(wrap-metrics)
(wrap-auth "auth-token")
(wrap-response-format)
(wrap-params)

View File

@ -0,0 +1,127 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.metrics
"Minimal in-process Prometheus metrics (text exposition format, no
external deps). Records per-render counters and duration/CPU histograms
labeled by render backend (`wasm` vs `browser`) and export type, so the
headless and browser export paths can be compared in Grafana."
(:require
[cuerdas.core :as str]
[promesa.core :as p]))
(def ^:private bucket-bounds
[0.5 1 2.5 5 10 30 60 120])
;; {[backend type] {:count n :errors n :sum secs :cpu secs :buckets [n...]}}
(defonce ^:private state (atom {}))
(defn- empty-entry
[]
{:count 0
:errors 0
:sum 0
:cpu 0
:buckets (vec (repeat (count bucket-bounds) 0))})
(defn- update-entry
[entry duration cpu error?]
(let [entry (or entry (empty-entry))]
(-> entry
(update :count inc)
(cond-> error? (update :errors inc))
(update :sum + duration)
(update :cpu + cpu)
;; increment every bucket whose upper bound covers this duration;
;; that keeps the stored counts cumulative, as prometheus expects.
(update :buckets
(fn [buckets]
(vec (map-indexed
(fn [i count]
(if (<= duration (nth bucket-bounds i))
(inc count)
count))
buckets)))))))
(defn observe-render!
[backend type duration-s cpu-s error?]
(swap! state update [backend (name type)] update-entry duration-s cpu-s error?))
(defn- elapsed
"Seconds elapsed and process CPU seconds consumed since `t0`/`c0`."
[t0 c0]
(let [duration (/ (js/Number (- (js/process.hrtime.bigint) t0)) 1e9)
cusage (js/process.cpuUsage c0)
cpu (/ (+ (unchecked-get cusage "user")
(unchecked-get cusage "system"))
1e6)]
[duration cpu]))
(defn with-render-metrics
"Runs `thunk` (returning a promise) and records its wall-clock duration
and the process CPU delta under the given backend/type labels. CPU is a
whole-process delta, so concurrent renders bleed into each other's
numbers; fine for the low-concurrency comparison use case."
[backend type thunk]
(let [t0 (js/process.hrtime.bigint)
c0 (js/process.cpuUsage)]
(-> (p/do (thunk))
(p/then (fn [result]
(let [[duration cpu] (elapsed t0 c0)]
(observe-render! backend type duration cpu false)
result)))
(p/catch (fn [cause]
(let [[duration cpu] (elapsed t0 c0)]
(observe-render! backend type duration cpu true)
(p/rejected cause)))))))
(defn- labels-str
[backend type & extra]
(str "{backend=\"" backend "\",type=\"" type "\""
(str/join "" extra)
"}"))
(defn export-text
"Renders all recorded metrics in the Prometheus text exposition format."
[]
(let [entries @state
lines
(concat
["# HELP penpot_exporter_render_total Total renders processed."
"# TYPE penpot_exporter_render_total counter"]
(for [[[backend type] entry] entries]
(str "penpot_exporter_render_total" (labels-str backend type) " " (:count entry)))
["# HELP penpot_exporter_render_errors_total Renders that failed."
"# TYPE penpot_exporter_render_errors_total counter"]
(for [[[backend type] entry] entries]
(str "penpot_exporter_render_errors_total" (labels-str backend type) " " (:errors entry)))
["# HELP penpot_exporter_render_cpu_seconds_total Process CPU consumed during renders."
"# TYPE penpot_exporter_render_cpu_seconds_total counter"]
(for [[[backend type] entry] entries]
(str "penpot_exporter_render_cpu_seconds_total" (labels-str backend type) " " (:cpu entry)))
["# HELP penpot_exporter_render_duration_seconds Render wall-clock duration."
"# TYPE penpot_exporter_render_duration_seconds histogram"]
(mapcat
(fn [[[backend type] entry]]
(concat
(map-indexed
(fn [i bound]
(str "penpot_exporter_render_duration_seconds_bucket"
(labels-str backend type ",le=\"" bound "\"")
" " (nth (:buckets entry) i)))
bucket-bounds)
[(str "penpot_exporter_render_duration_seconds_bucket"
(labels-str backend type ",le=\"+Inf\"")
" " (:count entry))
(str "penpot_exporter_render_duration_seconds_sum"
(labels-str backend type) " " (:sum entry))
(str "penpot_exporter_render_duration_seconds_count"
(labels-str backend type) " " (:count entry))]))
entries))]
(str (str/join "\n" lines) "\n")))

View File

@ -10,6 +10,7 @@
[app.common.logging :as l]
[app.common.spec :as us]
[app.config :as cf]
[app.metrics :as mtx]
[app.renderer.bitmap :as rb]
[app.renderer.pdf :as rp]
[app.renderer.svg :as rs]
@ -45,18 +46,21 @@
;; Opt-in headless path: when an export is flagged `:is-wasm` AND the
;; `:wasm-headless` config is enabled, render with the in-process Skia/WASM
;; pipeline (no browser). Off by default, so existing behavior is unchanged.
(let [headless? (and is-wasm (cf/get :wasm-headless))]
(let [headless? (and is-wasm (cf/get :wasm-headless))
backend (if headless? "wasm" "browser")]
(l/info :hint "render"
:type type
:is-wasm (boolean is-wasm)
:wasm-headless (boolean (cf/get :wasm-headless))
:backend (if headless? "wasm" "browser"))
(if headless?
(rw/render params on-object)
(case type
:png (rb/render params on-object)
:jpeg (rb/render params on-object)
:webp (rb/render params on-object)
:pdf (rp/render params on-object)
:svg (rs/render params on-object)))))
:backend backend)
(mtx/with-render-metrics backend type
(fn []
(if headless?
(rw/render params on-object)
(case type
:png (rb/render params on-object)
:jpeg (rb/render params on-object)
:webp (rb/render params on-object)
:pdf (rp/render params on-object)
:svg (rs/render params on-object)))))))