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:

valuesplits by
event_namethe 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.

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 & pathDoes
GET /v1/trendsList every saved trend in the project
POST /v1/trendsCreate one
GET /v1/trends/:idRead one
PATCH /v1/trends/:idRename it, or change its event, interval or breakdown
DELETE /v1/trends/:idDelete 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.