Description
While I (personally, with obvious bias) believe the learning curve of River isn't too high:
- Some people may still be uncomfortable learning something new.
- River doesn't integrate as well with tooling like Helm or Jsonnet and requires using helper libraries like river-jsonnet.
One possible way to solve these problems is to introduce JSON and YAML representation that can be used in place of River, but still support River expressions instead of them.
Proposal
There are three challenges with mapping JSON and YAML to River:
- The representation of blocks (e.g., being able to distinguish between YAML creating a block vs creating an attribute)
- The representation of River expressions.
- The representation of multiple blocks of the same name (e.g.,
rule
blocks inprometheus.relabel
).
I propose that blocks be given a special syntax of block BLOCK_NAME BLOCK_LABEL
. For example: block prometheus.remote_write default
. Note the lack of quotes for the label; quotes are avoided to make it easier for JSON, which would otherwise require escaping the double quotes. This introduces the risk of improperly formatting the block, such as not putting a space between the block name and block label.
Expressions will be represented by the interpolated string syntax, ${EXPR}
, as proposed in grafana/river#15.
If there is content before or after the expression, the expression is converted into a string and concatenated to the strings before and after it:
field: "Hello, ${2022 + 1}!"
would evaluate to:
field: "Hello, 2023!"
A string which contains only the interpolated string syntax is treated as a pure expression:
field: "${2022}"
would evaluate to a number, not a string:
field: 2022
If the expression cannot be converted into a string, a VM error would be produced.
Finally, blocks may be defined as an array if there are multiple blocks of the same name:
block rule:
- # relabel rule 1
- # relabel rule 2
- ...
Internally, JSON and YAML files will be parsed into a River AST. AST elements which store position (like the position of {
for a block) will have to get creative with where a {
hypothetically would be for the start of the block.
Examples
The following examples are all three equivalent Flow configuration files using River, YAML, and JSON:
logging {
level = "debug"
format = "logfmt"
}
tracing {
sampling_fraction = 1
write_to = [otelcol.exporter.otlp.tempo.input]
}
otelcol.exporter.otlp "tempo" {
client {
endpoint = "localhost:4317"
tls {
insecure = true
}
}
}
prometheus.integration.node_exporter { /* use defaults */ }
prometheus.scrape "default" {
targets = prometheus.integration.node_exporter.targets
forward_to = [prometheus.remote_write.default.receiver]
}
prometheus.remote_write "default" {
endpoint {
url = "http://localhost:9009/api/prom/push"
}
}
block logging:
level: debug
format: logfmt
block tracing:
sampling_fraction: 1
write_to:
- ${otelcol.exporter.otlp.tempo.input}
block otelcol.exporter.otlp tempo:
block client:
endpoint: localhost:4317
block tls:
insecure: true
block prometheus.integration.node_exporter: {} # use defaults
block prometheus.scrape default:
targets: ${prometheus.integration.node_exporter.targets}
forward_to:
- ${prometheus.remote_write.default.receiver}
block prometheus.remote_write default:
block endpoint:
url: http://localhost:9009/api/prom/push
{
"block logging": {
"level": "debug",
"format": "logfmt"
},
"block tracing": {
"sampling_fraction": 1,
"write_to": [
"${otelcol.exporter.otlp.tempo.input}"
]
},
"block otelcol.exporter.otlp tempo": {
"block client": {
"endpoint": "localhost:4317",
"block tls": {
"insecure": true
}
}
},
"block prometheus.integration.node_exporter": {},
"block prometheus.scrape default": {
"targets": "${prometheus.integration.node_exporter.targets}",
"forward_to": [
"${prometheus.remote_write.default.receiver}"
]
},
"block prometheus.remote_write default": {
"block endpoint": [{
"url": "http://localhost:9009/api/prom/push"
}]
}
}
Potentially removing the "block" keyword
The block
keyword is needed to identify at parse time whether a key in a JSON or YAML object is referring to a block or an attribute. Other keywords could potentially be used which are less verbose.
Alternatively, the River AST could be modified to include an ambiguous AST node which either represents a block or an attribute. The River VM would have to be responsible for resolving this AST node into its final form, and fail the evaluation if an expression was used to assign a block. This shouldn't have any significant overhead, but would require introducing an AST element for something which isn't River.
Modifying the AST to remove the block keyword would change the above YAML to the following:
logging:
level: debug
format: logfmt
tracing:
sampling_fraction: 1
write_to:
- ${otelcol.exporter.otlp.tempo.input}
otelcol.exporter.otlp tempo:
client:
endpoint: localhost:4317
tls:
insecure: true
prometheus.integration.node_exporter: {} # use defaults
prometheus.scrape default:
targets: ${prometheus.integration.node_exporter.targets}
forward_to:
- ${prometheus.remote_write.default.receiver}
prometheus.remote_write default:
endpoint:
url: http://localhost:9009/api/prom/push
Counterarguments
There are some counterarguments I can imagine to this entire proposal:
- We haven't really gotten much complaints about River, so doing this might not be fully justified yet.
- River is still used here for expressions, and only the base syntax of the file themselves is JSON/YAML, so users would still need to learn River.
- River would still be preferable in the future for concepts which couldn't translate to JSON/YAML, such as variables.