penpot/backend/test/backend_tests/metrics_test.clj

166 lines
7.1 KiB
Clojure

;; 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 SUBSIDIARY SL
(ns backend-tests.metrics-test
(:require
[app.common.exceptions :as ex]
[app.metrics :as mtx]
[app.metrics.definition :as-alias mdef]
[clojure.test :as t]
[integrant.core :as ig]
[promesa.exec :as px])
(:import
io.prometheus.client.Collector$MetricFamilySamples
io.prometheus.client.Collector$MetricFamilySamples$Sample
io.prometheus.client.CollectorRegistry
java.util.concurrent.ScheduledThreadPoolExecutor))
(def ^:private valid-definitions? @#'app.metrics/valid-definitions?)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Helpers
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- sample-names
[metrics]
(->> (mtx/get-registry metrics)
(.metricFamilySamples)
(enumeration-seq)
(mapcat (fn [^Collector$MetricFamilySamples family]
(map (fn [^Collector$MetricFamilySamples$Sample sample]
(.-name sample))
(.samples family))))
(set)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Tests
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(t/deftest process-metrics-are-exported
;; the process cpu and file descriptor families come from the
;; prometheus client `StandardExports`, registered by `app.metrics`.
;; They are read reflectively from the OS MXBean and depend on the
;; `jdk.management` module at runtime: a pruned jlink JRE turns the
;; MXBean into `sun.management.BaseOperatingSystemImpl`, the reflective
;; getters fail and the families are silently dropped (that is how the
;; production backend lost `process_open_fds`). This test pins the
;; contract the fd alert relies on.
(let [metrics (ig/init-key :app.metrics/metrics {:default {}})
names (sample-names metrics)]
(t/is (contains? names "process_open_fds"))
(t/is (contains? names "process_max_fds"))
(t/is (contains? names "process_cpu_seconds_total"))))
(t/deftest label-coercion
(t/are [value fallback expected]
(= expected (mtx/label value fallback))
"fs" "unknown" "fs"
:s3 "unknown" "s3"
200 "unknown" "200"
nil "unknown" "unknown"
nil "default" "default"))
(t/deftest run-contains-label-arity-bug
;; A wrong label count throws inside the prometheus client; the
;; recording failure must not propagate to the caller.
(let [registry (CollectorRegistry.)
collector (mtx/create-collector {::mdef/name "penpot_test_run_arity"
::mdef/help "test helper"
::mdef/type :counter
::mdef/labels ["a" "b"]
:app.metrics/registry registry})
instance (reify mtx/IMetrics
(get-collector [_ _] collector))]
(t/is (nil? (mtx/run! instance :id :x :inc 1 :labels ["only-one"])))))
(t/deftest run-fails-loudly-without-metrics-instance
;; A missing or invalid instance is a wiring bug: it must fail on every
;; invocation instead of silently dropping the measurement. With
;; asserts enabled this is an AssertionError; with asserts disabled the
;; collector lookup (outside the recording guard) throws instead.
(t/is (thrown? Throwable (mtx/run! nil :id :x :inc 1)))
(t/is (thrown? Throwable (mtx/run! :not-metrics :id :x :inc 1))))
(t/deftest run-records-on-success
(let [registry (CollectorRegistry.)
collector (mtx/create-collector {::mdef/name "penpot_test_run"
::mdef/help "test helper"
::mdef/type :counter
::mdef/labels ["result"]
:app.metrics/registry registry})
instance (reify mtx/IMetrics
(get-collector [_ _] collector))]
(t/is (true? (mtx/run! instance :id :x :inc 1 :labels ["ok"])))))
(t/deftest instance-resolves-the-metrics-from-a-cfg
;; the resolver is a map read and nothing more: it hands back the very
;; instance, not a copy and not a wrapper
(let [metrics (ig/init-key :app.metrics/metrics {:default {}})]
(t/is (identical? metrics (mtx/instance {::mtx/metrics metrics})))))
(t/deftest instance-raises-on-a-cfg-without-metrics
;; A missing instance is a wiring bug, and the resolver is the one place
;; that says so: a module that resolves it at the top of a write sees the
;; failure before anything is persisted.
(let [cause (try
(mtx/instance {})
(catch Throwable e e))]
(t/is (some? cause))
(t/is (= :missing-metrics (:code (ex-data cause))))
(t/is (re-find #"::mtx/metrics" (:hint (ex-data cause))))))
(t/deftest definitions-schema-accepts-all-consumed-keys
(t/is (true? (valid-definitions?
{:test-timing
{::mdef/name "penpot_test_timing"
::mdef/help "test"
::mdef/type :histogram
::mdef/labels ["op"]
::mdef/buckets [5 10 25]}
:test-summary
{::mdef/name "penpot_test_summary"
::mdef/help "test"
::mdef/type :summary
::mdef/quantiles [[0.5 0.01]]
::mdef/max-age 60}}))))
(t/deftest definitions-schema-rejects-unknown-keys
;; A typo such as ::mdef/bucksets must fail at startup instead of
;; silently falling back to the default histogram buckets. The
;; definition map is closed, so unknown keys do not validate.
(t/is (false? (valid-definitions?
{:bad {::mdef/name "penpot_bad_metric"
::mdef/help "typo check"
::mdef/type :histogram
::mdef/bucksets [100]}}))))
(t/deftest create-sampler-reschedules-after-error
;; The docstring promise, tested once for both call sites: an unexpected error
;; on a single sample must not cancel the following runs. The first sample
;; runs immediately and throws; the next one must still be scheduled.
(let [calls (atom 0)
sampler (mtx/create-sampler 1000 (fn []
(swap! calls inc)
(throw (ex-info "boom" {}))))]
(try
(t/is (loop [i 0]
(cond (pos? @calls) true
(> i 200) false
:else (do (Thread/sleep 10) (recur (inc i)))))
"the first sample must run immediately")
(let [queue (.getQueue ^ScheduledThreadPoolExecutor sampler)]
(t/is (loop [i 0]
(cond (= 1 (.size queue)) true
(> i 200) false
:else (do (Thread/sleep 10) (recur (inc i)))))
"the next sample must be scheduled after the error"))
(finally
(px/shutdown-now sampler)))))