Skip to content

activity-decay

One journey that keeps an is-active trait current, so a segment can read it without a nightly sweep.

Journey What it does
activity-decay Keeps track of whether someone has been active in the last 30 days.

No particular one: it reacts to any event your app sends.

Terminal window
cow init --blueprint activity-decay

Every 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.

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.

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.

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.

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",
from: "Acme <[email protected]>",
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");
},
});

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.

activity-decay.json
{
"user": { "id": "usr_1", "traits": {} },
"events": [{ "at": "10d", "event": "page_view" }],
"expect": [
{ "activity": "setTrait", "input": { "key": "active_30d", "value": true } },
{ "activity": "restart" }
]
}
Terminal window
cow test activity-decay --scenario scenarios/activity-decay.json