activity-decay
One journey that keeps an is-active trait current, so a segment can read it without a nightly sweep.
Journeys
Section titled “Journeys”| Journey | What it does |
|---|---|
activity-decay |
Keeps track of whether someone has been active in the last 30 days. |
Events it needs
Section titled “Events it needs”No particular one: it reacts to any event your app sends.
Copy it
Section titled “Copy it”cow init --blueprint activity-decayEvery file above lands in your repo, yours to edit. In a repo that already has a cow.json, add it with cow add blueprint activity-decay instead.
The pattern
Section titled “The pattern”Timer loop as computed state. “Active in the last 30 days” expressed as a per-user durable timer instead of a nightly sweep: the trigger event sets the flag, every further activity event restarts the execution (which resets the silence clock with an empty journal), and 30 days of silence removes the flag (unset, not false, since exists predicates stop matching only when the key is gone). The execution then completes, and completing is what ends a marketing journey for that user, so give it an enrollment cooldown if the next burst of activity should flag them again.
Reach for it when
Section titled “Reach for it when”Any rolling-window state a segment should see: active_30d, at_risk, slipping_away. Pair it with a segment on the trait and a second journey triggered by that segment’s entry: the composition pattern.
Make it yours
Section titled “Make it yours”Rename the trait and the activity event. The window ("30d") is plain configuration. api.restart() is what keeps a journey that loops for months bounded: it ends the execution and starts a fresh one under the same workflow id, so the journal never grows past one window.
The journey
Section titled “The journey”Timer decay: “active in the last 30 days” as a per-user journey instead of
a nightly sweep. The trigger sets the flag and the execution waits; every
later event restarts the execution, which resets the 30-day silence clock
with a fresh, empty journal; 30 days of silence unsets the flag (removed,
not set to false, so exists predicates stop matching) and the execution
completes.
Restarting is not finishing, so a user who keeps showing up never counts
as having been through this journey and the loop runs for as long as they
do. The execution that ends after 30 days of silence does count, and with
marketing and no enrollment that is the end of it: their next event
does not re-flag them. A journey meant to track state for the life of an
account wants to reopen as soon as it closes, which is what
enrollment: { cooldown: "1s" } says.
"*" is activity as it really arrives: whatever names your apps send,
page_view and purchase and login alike. It never matches an event Cowliss
wrote itself (those are all under system.), which is what stops the
trait write below from flipping a segment whose entry event restarts this
journey, forever.
Blueprint: add it with cow add blueprint activity-decay; rename the trait to
your domain’s vocabulary, or narrow the pattern (checkout.*, or a list)
if only some events should count as activity.
import { defineJourney } from "@cowliss/cli/journeys";
/** * Timer decay: "active in the last 30 days" as a per-user journey instead of * a nightly sweep. The trigger sets the flag and the execution waits; every * later event restarts the execution, which resets the 30-day silence clock * with a fresh, empty journal; 30 days of silence unsets the flag (removed, * not set to false, so `exists` predicates stop matching) and the execution * completes. * * Restarting is not finishing, so a user who keeps showing up never counts * as having been through this journey and the loop runs for as long as they * do. The execution that ends after 30 days of silence does count, and with * `marketing` and no `enrollment` that is the end of it: their next event * does not re-flag them. A journey meant to track state for the life of an * account wants to reopen as soon as it closes, which is what * `enrollment: { cooldown: "1s" }` says. * * `"*"` is activity as it really arrives: whatever names your apps send, * page_view and purchase and login alike. It never matches an event Cowliss * wrote itself (those are all under `system.`), which is what stops the * trait write below from flipping a segment whose entry event restarts this * journey, forever. * * Blueprint: add it with `cow add blueprint activity-decay`; rename the trait to * your domain's vocabulary, or narrow the pattern (`checkout.*`, or a list) * if only some events should count as activity. */export default defineJourney({ trigger: { event: "*" }, purpose: "marketing", description: "Keeps track of whether someone has been active in the last 30 days.", tags: ["demo", "retention"], run: async (_event, api) => { await api.traits.set("active_30d", true); const active = await api.waitForEvent("*", { timeout: "30d" }); if (active) { // This is the loop, and it is bounded: restart ends this execution and // starts a fresh one under the same workflow id with an empty journal, // so a user who stays active for years never grows one. await api.restart({ event: active }); } await api.traits.unset("active_30d"); },});Its scenario
Section titled “Its scenario”cow test replays this on a virtual clock: the trigger fires at the start, events arrive at their offsets, and expect is the ordered list of capability calls the run must make, each input matched as a subset.
{ "user": { "id": "usr_1", "traits": {} }, "events": [{ "at": "10d", "event": "page_view" }], "expect": [ { "activity": "setTrait", "input": { "key": "active_30d", "value": true } }, { "activity": "restart" } ]}cow test activity-decay --scenario scenarios/activity-decay.json