Example

A complete analytics.json for a warehouse App, walked line by line — the envelope, the view, every column locator, and the roles.

One complete file, then the walk-through. This is the analytics.json of a hypothetical acme/warehouse App whose schema.json owns a locations entity and a zones entity.

analytics.json
{
  "$schema": "https://schemas.revenexx.com/analytics.schema.json",
  "version": "1",
  "views": [
    {
      "name": "locations",
      "source": "locations",
      "key": "id",
      "title": "Stock locations",
      "description": "One row per location, with its type and market.",
      "depends_on": ["zones"],
      "roles": {
        "time": "created_at",
        "entity": "location_id",
        "measures": ["capacity"],
        "dimensions": ["type", "zone"]
      },
      "columns": [
        { "name": "location_id", "from": "column:id", "type": "uuid", "optional": false },
        { "name": "code", "from": "column:code", "type": "text", "optional": false },
        { "name": "type", "from": "column:type", "type": "text", "optional": false },
        { "name": "capacity", "from": "column:capacity", "type": "numeric" },
        { "name": "label", "from": "attribute:locale.display_name", "type": "text" },
        { "name": "zone", "from": "relation:zone_id→zones.code", "type": "text" },
        { "name": "created_at", "from": "column:created_at", "type": "timestamptz", "optional": false }
      ]
    }
  ]
}

The envelope

analytics.json
"$schema": "https://schemas.revenexx.com/analytics.schema.json",
"version": "1",

$schema is not required, and you want it anyway: it gives your editor completion and validation, which catches a mistyped locator before a deploy does. version is the contract major version — "1" today, and the only accepted value.

views takes at least one view. This app ships one.

Identity

analytics.json
"name": "locations",
"source": "locations",
"key": "id",

source names the entity from schema.json. name is the view's own name, and it need not match the source — but where it can, it should, because the name becomes part of the public dataset identifier:

Emitted
analytics.acme__warehouse__locations

key is id here, which is also the default. It is declared explicitly because being explicit about the key of a view is worth one line.

Labels

analytics.json
"title": "Stock locations",
"description": "One row per location, with its type and market.",

This is what the operator reads in the dataset catalog. Without a title the name is humanised — "Locations" — which is worse than "Stock locations" and much worse than a description that states the grain.

State the grain in the description. "One row per location" is the single most useful sentence in the file for whoever charts it.

Dependencies

analytics.json
"depends_on": ["zones"],

The view reads zones through the relation locator below. depends_on documents that. It is not a join specification — the locator does the joining — and it is what a reviewer reads to see what the view touches.

The columns

analytics.json
{ "name": "location_id", "from": "column:id", "type": "uuid", "optional": false },

The key, exposed under a name that is meaningful in a chart legend. optional: false because it can never be null. Note the rename: the column in the entity is id, and in the view it is location_id — worth doing, because id in a dataset picker tells nobody which id it is.

analytics.json
{ "name": "code", "from": "column:code", "type": "text", "optional": false },
{ "name": "type", "from": "column:type", "type": "text", "optional": false },

Two plain scalar columns. type will be a dimension, which is why it is cast to text and marked non-null: a dimension with nulls in it produces a chart with an unexplained bucket.

analytics.json
{ "name": "capacity", "from": "column:capacity", "type": "numeric" },

The one measure. Cast to numeric deliberately — a measure that arrives as text cannot be summed, and no error will tell you. Left optional, because a location without a declared capacity is legitimate.

analytics.json
{ "name": "label", "from": "attribute:locale.display_name", "type": "text" },

A locale-scoped attribute, lifted out of its bucket and resolved at the view's default_locale — which is not declared here, so en. One row per key, not one per locale: if this app needed German and English side by side, that would be two explicit columns, not a second row.

analytics.json
{ "name": "zone", "from": "relation:zone_id→zones.code", "type": "text" },

The relation. The zone_id foreign key is followed to one row in zones, and that row's code is taken. The arrow is a literal — a -> here is a validation failure.

This is the line that makes the dataset usable. Without it the only zone information in the view is a UUID, and nobody groups a chart by UUIDs.

analytics.json
{ "name": "created_at", "from": "column:created_at", "type": "timestamptz", "optional": false }

The time axis, cast to timestamptz and non-null, because the time role points at it and a null there means a row that silently drops out of every date-scoped view.

The roles

analytics.json
"roles": {
  "time": "created_at",
  "entity": "location_id",
  "measures": ["capacity"],
  "dimensions": ["type", "zone"]
}

Read it back as a sentence: sum of capacity, by type and zone, over created_at, per location. That is a question somebody would actually ask, which is the test for whether the roles are right.

Every name here refers to a column declared above — that is a rule, not a convention. And note what is not in the roles: code and label are in the view because an operator needs to recognise a row, not because anything should be grouped by them. A high-cardinality label is a bad dimension.

What you would add next

For a real warehouse app, one view is not enough. The natural second and third:

  • movements — one row per stock movement, time on the movement timestamp, entity on the product, measures on the quantity. A different grain, so a different view.
  • stock_levels — one row per product per location, with the current level as the measure and no time role at all, because it is a snapshot rather than a series.

Three narrow views beat one wide one: the roles can be right for each, and the grain is stated rather than implied.

Where to go next

Was this page helpful?