Your schema, without a schema migration

Every Infralytiqs event carries two open maps alongside its standard fields. custom_dimensions holds strings — the things you slice by. custom_metrics holds numbers — the things you add up. Between them they are how a generic event pipeline learns your business vocabulary without anyone altering a table.

There is no registry to fill in first. You do not declare a dimension in the console and then start sending it; you send it, and the platform finds it. A background catalogue scans recent events for distinct keys and offers them in the report builder’s field picker within minutes. The practical consequence is pleasant: shipping a new dimension is a change to your application, not a change request against your analytics platform.

The split between the two maps is not cosmetic. Dimensions are stored as Map(LowCardinality(String), String), which is optimised for repeated values you group by. Metrics are Map(String, Float64), which is optimised for arithmetic. Putting a number in the dimensions map works, but you will not be able to average it without casting; putting an identifier in the metrics map mostly does not work at all.

The numbers that constrain you

Worth knowing before you design your event vocabulary rather than after.

0

Distinct keys discovered

Per map column, per discovery pass — the ceiling on how many distinct keys the field picker will offer

0

Default discovery lookback

Configurable from 1 to 365. A key that has not appeared in the window is not offered

0

Catalogue cache

How long discovery results are held before a rescan. Force one with refresh=1

0

Metric precision

Every custom metric is a Float64 — no integer overflow, but no exact decimal either

Dimension, metric, or neither

Getting this right at the point you emit the event saves a great deal of SQL later.

Make it a dimension

Anything you will put on an axis, in a legend, or in a filter. Plan tier, asset type, campaign code, workflow name, deployment environment. Values should repeat — that is what makes grouping cheap and what the LowCardinality encoding is for.

Make it a metric

Anything you will sum, average or take a percentile of. Bytes uploaded, milliseconds elapsed, cart value, row count. If the answer to “what would I do with this?” is “add it up”, it belongs here.

Send a signal metric

A metric fixed at 1.0 looks redundant next to a row count, but it survives de-duplication and roll-ups intact and gives you something to sum when you later add weighting. The AEM integrator does exactly this with download_signal and upload_signal.

Do not send an identifier

A request id or a full asset path as a dimension creates one distinct value per event. It will not group into anything useful, it inflates the discovery catalogue, and it makes the LowCardinality encoding actively counterproductive. Put it in the log line instead.

Do not send a timestamp

Events are already timestamped on arrival, and time is the primary key of the table. A created_at dimension is a second, worse copy of information the platform already has indexed.

Reuse the materialised keys

asset_id and search_term are extracted from the dimensions map into their own ClickHouse columns. Use exactly those key names and filtering on them costs a column read rather than a map lookup.

Sending them

Same two maps from every surface. The transport differs; the contract does not.

// track(eventType, customDimensions, customMetrics, eventSubtype)
//
// Dimensions are strings, metrics are numbers. The SDK does not coerce
// between them -- a number sent as a dimension stays a string, and a
// string sent as a metric is dropped.

Infralytiqs.track('checkout_completed', {
  plan:          'team',        // groupable
  payment_method: 'card',
  currency:      'GBP',
  experiment:    'checkout_v3'
}, {
  cart_value:    149.50,        // summable
  line_items:    3,
  checkout_ms:   4820
});

// Dimensions you want on EVERY event belong in init, not in each call.
// Release version is the classic case: it turns "did the conversion
// rate drop?" into a question you can actually segment.
Infralytiqs.init({
  serverUrl: 'https://api.infralytiqs.com',
  tenantId:  'acme',
  siteId:    'acme-www',
  globalDimensions: {
    release:     window.__APP_VERSION__ || 'unknown',
    render_mode: 'spa'
  }
});

The rules

Short list, and all of them are enforced at ingest rather than at query time.

Rule Applies to Detail
Key format Both maps ^[a-zA-Z0-9_-]+$. Dots, spaces and colons are rejected. Invalid keys are dropped from the event; the rest of the event is still stored
Dimension value type custom_dimensions String. Numbers and booleans are stringified, so true arrives as "true"
Metric value type custom_metrics Float64. Non-numeric values are dropped rather than coerced to zero, because a silent zero is worse than a missing value
Storage Both maps Map(LowCardinality(String), String) and Map(String, Float64) on website_events
Discovery window Both maps Keys are found by scanning recent events. lookbackDays accepts 1–365 and defaults to 14
Discovery ceiling Both maps 500 distinct keys per map column. Past that, the least recently seen keys stop being offered in the picker — they are still queryable by name
JSON array values custom_dimensions A dimension holding a JSON array (the integrator’s user_groups, for instance) can be expanded with the jsonArray transform and grouped element by element
Reserved keys custom_dimensions asset_id and search_term are materialised into dedicated columns. Use them for what they say, not for something else

How discovery works

When you open the report builder it asks the server what fields exist. The server runs a DISTINCT over the map keys across the lookback window, caps the result at 500 keys per column, caches it for five minutes and hands back a catalogue: standard columns, discovered dimension keys, discovered metric keys.

That cache is the reason a brand-new dimension sometimes does not appear immediately. If you have just sent the first event carrying a new key and want it now, add refresh=1 to force a rescan. If it still does not appear, the key almost certainly failed validation — check it against ^[a-zA-Z0-9_-]+$ before looking anywhere else.

Filter value pickers work the same way. Rather than making you type a value, the builder asks for the distinct values of one field with counts, so you can see that carrier has four values and one of them accounts for most of the traffic before you commit to a filter.

Discovery and query endpoints

The report builder is a client of these, and so can you be.

Endpoint Parameters Returns
GET …/report-dsl-metadata lookbackDays (default 14), refresh The full field catalogue: standard columns, discovered dimensions and metrics, plus the aggregations and visualisation types each supports
GET …/custom-fields lookbackDays, refresh Just the discovered custom keys — lighter, when that is all you need
GET …/field-values field, lookbackDays, limit Distinct values for one field with occurrence counts, for filter pickers
POST …/report-query query (metrics, dimensions, filters, timeRange, orderBy, limit), optional runtimeFilters and timeRangeOverride The result set behind a panel. Custom fields are referenced as custom_dimensions.<key> and custom_metrics.<key>

Querying them

Through the report DSL, or in SQL against your own ClickHouse — the rows are yours either way.

// The JSON a saved panel stores. Custom fields are addressed with a
// dotted prefix; everything else is a plain column name.
{
  "query": {
    "dimensions": [
      { "field": "custom_dimensions.warehouse" },
      { "field": "custom_dimensions.carrier" }
    ],
    "metrics": [
      { "field": "custom_metrics.parcel_weight_kg", "agg": "sum",
        "alias": "total_kg" },
      { "field": "custom_metrics.pick_duration_ms", "agg": "p95",
        "alias": "pick_p95_ms" },
      { "field": "event_type", "agg": "count", "alias": "shipments" }
    ],
    "filters": [
      { "field": "event_type", "op": "eq", "value": "order_shipped" },
      { "field": "custom_dimensions.service_level", "op": "in",
        "value": ["next_day", "same_day"] }
    ],
    "timeRange": { "preset": "last_30_days" },
    "orderBy": [{ "field": "total_kg", "dir": "desc" }],
    "limit": 50
  }
}

From a new key to a saved panel

The whole loop, which is usually shorter than the meeting about whether to add the field.

  1. Decide which map it belongs in

    Ask what you will do with it. Group by it, or filter on it — dimension. Add it up, or take a percentile — metric. If the answer is “look at one specific row”, it is neither: that is a log line, not an analytics field.

    Name it in snake_case and be boring about it. carrier ages better than shipProvider2, and consistency across your producers is what lets one report span web, mobile and AEM.

    Design
  2. Emit it

    Add the key to the custom_dimensions or custom_metrics object at the point the event is raised. No deployment to Infralytiqs is required and no table is altered — the map columns already exist.

    If it applies to every event from a surface, use globalDimensions in the SDK configuration rather than repeating it at each call site.

    Application
  3. Confirm it arrived

    The fastest check is your own database: SELECT custom_dimensions FROM website_events ORDER BY event_timestamp DESC LIMIT 5. If the key is missing there, it never reached storage and no amount of refreshing the console will conjure it.

    The usual cause is a key that failed ^[a-zA-Z0-9_-]+$ — a dot or a space is enough — and the drop is deliberately silent so one bad key cannot fail a batch.

    Verify
  4. Let discovery pick it up

    Open the report builder and the field appears in the picker under custom dimensions or custom metrics. If it does not, the five-minute catalogue cache has not turned over; force it with refresh=1.

    Remember the window. A key sent once, three weeks ago, is outside the default 14-day lookback and will not be offered until you widen lookbackDays.

    Catalogue
  5. Build the panel

    Drop the dimension onto the grouping, choose an aggregation for the metric, pick a visualisation. Filter value pickers populate themselves from your data, so you select next_day from a list rather than typing it and wondering about the spelling.

    Report builder
  6. Save it to a suite

    Save the panel into a report suite so it is there next time and for everyone else. Suites are per-company and per-ClickHouse-server, which is what keeps one tenant’s vocabulary out of another’s.

    Panels can be opened for drill-down or shared as an embed, and both paths carry the custom fields through unchanged.

    Dashboard

Questions that come up

No. There is no registry and no schema step. Send the key on an event and the discovery catalogue picks it up on its next pass, usually within five minutes. This is the main reason the two maps exist rather than a set of fixed eVar1-style slots.
It is dropped from the event and the rest of the event is stored normally. The validation is ^[a-zA-Z0-9_-]+$, so dots, spaces, colons and slashes all fail. The drop is silent by design — rejecting a whole batch of 1,000 events over one malformed key would be a much worse failure mode — which does mean a typo shows up as a missing field rather than an error.
It is a limit on how many the discovery catalogue will offer in the picker, not on how many you can store or query. The map columns will hold whatever you write. If you are near 500 distinct keys, though, that is usually a signal that identifiers have leaked into the dimensions map rather than that you genuinely have 500 dimensions.
ClickHouse map access returns the value type’s default for a missing key, which is 0 for a Float64. If “not sent” and “sent as zero” mean different things in your reporting, guard with has(custom_metrics, 'key') rather than treating zero as absence.
Not retroactively, because the key is stored on every row that carried it. What you can do is start emitting the new name, keep both for a transition period, and use coalesce or a CASE over the two keys in the panel while the old rows age out under your retention policy.
Discovery is scoped to the tenant and site in the request path, so one site’s vocabulary does not appear in another’s picker. A tenant-wide configuration — a blank site id, which resolves to the __all_sites__ scope — sees the union across that tenant’s sites.
Heavily, and they are a good model to copy. Asset events carry asset_path, asset_extension, asset_parent_path and replication_action as dimensions, with upload_size_bytes, upload_duration_ms and the *_signal counters as metrics. Every stock AEM report is built from exactly the same mechanism available to your own events.

Put them to work

Custom fields are only useful once something is emitting them. Start with a surface and come back to the report builder.