From c43969535b82596c8ad8429fe69ffee6f93eb235 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Wed, 22 Jul 2026 15:30:04 +0200 Subject: [PATCH] :wrench: Add exporter metrics --- exporter/src/app/config.cljs | 6 +- exporter/src/app/http.cljs | 17 +++++ exporter/src/app/metrics.cljs | 127 +++++++++++++++++++++++++++++++++ exporter/src/app/renderer.cljs | 24 ++++--- 4 files changed, 163 insertions(+), 11 deletions(-) create mode 100644 exporter/src/app/metrics.cljs diff --git a/exporter/src/app/config.cljs b/exporter/src/app/config.cljs index 9d5b9a9fff..f4782ed0e2 100644 --- a/exporter/src/app/config.cljs +++ b/exporter/src/app/config.cljs @@ -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)) diff --git a/exporter/src/app/http.cljs b/exporter/src/app/http.cljs index 824f22d5cd..d62a16ba2b 100644 --- a/exporter/src/app/http.cljs +++ b/exporter/src/app/http.cljs @@ -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) diff --git a/exporter/src/app/metrics.cljs b/exporter/src/app/metrics.cljs new file mode 100644 index 0000000000..2756dfbefa --- /dev/null +++ b/exporter/src/app/metrics.cljs @@ -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"))) diff --git a/exporter/src/app/renderer.cljs b/exporter/src/app/renderer.cljs index ae54133dcd..234eeb4c0a 100644 --- a/exporter/src/app/renderer.cljs +++ b/exporter/src/app/renderer.cljs @@ -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)))))))