corpus

Log | Files | Refs | README | LICENSE

commit 8008ede24ab1dde56c14ed552d91babc7a473269
parent 1bec5be07224d26f8e0c478aa33dd7ccf263846a
Author: mtmn <miro@haravara.org>
Date:   Sat, 18 Apr 2026 17:35:33 +0200

fix: update docs; add metrics switch, fixes

.

Diffstat:
Mdocs/architecture.md | 32++++++++++++++++++++++++++++++++
Mdocs/duckdb.md | 2+-
Msrc/Config.purs | 5++++-
Msrc/Db.purs | 37++++++++++++++++++++-----------------
Msrc/Main.purs | 77+++++++++++++++++++++++++++--------------------------------------------------
Msrc/Metrics.js | 216+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
6 files changed, 208 insertions(+), 161 deletions(-)

diff --git a/docs/architecture.md b/docs/architecture.md @@ -11,6 +11,7 @@ The server is built with PureScript running on Node.js. It handles several core - **Last.fm Sync**: A background process that polls the Last.fm API every 60 seconds to fetch new scrobbles. Both syncs write to the same `scrobbles` table; duplicate timestamps are silently ignored. - **Metadata Enrichment**: A background process that identifies scrobbles with missing metadata (genres, labels, release years) and fetches information from MusicBrainz, Last.fm, and Discogs. - **Cover Art Proxy**: A specialized endpoint that fetches, caches, and serves cover art, utilizing a multi-source fallback strategy (CAA → Last.fm → Discogs). +- **Observability**: Prometheus metrics exposed at `/metrics` and optional OpenTelemetry tracing of all HTTP requests. ### Frontend A Single Page Application (SPA) built with [Elm](https://elm-lang.org). @@ -36,6 +37,8 @@ Corpus runs as a single server process serving multiple users. User configuratio ### Routing - `/` and `/~<slug>` — serve the Elm SPA for the root user and named users respectively - `/proxy?user=<slug>`, `/stats?user=<slug>`, `/cover?user=<slug>` — shared API endpoints, user selected via query parameter +- `/healthz?user=<slug>` — liveness check; pings the user's DuckDB connection +- `/metrics` — Prometheus metrics (no user parameter; covers all users; only available when `METRICS_ENABLED=true`) ### Configuration User configuration is split into two layers: @@ -63,6 +66,9 @@ Each user gets their own `UserContext` with an independent DuckDB connection, sy | `AWS_ENDPOINT_URL` | — | S3 endpoint (for S3-compatible storage) | | `AWS_S3_ADDRESSING_STYLE` | — | `virtual` or `path` | | `PORT` | `8000` | HTTP listen port | +| `METRICS_ENABLED` | `false` | Set to `true` to enable the Prometheus `/metrics` endpoint | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | — | If set, enables OpenTelemetry tracing and exports spans to this OTLP HTTP endpoint | +| `OTEL_SERVICE_NAME` | `corpus` | Service name reported in OTel spans | ### users.dhall Fields @@ -110,6 +116,31 @@ When a cover is requested: - Final fallback to **Discogs** search API. 3. If found in any source, the image is proxied to the client and uploaded to S3 in the background. +## Observability + +### Prometheus Metrics + +Prometheus metrics are **disabled by default**. Set `METRICS_ENABLED=true` to enable them. When enabled, all HTTP requests are instrumented via `Metrics.wrapRequest` and background work is tracked with dedicated counters and gauges. When disabled, the `/metrics` endpoint returns 404 and all metric-increment calls are no-ops with no runtime overhead. + +| Metric | Type | Labels | Description | +|---|---|---|---| +| `corpus_http_requests_total` | Counter | `method`, `path`, `status` | HTTP requests | +| `corpus_http_request_duration_seconds` | Histogram | `method`, `path` | HTTP request latency | +| `corpus_sync_runs_total` | Counter | `user`, `source`, `result` | Sync loop iterations | +| `corpus_sync_scrobbles_added_total` | Counter | `user`, `source` | Scrobbles inserted per sync | +| `corpus_sync_last_success_seconds` | Gauge | `user`, `source` | Timestamp of last successful sync | +| `corpus_enrichment_fetches_total` | Counter | `user`, `source`, `result` | Metadata enrichment API calls | +| `corpus_enrichment_queue_size` | Gauge | `user`, `type` | Releases pending enrichment | +| `corpus_cover_requests_total` | Counter | `user`, `source`, `result` | Cover art requests | +| `corpus_db_backup_runs_total` | Counter | `user`, `result` | Database backup runs | +| `corpus_db_backup_last_success_seconds` | Gauge | `user` | Timestamp of last successful backup | + +Node.js default metrics (GC, event loop, memory) are also collected via `prom-client`'s `collectDefaultMetrics`. + +### OpenTelemetry Tracing + +Tracing is opt-in: the OTel SDK starts only when `OTEL_EXPORTER_OTLP_ENDPOINT` is set. Spans are exported over OTLP/HTTP. Each incoming HTTP request becomes a server span; outbound `fetch`/`undici` calls made during that request automatically become child spans via the `HttpInstrumentation` and `UndiciInstrumentation` auto-instrumentation packages. W3C `traceparent` headers are extracted from incoming requests for distributed trace propagation. + ## Tech Stack - **Language**: [PureScript](https://purescript.org) (server), [Elm](https://elm-lang.org) (frontend) @@ -127,6 +158,7 @@ Corpus relies on FFI to interact with the Node.js ecosystem where native PureScr - **Cloud Storage (`S3.js`)**: AWS SDK (`@aws-sdk/client-s3`) for cover art caching. Takes explicit config structs rather than reading `process.env`. - **System Utilities (`Main.js`)**: Bridges PureScript with Node.js — `dotenv` loading and request helpers. - **Config (`Config.js`)**: Reads and parses `users.json` from the path given by `CORPUS_USERS_FILE`. +- **Observability (`Metrics.js`)**: Initialises `prom-client` (Prometheus) and the OpenTelemetry Node SDK. Exports metric-increment helpers called from PureScript and the `wrapRequest` function that wraps each HTTP handler in an OTel server span. ## System Flow diff --git a/docs/duckdb.md b/docs/duckdb.md @@ -7,7 +7,7 @@ Corpus uses [DuckDB](https://duckdb.org/) as its primary analytical database. Du The database consists of two main tables: ### `scrobbles` -Stores the raw listening history synced from ListenBrainz. +Stores the raw listening history synced from ListenBrainz and/or Last.fm. | Column | Type | Description | | :--- | :--- | :--- | diff --git a/src/Config.purs b/src/Config.purs @@ -40,6 +40,7 @@ type UserEntry = type AppConfig = { port :: Int + , metricsEnabled :: Boolean , users :: Array UserEntry } @@ -88,6 +89,7 @@ loadConfig path = do awsEndpointUrl <- liftEffect $ lookupEnv "AWS_ENDPOINT_URL" awsS3AddressingStyle <- liftEffect $ lookupEnv "AWS_S3_ADDRESSING_STYLE" databasePath <- liftEffect $ lookupEnv "DATABASE_PATH" + metricsEnabledStr <- liftEffect $ lookupEnv "METRICS_ENABLED" defaultPath <- liftEffect cwd let resolvePath file = case databasePath of @@ -106,7 +108,8 @@ loadConfig path = do } let port = fromMaybe 8000 (portStr >>= Data.Int.fromString) - fullConfig = { port, users: map (\u -> u { config = fillCreds u.config }) rawUsers } + metricsEnabled = metricsEnabledStr == Just "true" + fullConfig = { port, metricsEnabled, users: map (\u -> u { config = fillCreds u.config }) rawUsers } case validateConfig fullConfig of Left msg -> makeAff \cb -> cb (Left (error msg)) *> pure nonCanceler Right cfg -> pure cfg diff --git a/src/Db.purs b/src/Db.purs @@ -98,21 +98,25 @@ backupDb conn dbFile s3cfg intervalMs slug = forever do Right _ -> pure unit -- Acquires the write lock, runs the action inside a transaction, then releases. --- If the action throws, the transaction is rolled back before the lock is released. +-- The lock is always released: on success, on action failure (rollback), and on +-- BEGIN/COMMIT failure. This prevents deadlock if the database throws unexpectedly. withTransaction :: forall a. Connection -> AVar Unit -> Aff a -> Aff a withTransaction conn lock action = do Avar.take lock - run conn "BEGIN TRANSACTION" [] - result <- try action + result <- try do + run conn "BEGIN TRANSACTION" [] + r <- try action + case r of + Left err -> do + void $ try $ run conn "ROLLBACK" [] + throwError err + Right v -> do + run conn "COMMIT" [] + pure v + Avar.put unit lock case result of - Left err -> do - void $ try $ run conn "ROLLBACK" [] - Avar.put unit lock - throwError err - Right r -> do - run conn "COMMIT" [] - Avar.put unit lock - pure r + Left err -> throwError err + Right v -> pure v queryAll :: Connection -> String -> Array Foreign -> Aff (Array Json) queryAll conn sql params = makeAff \cb -> do @@ -129,11 +133,9 @@ initDb conn = do checkExists :: Connection -> Int -> Aff Boolean checkExists conn ts = do rows <- queryAll conn "SELECT 1 FROM scrobbles WHERE listened_at = ?" [ unsafeCoerce ts ] - pure $ fromMaybe false $ do - arr <- Just rows - case arr of - [] -> Just false - _ -> Just true + pure case rows of + [] -> false + _ -> true getOldestTs :: Connection -> Aff (Maybe Int) getOldestTs conn = do @@ -141,7 +143,8 @@ getOldestTs conn = do pure $ do row <- rows !! 0 obj <- toObject row - ts <- Object.lookup "min_ts" obj >>= (unsafeCoerce >>> Just) + v <- Object.lookup "min_ts" obj + ts <- toMaybe (unsafeCoerce v :: Nullable Int) if ts == 0 then Nothing else Just ts upsertScrobble :: Connection -> Listen -> Aff Unit diff --git a/src/Main.purs b/src/Main.purs @@ -70,9 +70,8 @@ type UserContext = listenBrainzUrl :: String -> String listenBrainzUrl username = "https://api.listenbrainz.org/1/user/" <> username <> "/listens" -fetchListenBrainzData :: String -> Int -> Aff String -fetchListenBrainzData username count = withRetry "ListenBrainz fetch" $ makeAff \callback -> do - let url = listenBrainzUrl username <> "?count=" <> show count +fetchListenBrainzUrl :: String -> Aff String +fetchListenBrainzUrl url = withRetry "ListenBrainz fetch" $ makeAff \callback -> do req <- HTTPS.get url req # on_ Client.responseH \res -> do @@ -86,35 +85,7 @@ fetchListenBrainzData username count = withRetry "ListenBrainz fetch" $ makeAff callback (Left $ Exception.error $ "ListenBrainz API returned status " <> show statusCode) let errorH = EventHandle "error" mkEffectFn1 - on_ errorH - ( \err -> do - callback (Left err) - ) - (unsafeCoerce req) - - pure nonCanceler - -fetchListenBrainzDataBefore :: String -> Int -> Aff String -fetchListenBrainzDataBefore username maxTs = withRetry "ListenBrainz fetch" $ makeAff \callback -> do - let url = listenBrainzUrl username <> "?count=100&max_ts=" <> show maxTs - req <- HTTPS.get url - - req # on_ Client.responseH \res -> do - launchAff_ do - body <- readableToStringUtf8 (IM.toReadable res) - let statusCode = IM.statusCode res - liftEffect $ - if statusCode == 200 then - callback (Right body) - else - callback (Left $ Exception.error $ "ListenBrainz API returned status " <> show statusCode) - - let errorH = EventHandle "error" mkEffectFn1 - on_ errorH - ( \err -> do - callback (Left err) - ) - (unsafeCoerce req) + on_ errorH (\err -> callback (Left err)) (unsafeCoerce req) pure nonCanceler @@ -194,7 +165,7 @@ lbSyncOnce conn username slug writeLock initialSyncEnabled = void performFullSyn where performFullSync = do when initialSyncEnabled $ Log.info $ "Starting ListenBrainz sync for " <> username - result <- try $ fetchListenBrainzData username 100 + result <- try $ fetchListenBrainzUrl (listenBrainzUrl username <> "?count=100") case result of Right body -> do case parseJson body >>= decodeJson of @@ -209,15 +180,11 @@ lbSyncOnce conn username slug writeLock initialSyncEnabled = void performFullSyn let allExist = hitCount == length listens && length listens > 0 if allExist || length listens == 0 || not initialSyncEnabled then do when (added > 0) $ Log.info $ "ListenBrainz sync complete. Added " <> show added <> " new scrobbles." - liftEffect $ Metrics.incSyncRuns slug "listenbrainz" "success" - when (added > 0) $ liftEffect $ Metrics.incSyncScrobbles slug "listenbrainz" added - liftEffect $ Metrics.setSyncLastSuccess slug "listenbrainz" + recordSuccess added else do total <- paginateUntilDone 2 minTs added Log.info $ "ListenBrainz sync complete. Added " <> show total <> " new scrobbles." - liftEffect $ Metrics.incSyncRuns slug "listenbrainz" "success" - when (total > 0) $ liftEffect $ Metrics.incSyncScrobbles slug "listenbrainz" total - liftEffect $ Metrics.setSyncLastSuccess slug "listenbrainz" + recordSuccess total Left err -> do Log.error $ "Sync fetch error: " <> Exception.message err liftEffect $ Metrics.incSyncRuns slug "listenbrainz" "error" @@ -226,7 +193,7 @@ lbSyncOnce conn username slug writeLock initialSyncEnabled = void performFullSyn Nothing -> pure acc Just ts -> do Log.info $ "Fetching ListenBrainz batch " <> show batchNum <> " (before " <> show ts <> ")..." - result <- try $ fetchListenBrainzDataBefore username ts + result <- try $ fetchListenBrainzUrl (listenBrainzUrl username <> "?count=100&max_ts=" <> show ts) case result of Right body -> do case parseJson body >>= decodeJson of @@ -245,6 +212,11 @@ lbSyncOnce conn username slug writeLock initialSyncEnabled = void performFullSyn Log.error $ "Sync fetch error: " <> Exception.message err pure acc + recordSuccess n = do + liftEffect $ Metrics.incSyncRuns slug "listenbrainz" "success" + when (n > 0) $ liftEffect $ Metrics.incSyncScrobbles slug "listenbrainz" n + liftEffect $ Metrics.setSyncLastSuccess slug "listenbrainz" + processListens listens = do syncRecursive 0 Nothing 0 listens @@ -283,15 +255,11 @@ lfSyncOnce conn apiKey lfmUser slug writeLock initialSyncEnabled = do allExist = hitCount == length validTracks && length validTracks > 0 if allExist || totalPages <= 1 || not initialSyncEnabled then do when (added > 0) $ Log.info $ "Last.fm sync complete. Added " <> show added <> " new scrobbles." - liftEffect $ Metrics.incSyncRuns slug "lastfm" "success" - when (added > 0) $ liftEffect $ Metrics.incSyncScrobbles slug "lastfm" added - liftEffect $ Metrics.setSyncLastSuccess slug "lastfm" + recordSuccess added else do total <- paginateLastfmUntilDone 2 totalPages Nothing added Log.info $ "Last.fm sync complete. Added " <> show total <> " new scrobbles." - liftEffect $ Metrics.incSyncRuns slug "lastfm" "success" - when (total > 0) $ liftEffect $ Metrics.incSyncScrobbles slug "lastfm" total - liftEffect $ Metrics.setSyncLastSuccess slug "lastfm" + recordSuccess total paginateLastfmUntilDone page totalPages mTo acc | page > totalPages = pure acc @@ -323,6 +291,11 @@ lfSyncOnce conn apiKey lfmUser slug writeLock initialSyncEnabled = do total <- paginateLastfmUntilDone 2 totalPages (Just (oldestTs - 1)) added Log.info $ "Last.fm backfill complete. Added " <> show total <> " older scrobbles." + recordSuccess n = do + liftEffect $ Metrics.incSyncRuns slug "lastfm" "success" + when (n > 0) $ liftEffect $ Metrics.incSyncScrobbles slug "lastfm" n + liftEffect $ Metrics.setSyncLastSuccess slug "lastfm" + processLastfmTracks tracks = syncLastfmRecursive 0 0 (mapMaybe lastfmTrackToListen tracks) syncLastfmRecursive acc hitCount listens = case uncons listens of @@ -795,8 +768,8 @@ normalizePath path = case stripPrefix (Pattern "/~") path of -- Request handler -- API endpoints (/proxy, /stats, /cover, /healthz) select the user via ?user=<slug>. -- Index pages are served at / (root user) and /~<slug> (named users). -handleRequest :: Array UserContext -> Request -> Response -> Effect Unit -handleRequest contexts req res = do +handleRequest :: Boolean -> Array UserContext -> Request -> Response -> Effect Unit +handleRequest metricsEnabled contexts req res = do let method = IM.method req let rawUrl = IM.url req case URL.fromRelative rawUrl "http://localhost" of @@ -808,7 +781,11 @@ handleRequest contexts req res = do "/client.js" -> serveClientJs res "/favicon.png" -> serveAsset "image/png" "assets/favicon.png" res "/" -> serveIndex "" res - "/metrics" -> serveMetrics res + "/metrics" -> + if metricsEnabled then serveMetrics res + else do + Log.warn "Path not found: /metrics" + serveNotFound res "/proxy" -> withUser url \ctx -> serveProxy ctx.conn url res "/stats" -> withUser url \ctx -> serveStats ctx.conn url res "/cover" -> withUser url \ctx -> serveCover ctx.config ctx.slug url res @@ -1352,7 +1329,7 @@ main = do contexts <- traverse startUser appConfig.users liftEffect $ do server <- createServer - server # on_ Server.requestH (handleRequest contexts) + server # on_ Server.requestH (handleRequest appConfig.metricsEnabled contexts) let netServer = Server.toNetServer server netServer # on_ listeningH do diff --git a/src/Metrics.js b/src/Metrics.js @@ -18,83 +18,98 @@ import { } from "@opentelemetry/api"; // --------------------------------------------------------------------------- -// Prometheus metrics +// Prometheus metrics — only active when METRICS_ENABLED=true // --------------------------------------------------------------------------- -const registry = new Registry(); - -collectDefaultMetrics({ register: registry }); - -const httpRequestsTotal = new Counter({ - name: "corpus_http_requests_total", - help: "Total number of HTTP requests", - labelNames: ["method", "path", "status"], - registers: [registry], -}); - -const httpRequestDurationSeconds = new Histogram({ - name: "corpus_http_request_duration_seconds", - help: "HTTP request duration in seconds", - labelNames: ["method", "path"], - buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5], - registers: [registry], -}); - -const syncRunsTotal = new Counter({ - name: "corpus_sync_runs_total", - help: "Total number of sync runs", - labelNames: ["user", "source", "result"], - registers: [registry], -}); - -const syncScrobblesAddedTotal = new Counter({ - name: "corpus_sync_scrobbles_added_total", - help: "Total scrobbles added during syncs", - labelNames: ["user", "source"], - registers: [registry], -}); - -const syncLastSuccessSeconds = new Gauge({ - name: "corpus_sync_last_success_seconds", - help: "Unix timestamp of last successful sync run", - labelNames: ["user", "source"], - registers: [registry], -}); - -const enrichmentFetchesTotal = new Counter({ - name: "corpus_enrichment_fetches_total", - help: "Total metadata enrichment fetches by user, source, and result", - labelNames: ["user", "source", "result"], - registers: [registry], -}); - -const enrichmentQueueSize = new Gauge({ - name: "corpus_enrichment_queue_size", - help: "Number of releases pending metadata enrichment per user", - labelNames: ["user", "type"], - registers: [registry], -}); - -const coverRequestsTotal = new Counter({ - name: "corpus_cover_requests_total", - help: "Total cover art requests by user, source, and result", - labelNames: ["user", "source", "result"], - registers: [registry], -}); - -const dbBackupRunsTotal = new Counter({ - name: "corpus_db_backup_runs_total", - help: "Total database backup runs", - labelNames: ["user", "result"], - registers: [registry], -}); - -const dbBackupLastSuccessSeconds = new Gauge({ - name: "corpus_db_backup_last_success_seconds", - help: "Unix timestamp of last successful database backup", - labelNames: ["user"], - registers: [registry], -}); +const metricsEnabled = process.env.METRICS_ENABLED === "true"; + +let registry = null; +let httpRequestsTotal = null; +let httpRequestDurationSeconds = null; +let syncRunsTotal = null; +let syncScrobblesAddedTotal = null; +let syncLastSuccessSeconds = null; +let enrichmentFetchesTotal = null; +let enrichmentQueueSize = null; +let coverRequestsTotal = null; +let dbBackupRunsTotal = null; +let dbBackupLastSuccessSeconds = null; + +if (metricsEnabled) { + registry = new Registry(); + collectDefaultMetrics({ register: registry }); + + httpRequestsTotal = new Counter({ + name: "corpus_http_requests_total", + help: "Total number of HTTP requests", + labelNames: ["method", "path", "status"], + registers: [registry], + }); + + httpRequestDurationSeconds = new Histogram({ + name: "corpus_http_request_duration_seconds", + help: "HTTP request duration in seconds", + labelNames: ["method", "path"], + buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5], + registers: [registry], + }); + + syncRunsTotal = new Counter({ + name: "corpus_sync_runs_total", + help: "Total number of sync runs", + labelNames: ["user", "source", "result"], + registers: [registry], + }); + + syncScrobblesAddedTotal = new Counter({ + name: "corpus_sync_scrobbles_added_total", + help: "Total scrobbles added during syncs", + labelNames: ["user", "source"], + registers: [registry], + }); + + syncLastSuccessSeconds = new Gauge({ + name: "corpus_sync_last_success_seconds", + help: "Unix timestamp of last successful sync run", + labelNames: ["user", "source"], + registers: [registry], + }); + + enrichmentFetchesTotal = new Counter({ + name: "corpus_enrichment_fetches_total", + help: "Total metadata enrichment fetches by user, source, and result", + labelNames: ["user", "source", "result"], + registers: [registry], + }); + + enrichmentQueueSize = new Gauge({ + name: "corpus_enrichment_queue_size", + help: "Number of releases pending metadata enrichment per user", + labelNames: ["user", "type"], + registers: [registry], + }); + + coverRequestsTotal = new Counter({ + name: "corpus_cover_requests_total", + help: "Total cover art requests by user, source, and result", + labelNames: ["user", "source", "result"], + registers: [registry], + }); + + dbBackupRunsTotal = new Counter({ + name: "corpus_db_backup_runs_total", + help: "Total database backup runs", + labelNames: ["user", "result"], + registers: [registry], + }); + + dbBackupLastSuccessSeconds = new Gauge({ + name: "corpus_db_backup_last_success_seconds", + help: "Unix timestamp of last successful database backup", + labelNames: ["user"], + registers: [registry], + }); +} // --------------------------------------------------------------------------- // OpenTelemetry — only active when OTEL_EXPORTER_OTLP_ENDPOINT is set @@ -151,13 +166,18 @@ function endHttpSpan(span, statusCode) { // --------------------------------------------------------------------------- export const getMetricsImpl = (onSuccess) => (onError) => () => { + if (!registry) { + onSuccess("")(); + return; + } registry.metrics().then( (s) => onSuccess(s)(), (err) => onError(err.message)(), ); }; -export const getContentType = () => registry.contentType; +export const getContentType = () => + registry ? registry.contentType : "text/plain; version=0.0.4; charset=utf-8"; // Runs `handler` inside the active context of a server span so that any // outbound HTTP/fetch calls made during handling automatically become child @@ -184,26 +204,38 @@ export const wrapRequest = } }; -export const incSyncRuns = (user) => (source) => (result) => () => - syncRunsTotal.inc({ user, source, result }); +export const incSyncRuns = (user) => (source) => (result) => () => { + if (syncRunsTotal) syncRunsTotal.inc({ user, source, result }); +}; -export const incSyncScrobbles = (user) => (source) => (count) => () => - syncScrobblesAddedTotal.inc({ user, source }, count); +export const incSyncScrobbles = (user) => (source) => (count) => () => { + if (syncScrobblesAddedTotal) + syncScrobblesAddedTotal.inc({ user, source }, count); +}; -export const setSyncLastSuccess = (user) => (source) => () => - syncLastSuccessSeconds.setToCurrentTime({ user, source }); +export const setSyncLastSuccess = (user) => (source) => () => { + if (syncLastSuccessSeconds) + syncLastSuccessSeconds.setToCurrentTime({ user, source }); +}; -export const incEnrichmentFetch = (user) => (source) => (result) => () => - enrichmentFetchesTotal.inc({ user, source, result }); +export const incEnrichmentFetch = (user) => (source) => (result) => () => { + if (enrichmentFetchesTotal) + enrichmentFetchesTotal.inc({ user, source, result }); +}; -export const setEnrichmentQueueSize = (user) => (type) => (size) => () => - enrichmentQueueSize.set({ user, type }, size); +export const setEnrichmentQueueSize = (user) => (type) => (size) => () => { + if (enrichmentQueueSize) enrichmentQueueSize.set({ user, type }, size); +}; -export const incCoverRequest = (user) => (source) => (result) => () => - coverRequestsTotal.inc({ user, source, result }); +export const incCoverRequest = (user) => (source) => (result) => () => { + if (coverRequestsTotal) coverRequestsTotal.inc({ user, source, result }); +}; -export const incDbBackupRun = (user) => (result) => () => - dbBackupRunsTotal.inc({ user, result }); +export const incDbBackupRun = (user) => (result) => () => { + if (dbBackupRunsTotal) dbBackupRunsTotal.inc({ user, result }); +}; -export const setDbBackupLastSuccess = (user) => () => - dbBackupLastSuccessSeconds.setToCurrentTime({ user }); +export const setDbBackupLastSuccess = (user) => () => { + if (dbBackupLastSuccessSeconds) + dbBackupLastSuccessSeconds.setToCurrentTime({ user }); +};