diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e60f16..d0d2245 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Step context now goes through [trove](https://github.com/taoensso/trove)'s own context API instead of a `dbos-clj`-specific hook. Every step body runs inside `trove/with-ctx+ {:workflow/step ""}`, so any `trove/log!` in a step carries the step name with no setup at all. +- **Requires trove 1.2.0 or later**, which is where that context API landed. +- **Breaking:** the `:step/start` log no longer carries `:data {:workflow/step ""}`. The step name now rides in trove's `:ctx`, alongside every other log emitted in the step body, so read it from `(:ctx payload)` instead. Anything matching on `:data` goes quietly nil. + +### Removed + +- **Breaking:** `set-step-ctx-wrapper!` and the `*step-ctx-wrapper*` dynamic var. To tag *native* backend calls — a bare Telemere `t/log!`, a `μ/log`, an MDC-aware SLF4J layout — opt into trove's context bridge when building your log-fn instead: + + ```clojure + (trove/set-log-fn! (trove-telemere/get-log-fn {:bridge-ctx? true})) + ``` + + One line where there were two, and the step name reaches your backend's own context without `dbos-clj` knowing which backend you picked. Bridging is supported by the Telemere, Timbre, μ/log and SLF4J (MDC-capable provider) backends. + ## [0.3.0] - 2026-07-25 ### Added diff --git a/README.md b/README.md index d184efe..d4de0f1 100644 --- a/README.md +++ b/README.md @@ -641,21 +641,21 @@ Trove ships a `get-log-fn` per backend - `taoensso.trove.telemere`, `.timbre`, ` (call-api :post "/some-url"))) ; want call-api's log tagged with this step ``` -`set-step-ctx-wrapper!` closes that gap. Give it a `(fn [ctx thunk])` that installs `ctx` into your backend's *own* context and then runs `thunk`; `dbos-clj` applies it around every step body, passing `{:workflow/step "call-the-api"}`: +Trove's context API closes that gap. `dbos-clj` binds `trove/*ctx*` to `{:workflow/step "call-the-api"}` around every step body, so any `trove/log!` made inside a step carries the step name for free. + +To tag *native* backend calls too — a bare `t/log!`, a `μ/log`, an MDC-aware SLF4J layout — opt into trove's context bridge when you build your log-fn: ```clojure ;; Telemere -(dbos/set-step-ctx-wrapper! - (fn [ctx thunk] (taoensso.telemere/with-ctx+ ctx (thunk)))) +(trove/set-log-fn! (trove-telemere/get-log-fn {:bridge-ctx? true})) ;; μ/log -(dbos/set-step-ctx-wrapper! - (fn [ctx thunk] (com.brunobonacci.mulog/with-context ctx (thunk)))) +(trove/set-log-fn! (trove-mulog/get-log-fn {:bridge-ctx? true})) ``` `call-api` now logs with `:workflow/step "call-the-api"` attached, without knowing anything about DBOS. -Defaults to a no-op, so nothing is injected into your backend unless you opt in. +The bridge is off by default, so nothing is injected into your backend's own context unless you ask for it. It is supported by the Telemere, Timbre, μ/log and SLF4J (MDC-capable provider) backends. ## Development @@ -689,7 +689,7 @@ bb test --focus my-test # extra args go straight to kaocha `dev.clj`-style wiring lives in `dbos.dev-logging` (under `test/`, so it's on the classpath for both the REPL and the test runner). It is installed automatically by `dev/user.clj` in the REPL, and by a kaocha `pre-load` hook for tests. It does two things: -- points **trove** — the facade the library logs steps through — at Telemere, so you see a `:step/start` signal per step, and any `t/log!` inside a step body inherits `:workflow/step` as context; +- points **trove** — the facade the library logs steps through — at Telemere with `{:bridge-ctx? true}`, so you see a `:step/start` signal per step, and any `t/log!` inside a step body inherits `:workflow/step` as context; - adds `telemere-slf4j`, an SLF4J provider, so DBOS's **internal Java logs** land in the same stream instead of being dropped with a "No SLF4J providers were found" warning. Both are dev/test only — a library must never ship a logging backend. diff --git a/deps.edn b/deps.edn index b4b5988..d4bc099 100644 --- a/deps.edn +++ b/deps.edn @@ -1,6 +1,7 @@ {:paths ["src" "resources"] :deps {dev.dbos/transact {:mvn/version "1.0.0"} - com.taoensso/trove {:mvn/version "1.1.0"} + ;; Logging facade & context bridging + com.taoensso/trove {:mvn/version "1.2.0"} com.cognitect/transit-clj {:mvn/version "1.0.333"}} :aliases {:build @@ -9,25 +10,20 @@ :ns-default build :extra-paths ["build"]} :dev - {;; Telemere backs trove (see dbos.dev-logging) for step logs; telemere-slf4j - ;; is an SLF4J provider, so DBOS's internal Java logs land in the same - ;; stream. A library must never ship a logging backend, hence dev/test only. - :extra-deps {com.taoensso/telemere {:mvn/version "1.2.0"} + {:extra-deps {com.taoensso/telemere {:mvn/version "1.2.0"} com.taoensso/telemere-slf4j {:mvn/version "1.2.0"}} :extra-paths ["dev" "test" "example/resources" "example/src"]} - ;; Classpath only, no :main-opts — bin/launchpad puts this on the REPL - ;; classpath and :main-opts would hijack its nREPL bootstrap. + + ;; No :main-opts — launchpad puts this on the REPL classpath and they'd + ;; hijack its nREPL bootstrap. :test {:extra-deps {lambdaisland/kaocha {:mvn/version "1.91.1392"} - ;; deps of the example/ consumer the integration tests boot; telemere also - ;; backs trove for step logs, and telemere-slf4j pulls in DBOS's internal - ;; Java logs (see dbos.dev-logging) integrant/integrant {:mvn/version "1.0.1"} com.taoensso/telemere {:mvn/version "1.2.0"} com.taoensso/telemere-slf4j {:mvn/version "1.2.0"}} :extra-paths ["test" "example/src" "example/resources"]} - ;; Kaocha entrypoint. Always paired: clojure -M:test:test-runner + ;; Kaocha entrypoint. Always paired with :test alias :test-runner {:main-opts ["-m" "kaocha.runner"]}}} diff --git a/scripts/make.clj b/scripts/make.clj index 22aa0f7..a169c1c 100644 --- a/scripts/make.clj +++ b/scripts/make.clj @@ -35,8 +35,8 @@ (fail! "git" (str/join " " args) "failed:" (str/trim err))) (str/trim out))) -(defn- latest-version - "Highest `v*` tag, without the leading v. +(defn- version-tags + "All `v*` tags, newest first, without the leading v. Sorted by version rather than `git describe`, which resolves by commit topology — that picks arbitrarily when two tags share a commit, and would @@ -44,9 +44,23 @@ [] (let [{:keys [exit out]} (process/sh ["git" "tag" "-l" "v*" "--sort=-v:refname"])] (when (zero? exit) - (some-> (first (str/split-lines (str/trim out))) - not-empty - (str/replace #"^v" ""))))) + (->> (str/split-lines (str/trim out)) + (remove str/blank?) + (mapv #(str/replace % #"^v" "")))))) + +(defn- latest-version + "Highest `v*` tag, prereleases included." + [] + (first (version-tags))) + +(defn- previous-release + "Highest stable `v*` tag, for the compare link. + + Prereleases are skipped: they get squashed into the release's single + changelog entry, so linking to v0.4.0-alpha2 would show a diff that + doesn't match what that entry describes." + [] + (first (filter #(re-matches #"\d+\.\d+\.\d+" %) (version-tags)))) (defn- changelog-sections "Parse the changelog into {version body}. Link-reference lines are dropped so @@ -110,8 +124,10 @@ (replace-in-file! readme re (fn [[_ before after]] (str before version after))))) (defn- ensure-releasable! [version] - (when-not (re-matches #"\d+\.\d+\.\d+" version) - (fail! "Version must be MAJOR.MINOR.PATCH, got:" version)) + ;; MAJOR.MINOR.PATCH with an optional semver prerelease suffix, e.g. + ;; 0.4.0-alpha1 — dot-separated alphanumeric identifiers. + (when-not (re-matches #"\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?" version) + (fail! "Version must be MAJOR.MINOR.PATCH[-PRERELEASE], got:" version)) (when (seq (git "status" "--porcelain")) (fail! "Working tree is dirty — commit or stash first.")) (when (seq (git "tag" "-l" (str "v" version))) @@ -131,8 +147,8 @@ (when-not version (fail! "Usage: bb tag e.g. bb tag 0.3.0")) (ensure-releasable! version) - (let [previous (or (latest-version) - (fail! "No previous v* tag to compare the release against."))] + (let [previous (or (previous-release) + (fail! "No previous stable v* tag to compare the release against."))] (shell "git fetch origin") (shell "git pull origin HEAD") (promote-unreleased! version) diff --git a/src/dbos/core.clj b/src/dbos/core.clj index 41ffd9c..b383ddc 100644 --- a/src/dbos/core.clj +++ b/src/dbos/core.clj @@ -55,41 +55,13 @@ :else (throw (ex-info "step must be a name string, options map, or StepOptions" {:step x})))) -(defn log-step-start! [step-name] - (trove/log! {:level :info - :message "Step start" - :id :step/start - :data {:workflow/step step-name}})) - -(def ^:dynamic *step-ctx-wrapper* - "Fn applied around every step body to bridge the step's context into your - logging backend's *native* scope — so bare backend log calls in the body - (Telemere `t/log!`, μ/log `μ/log`) inherit it, not just `trove/log!`. - - Value should be a (fn [ctx-map thunk]) that runs `thunk` with `ctx-map` - installed in the backend's own context, returning thunk's result. e.g.: - - (fn [ctx thunk] (taoensso.telemere/with-ctx+ ctx (thunk))) ; Telemere - (fn [ctx thunk] (com.brunobonacci.mulog/with-context ctx (thunk))) ; μ/log - - Default is a no-op (dbos-clj injects nothing into your backend). - - Re/bind dynamic value using `binding`. - Modify root (default) value using `set-step-ctx-wrapper!`." - (fn [_ctx thunk] (thunk))) - -(defn set-step-ctx-wrapper! - "Sets the root value of `*step-ctx-wrapper*` (see its docstring)." - [f] - (alter-var-root #'*step-ctx-wrapper* (constantly f))) - -(defn- ^:no-doc run-in-step-ctx - "Runtime half of `run-step|do-step`: install step ctx (Trove + native bridge), run thunk." +(defn- run-in-step-ctx [step thunk] - (let [step-name (step-display-name step) - ctx {:workflow/step step-name}] - (log-step-start! step-name) - (*step-ctx-wrapper* ctx thunk))) + (let [step-name (step-display-name step)] + (trove/with-ctx+ {:workflow/step step-name} + (trove/with-ctx-bridge + (trove/log! {:level :info :id :step/start :msg "Step start"}) + (thunk))))) (defn execute-step "Run a value-returning step via DBOS (result persisted). Redef seam for tests." diff --git a/test/dbos/core_test.clj b/test/dbos/core_test.clj index 5efdb42..159669d 100644 --- a/test/dbos/core_test.clj +++ b/test/dbos/core_test.clj @@ -381,50 +381,58 @@ (testing "an invalid input throws" (is (thrown? clojure.lang.ExceptionInfo (core/step-display-name 42))))) -(deftest step-ctx-wrapper-default-test - (testing "the default root wrapper is a pass-through returning the thunk's value" - (is (= 99 (core/*step-ctx-wrapper* {:any :ctx} (fn [] 99)))))) - -(deftest run-in-step-ctx-applies-wrapper-test - (testing "the wrapper runs around the thunk and receives the normalized ctx" - (let [seen (atom nil)] - (binding [core/*step-ctx-wrapper* - (fn [ctx thunk] (reset! seen ctx) (thunk))] - (let [ret (#'core/run-in-step-ctx "fetch" (fn [] :result))] - (is (= :result ret)) - (is (= {:workflow/step "fetch"} @seen)))))) +(defn- ctx-seen-by-body + [step] + (let [seen (atom nil)] + [(#'core/run-in-step-ctx step (fn [] (reset! seen trove/*ctx*) :result)) + @seen])) + +(deftest run-in-step-ctx-binds-trove-ctx-test + (testing "the step body sees :workflow/step in trove/*ctx*, and its value is returned" + (is (= [:result {:workflow/step "fetch"}] (ctx-seen-by-body "fetch")))) (testing "a map step is normalized to its :name in the ctx (carries name, not raw map)" - (let [seen (atom nil)] - (binding [core/*step-ctx-wrapper* - (fn [ctx thunk] (reset! seen ctx) (thunk))] - (#'core/run-in-step-ctx {:name "fetch-user" :max-attempts 3} (fn [] :ok)) - (is (= {:workflow/step "fetch-user"} @seen))))) + (is (= {:workflow/step "fetch-user"} + (second (ctx-seen-by-body {:name "fetch-user" :max-attempts 3}))))) (testing "a StepOptions step is normalized to its name in the ctx (carries name, not raw object)" - (let [seen (atom nil)] - (binding [core/*step-ctx-wrapper* - (fn [ctx thunk] (reset! seen ctx) (thunk))] - (#'core/run-in-step-ctx (StepOptions. "prebuilt") (fn [] :ok)) - (is (= {:workflow/step "prebuilt"} @seen)))))) - -(deftest set-step-ctx-wrapper!-test - (testing "mutates the root value of *step-ctx-wrapper* (restored afterward)" - (let [orig core/*step-ctx-wrapper* - marker (fn [_ctx thunk] (thunk))] - (try - (core/set-step-ctx-wrapper! marker) - (is (identical? marker core/*step-ctx-wrapper*)) - (finally - (core/set-step-ctx-wrapper! orig)))))) + (is (= {:workflow/step "prebuilt"} + (second (ctx-seen-by-body (StepOptions. "prebuilt")))))) + + (testing "ctx does not leak past the step boundary" + (#'core/run-in-step-ctx "fetch" (fn [] :ok)) + (is (nil? trove/*ctx*))) + + (testing "nested steps merge, inner wins" + (is (= {:workflow/step "inner"} + (#'core/run-in-step-ctx "outer" + (fn [] (#'core/run-in-step-ctx "inner" (fn [] trove/*ctx*)))))))) + +(deftest run-in-step-ctx-backend-bridge-test + (testing "a log-fn that opted into the ctx bridge sees the step ctx around the body" + (let [seen (atom nil) + log-fn (trove/add-ctx-bridge + (fn [_ns _coords _level _id _payload_] nil) + (fn [ctx thunk] (reset! seen ctx) (thunk)))] + (binding [trove/*log-fn* log-fn] + (is (= :result (#'core/run-in-step-ctx "fetch" (fn [] :result)))) + (is (= {:workflow/step "fetch"} @seen))))) + + (testing "a plain log-fn (no bridge) still runs the body and receives :step/start log" + (let [called (atom false)] + (binding [trove/*log-fn* (fn [_ns _coords _level _id _payload_] (reset! called true))] + (is (= :result (#'core/run-in-step-ctx "fetch" (fn [] :result)))) + (is (true? @called) "the log-fn still receives the :step/start log"))))) (deftest run-in-step-ctx-emits-step-start-log-test - (testing "a :step/start log is emitted with the step name under :data" + (testing "a :step/start log is emitted, with step name carried in trove's :ctx" (let [logs (atom [])] (binding [trove/*log-fn* (fn [_ns _coords level id payload_] (swap! logs conj {:level level :id id :payload (force payload_)}))] (#'core/run-in-step-ctx "fetch" (fn [] :ok)) - (let [entry (first (filter #(= :step/start (:id %)) @logs))] - (is (some? entry) "a :step/start log is emitted") - (is (= {:workflow/step "fetch"} (get-in entry [:payload :data])))))))) + (let [entry (first @logs)] + (is (= {:id :step/start, + :level :info, + :payload {:ctx {:workflow/step "fetch"}, :msg "Step start"}} + entry))))))) diff --git a/test/dbos/dev_logging.clj b/test/dbos/dev_logging.clj index 0840d54..9560f6f 100644 --- a/test/dbos/dev_logging.clj +++ b/test/dbos/dev_logging.clj @@ -6,8 +6,6 @@ Lives under test/ because that path is on the classpath for both the :dev REPL and the :test runner; it is dev tooling, not a test." (:require - [dbos.core :as core] - [taoensso.telemere :as t] [taoensso.trove :as trove] [taoensso.trove.telemere :as trove-telemere])) @@ -18,8 +16,9 @@ Idempotent. Takes and returns an optional argument so it can be used as a kaocha hook." ([] - (trove/set-log-fn! (trove-telemere/get-log-fn)) - (core/set-step-ctx-wrapper! (fn [ctx thunk] (t/with-ctx+ ctx (thunk)))) + ;; :bridge-ctx? opts into trove merging its ctx into Telemere's native ctx, + ;; which is what makes a bare t/log! inside a step body carry :workflow/step. + (trove/set-log-fn! (trove-telemere/get-log-fn {:bridge-ctx? true})) nil) ([x] (install!)