Custom Metric Templates
When our provided system templates don't fit, you can author your own Plotly chart with a Liquid template. SignalFlag renders the template with the columns from your metric's SQL query, and the template must produce a valid Plotly JSON blob, which the app drops onto the page in place of a system chart.
- For Plotly documentation, see https://plotly.com/javascript/
- For the Liquid templating language, see https://shopify.github.io/liquid/
The examples below include a functional custom template along with a metric definition for it.
When to reach for a custom template
System templates cover the common shapes (line, bar, table, scalar, state timeline, image, video) and handle theming and resizing for you. Start with a system template and only drop down to a custom one if none of them produce the chart you're after.
Some reasons to reach for a custom template:
- A Plotly trace type that isn't exposed by a system template (heatmap, box plot, polar plots).
- Bespoke styling — series colors, dash patterns, hover labels, custom axis ranges or tick formatting.
- Multiple trace shapes combined in one figure (e.g. line + scatter, or stacked area with annotations).
If there is a template you'd like us to include in our set of predefined system templates, let us know!
File layout
Custom templates live alongside your config in a templates/ directory:
.resim/
└── metrics/
├── config.resim.yml
└── templates/
└── speed_over_time.liquid
Reference the template from a metric with template_type: custom and template_file:
metrics:
Speed over time:
type: test
description: Robot speed over time, rendered with a custom Plotly template.
query_string: |
SELECT
'speed' AS group_name,
timestamp,
speeds
FROM drone_speed
template_type: custom # Use `custom` instead of `system`
template_file: speed_over_time.liquid # Reference the liquid template here
resim metrics sync uploads the config and every file in templates/ together — no separate command or extra flag is needed.
How rendering works
For each metric using a custom template, SignalFlag:
- Runs the metric's
query_stringagainst the data lake. -
Reshapes the result into a map keyed by column name, where each value is an array of that column's values across all rows. A query that returns three columns (
group_name,timestamp,speeds) produces a context like:JSON{ "group_name": ["speed", "speed", "speed"], "timestamp": [0, 1, 2], "speeds": [1.2, 1.4, 1.1] } -
Renders the Liquid template against that context.
- Parses the rendered string as JSON. If parsing fails, the metric errors out and the failure is surfaced in the app.
- Hands the resulting Plotly object to the front-end, where it is rendered with Plotly.js.
Two things to keep in mind:
- The variables available in the template are the column names from your query. Each is an array with one entry per row, in result order. SQL aliases (
AS some_name) rename the variable seen by the template. - The template's output must be valid JSON. Whitespace is free, but a trailing comma or missing brace will fail during rendering.
Authoring a template
Templates are written in Liquid. The standard tags and filters are available — {% for %}, {% if %}, {{ value | filter }}, whitespace controls {%- ... -%}, and so on. Two differences from Shopify's Liquid:
- There is no
jsonfilter. Quote strings yourself, as in the example below. If a string column can contain", hand quoting breaks the JSON; build the figure in your test code instead (see Building the figure in your test). - A variable set with
assigninside a loop does not keep its value into the next pass. Do not use one to decide where commas go. Useforloop.firstorforloop.lastover the whole array, never inside a loop that skips items.
Here is a working line-chart template that pairs with the metric above. Save it as .resim/metrics/templates/speed_over_time.liquid:
{
"data": [
{
"x": [
{% for item in timestamp -%}
"{{ item }}"{% unless forloop.last %},{% endunless %}
{%- endfor %}
],
"y": [
{% for item in speeds -%}
"{{ item }}"{% unless forloop.last %},{% endunless %}
{%- endfor %}
],
"type": "scatter",
"mode": "lines",
"line": { "color": "#1f77b4" },
"name": "{{ group_name[0] | default: 'Series' }}"
}
],
"layout": {
"xaxis": { "title": { "text": "timestamp" } },
"yaxis": { "title": { "text": "speed (m/s)" } },
"showlegend": true,
"hovermode": "closest"
},
"config": {
"responsive": true
}
}
A few details worth calling out:
timestamp,speeds, andgroup_namemap directly to the SQL columns. Rename a column in the query and you rename the variable in the template.group_name[0]reads the first row's value — handy when the query returns the same series name on every row.| default: 'Series'covers the case where the column is empty.{%- ... -%}strips surrounding whitespace, and{% unless forloop.last %},{% endunless %}avoids the trailing comma that would otherwise invalidate the JSON array.
You can produce any valid Plotly figure from here — multiple traces, secondary y-axes, annotations, custom modebar buttons. The Plotly JavaScript reference is the source of truth for what is renderable.
Building the figure in your test
If you emit from Python, it is often simpler to build the Plotly figure in your test code and let the template print it. Emit the figure as a JSON string into a topic with one string column:
topics:
path_xy_fig:
schema:
raw_metric: string
metrics:
Planned vs Driven:
type: test
description: Planned path against the driven path (x, y in m).
query_string: SELECT raw_metric FROM path_xy_fig
template_type: custom
template_file: raw.liquid
test.emit("path_xy_fig", {"raw_metric": json.dumps(fig)}, timestamp)
raw.liquid contains one line:
{{ raw_metric[0] }}
This avoids quoting problems, because json.dumps escapes the strings for you.
Iterating locally
The standard debug workflow works for custom templates:
resim metrics sync --project "my-project" --branch "metrics-dev"
resim metrics debug \
--project "my-project" \
--emissions-file emissions.resim.jsonl \
--metrics-config-path .resim/metrics/config.resim.yml \
--metrics-set "My Metrics"
Edit the .liquid file, re-run resim metrics sync, then refresh the debug dashboard to see the new output.
Troubleshooting
- JSON parse error after render (
RENDER_ERROR,json_parse_failed) — the rendered template isn't valid JSON. The usual culprit is a trailing comma inside an array. Make sure loops use the{% unless forloop.last %},{% endunless %}guard. Other causes: ajsonfilter (not available), an unescaped"inside a string value, or an empty template file. unknown templateat sync — the file named intemplate_fileis not in.resim/metrics/templates/(or the directory you pass astemplates_pathto the SDK).- Renders locally, fails on the server — a local Liquid render does not prove the server can render it, because the two Liquid implementations differ. Check the template with a real batch or a debug dashboard.
- Empty chart or
undefinedvalues — the variable name in the template doesn't match a column in the query result. Check the SQL aliases. Column names are case-sensitive and lowercased by default. - Liquid syntax error — the error surfaced in the app includes the offending tag. Verify tag pairs (
{% for %}/{% endfor %},{% if %}/{% endif %}) and that any filters you used are part of the standard Liquid set.