Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<name>"}`, 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 "<name>"}`. 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
Expand Down
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
18 changes: 7 additions & 11 deletions deps.edn
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"]}}}
34 changes: 25 additions & 9 deletions scripts/make.clj
Original file line number Diff line number Diff line change
Expand Up @@ -35,18 +35,32 @@
(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
silently produce the wrong compare link."
[]
(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
Expand Down Expand Up @@ -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)))
Expand All @@ -131,8 +147,8 @@
(when-not version
(fail! "Usage: bb tag <version> 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)
Expand Down
40 changes: 6 additions & 34 deletions src/dbos/core.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
80 changes: 44 additions & 36 deletions test/dbos/core_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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)))))))
7 changes: 3 additions & 4 deletions test/dbos/dev_logging.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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]))

Expand All @@ -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!)
Expand Down
Loading