Docs Asking questions
Trends
An event over time, split by a property or by a column of the event itself.
How many of this event over time, and how does that split by something? The first question anyone has after instrumenting a site, and the one the Feed’s chart cannot answer because it counts everything at once.
curl -s -H "X-Lyraflow-Key: sk_..." \
"http://localhost:3000/v1/events/stats?event=checkout&interval=1d&group_by=property:plan"
{ "buckets": [
{ "bucket": "2026-08-01T00:00:00.000Z", "series": "pro", "events": 41 },
{ "bucket": "2026-08-01T00:00:00.000Z", "series": "free", "events": 12 },
{ "bucket": "2026-08-01T00:00:00.000Z", "series": "(not set)", "events": 3 }
],
"folded_series": 0 }
This is the same GET /v1/events/stats the Feed’s chart already used,
with a breakdown added rather than a second endpoint — so the bucket cap, the
event_id deduplication and the deletion boundary are the ones that route
already enforced.
A trend can also be a saved object, named and reopened later the same
way a funnel is — see Saved trends below. Saving one does
not change how it runs: a saved trend is still answered by this same
GET /v1/events/stats call, with its stored event, interval and
group_by filled in.
Splitting
group_by takes three forms:
| value | splits by |
|---|---|
event_name | the event’s name. Unchanged — this is the only value the parameter took before, and it still returns an event_name field on every bucket. |
attribute:<column> | a column of the event: path, url, referrer, utm_source, country, browser, and the rest of the context fields. |
property:<key> | a key from the event’s own properties. |
A property is read from both property bags, so a numeric property splits by its value rather than collapsing into one empty series — routing is per value at ingest, so the same key can land in either.
An event with no value there is a (not set) series, not a dropped row.
That is what keeps a split reconcilable: the series always add up to the same
total the ungrouped request returns, so a number here can be checked against
the Feed.
Resolution
interval is 1m, 1h, 1d or 1w. Weeks start Monday, in UTC — the
same anchoring a retention cohort uses, so a weekly trend and a weekly cohort
row cannot disagree about where a week begins.
since and until are optional; without them the window is scaled to the
resolution. The Web UI offers presets and a two-date range, and says before
you run when a span and a resolution would pair into more buckets than the
server accepts — 30 days at 1m is 43,200 against a ceiling of 1000, which is
exactly what somebody builds by accident when span and resolution are two
independent choices.
Limits
At most 10 series come back; the rest are summed into one (other) series
and counted. folded_series says how many values went into it, so a caller
can say “and 340 others” rather than implying there were ten. (other) is
kept and labelled rather than dropped, for the reason (not set) exists: a
chart whose parts do not add up to the total is one nobody can reconcile.
Series are ranked by their total over the whole window, not by any single bucket, so a series does not appear and disappear as the window moves.
A breakdown producing more than 20,000 bucket/series rows is refused, with
too_many_series, rather than truncated. Splitting a 90-day daily chart by
utm_content on a busy site is that request; a chart silently missing its
rarest series still looks plausible and the caller cannot tell.
There is no breakdown by trait — only by event column and event property. A trait lives on the person rather than the event and needs a join this route does not do; it is the obvious next step rather than a decision against it.
Saved trends
A saved trend is a named, stored definition — an event, an interval, an
optional breakdown and a where filter — that the Trends screen creates and
reopens. There is no /run endpoint: saving one does not add a second way to
answer it, only a place to keep the question. Running a saved trend, from the
screen or by hand, is the same GET /v1/events/stats call above with its
stored fields as the query.
A saved trend’s where clauses are parsed against the same grammar the run endpoint uses.
A row this build cannot parse comes back with "stale": true rather than failing the whole
list — the same behaviour a saved retention report has. Every row also carries
definition_version, re-stamped to the current version whenever its stored where is
rewritten — the same field retention_reports already has, for the same reason: it is what
a future grammar change could filter on to find every row still on the old one, without
parsing each row’s JSON.
curl -X POST http://localhost:3000/v1/trends \
-H "x-lyraflow-server-key: $LYRAFLOW_SERVER_KEY" \
-H 'content-type: application/json' \
-d '{ "name": "Checkouts by plan", "event": "checkout", "interval": "1d",
"group_by": "property:plan" }'
| Method & path | Does |
|---|---|
GET /v1/trends | List every saved trend in the project |
POST /v1/trends | Create one |
GET /v1/trends/:id | Read one |
PATCH /v1/trends/:id | Rename it, or change its event, interval or breakdown |
DELETE /v1/trends/:id | Delete it — 204 |
A duplicate name within the same project is a 409. A non-numeric :id is a
400 naming invalid_trend_id; an id that does not exist, or belongs to
another project, is a 404 — never a 403, which would confirm the id
exists.
What is not stored is the range. since, until and every relative
preset live only in the Trends screen’s own URL, the same way a funnel never
stores since/until either — only its window_seconds, a duration rather
than a range. Reopening a saved trend runs it over whatever range the screen
currently has, not the one it was created with.
This page is the Trends section of the product
README at v0.15.0. It is generated from that file rather than
written here, so a correction belongs upstream.