feat(logql): Supporting prettifying LogQL expressions (#7906)

**What this PR does / why we need it**:

Changes
1. Added `Pretty()` method to `Expr` interface. So that every expression
would know how to render themselfs as pretty :)
2. Implemented `Pretty()` for every single LogQL expressions
3. Exposed `/api/v1/format_query` endpoint that takes `query` argument
and returns formatted version.
4. Integerated into `logcli`. echo `'<query>' | logcli fmt` would format
the query.

Why?
1. Readability, helpful in debugging and **more importantly, to
understand the execution flow**

example

**before**
```
quantile_over_time(0.99,{container="ingress-nginx",service="hosted-grafana"}| json| unwrap response_latency_seconds| __error__=""[1m]) by (cluster)
```

```
sum(rate({job="loki", namespace="loki", cluster="loki-dev-us"} |= "err" [5m])) + sum(rate({job="loki-dev", namespace="loki", cluster="loki-dev-eu"}|logfmt | level != "info" [5m])) / sum(rate({job="loki-prod", namespace="loki", cluster="loki-prod-us"} |logfmt | level="error"[5m]))
```

```
label_replace(rate({job="api-server",service="a:c"}|= "err" [5m]), "foo", "$1", "service", "(.*):.*")
```

**after**
```
quantile_over_time(
  0.99,
  {container="ingress-nginx", service="hosted-grafana"} | json
    | unwrap response_latency_seconds
    | __error__="" [1m]
) by (cluster)

```

```
  sum(
    rate(
      {job="loki", namespace="loki", cluster="loki-dev-us"} |= "err" [5m]
    )
  )
+
    sum(
      rate(
        {job="loki-dev", namespace="loki", cluster="loki-dev-eu"}
          | logfmt
          | level!="info" [5m]
      )
    )
  /
    sum(
      rate(
        {job="loki-prod", namespace="loki", cluster="loki-prod-us"}
          | logfmt
          | level="error" [5m]
      )
    )

```

```
label_replace(
  rate({job="api-server", service="a:c"} |= "err"[5m]),
  "foo",
  "$1",
  "service",
  "(.*):.*"
)

```

You can find more examples in the `prettier_test.go`



Future plans
* Integerate into LogQL analyzer
* Integrate into Grafana UI.

**Which issue(s) this PR fixes**:
Fixes # NA

**Special notes for your reviewer**:
This whole idea was inspired from last [PromCon lighting
talk](https://youtu.be/pjkWzDVxWk4?t=24469)

**Checklist**
- [x] Reviewed the `CONTRIBUTING.md` guide
- [x] Documentation added
- [x] Tests updated
- [x] `CHANGELOG.md` updated

Signed-off-by: Kaviraj <kavirajkanagaraj@gmail.com>
Co-authored-by: Christian Haudum <christian.haudum@gmail.com>
Co-authored-by: Christian Simon <simon@swine.de>
pull/7937/head^2
Kaviraj Kanagaraj 2 years ago committed by GitHub
parent c71620ae94
commit cbd6ec15ce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      CHANGELOG.md
  2. 25
      cmd/logcli/main.go
  3. 13
      docs/sources/api/_index.md
  4. 5
      pkg/logql/syntax/ast.go
  5. 406
      pkg/logql/syntax/prettier.go
  6. 406
      pkg/logql/syntax/prettier_test.go
  7. 24
      pkg/loki/format_query_handler.go
  8. 1
      pkg/loki/loki.go

@ -21,6 +21,7 @@
* [7708](https://github.com/grafana/loki/pull/7708) **DylanGuedes**: Fix multitenant querying.
* [7784](https://github.com/grafana/loki/pull/7784) **isodude**: Fix default values of connect addresses for compactor and querier workers to work with IPv6.
* [7880](https://github.com/grafana/loki/pull/7880) **sandeepsukhani**: consider range and offset in queries while looking for schema config for query sharding.
* [7906](https://github.com/grafana/loki/pull/7906) **kavirajk**: Add API endpoint that formats LogQL expressions and support new `fmt` subcommand in `logcli` to format LogQL query.
##### Changes

@ -1,6 +1,8 @@
package main
import (
"fmt"
"io"
"log"
"math"
"net/url"
@ -18,6 +20,7 @@ import (
"github.com/grafana/loki/pkg/logcli/output"
"github.com/grafana/loki/pkg/logcli/query"
"github.com/grafana/loki/pkg/logcli/seriesquery"
"github.com/grafana/loki/pkg/logql/syntax"
_ "github.com/grafana/loki/pkg/util/build"
)
@ -109,6 +112,8 @@ Use the --analyze-labels flag to get a summary of the labels found in all stream
This is helpful to find high cardinality labels.
`)
seriesQuery = newSeriesQuery(seriesCmd)
fmtCmd = app.Command("fmt", "Formats a LogQL query.")
)
func main() {
@ -213,7 +218,27 @@ func main() {
labelsQuery.DoLabels(queryClient)
case seriesCmd.FullCommand():
seriesQuery.DoSeries(queryClient)
case fmtCmd.FullCommand():
if err := formatLogQL(os.Stdin, os.Stdout); err != nil {
log.Fatalf("unable to format logql: %s", err)
}
}
}
func formatLogQL(r io.Reader, w io.Writer) error {
b, err := io.ReadAll(r)
if err != nil {
return err
}
expr, err := syntax.ParseExpr(string(b))
if err != nil {
return fmt.Errorf("failed to parse the query: %w", err)
}
fmt.Fprintf(w, "%s\n", syntax.Prettify(expr))
return nil
}
func newQueryClient(app *kingpin.Application) client.Client {

@ -23,6 +23,7 @@ These endpoints are exposed by all components:
- [`GET /config`](#list-current-configuration)
- [`GET /services`](#list-running-services)
- [`GET /loki/api/v1/status/buildinfo`](#list-build-information)
- [`GET /loki/api/v1/format_query`](#format-query)
These endpoints are exposed by the querier and the query frontend:
@ -703,6 +704,18 @@ GET /loki/api/v1/status/buildinfo
`/loki/api/v1/status/buildinfo` exposes the build information in a JSON object. The fields are `version`, `revision`, `branch`, `buildDate`, `buildUser`, and `goVersion`.
## Format query
```
GET /loki/api/v1/format_query
POST /loki/api/v1/format_query
```
Params:
- `query`: A LogQL query string. Can be passed as URL param (`?query=<query>`) in case of both `GET` and `POST`. Or as form value in case of `POST`.
The `/loki/api/v1/format_query` endpoint allows to format LogQL queries. It returns an error if the passed LogQL is invalid. It is exposed by all Loki components and helps to improve readability and the debugging experience of LogQL queries.
## List series
The Series API is available under the following:

@ -23,6 +23,9 @@ type Expr interface {
Shardable() bool // A recursive check on the AST to see if it's shardable.
Walkable
fmt.Stringer
// Pretty prettyfies any LogQL expression at given `level` of the whole LogQL query.
Pretty(level int) string
}
func Clone(e Expr) (Expr, error) {
@ -658,7 +661,7 @@ const (
OpTypeAnd = "and"
OpTypeUnless = "unless"
// binops - operations
// binops - arithmetic
OpTypeAdd = "+"
OpTypeSub = "-"
OpTypeMul = "*"

@ -0,0 +1,406 @@
// LogQL formatter is inspired from PromQL formatter
// https://github.com/prometheus/prometheus/blob/release-2.40/promql/parser/prettier.go
// https://youtu.be/pjkWzDVxWk4?t=24469
package syntax
import (
"fmt"
"strconv"
"strings"
"github.com/prometheus/common/model"
)
// How LogQL formatter works?
// =========================
// General idea is to parse the LogQL query(string) and converts it into AST(expressions) first, then format each expression from bottom up (from leaf expressions to the root expression). Every expression in AST has a level/depth (distance from the root), that is passed by it's parent.
//
// While prettifying an expression, we consider two things:
// 1. Did the current expression's parent add a new line?
// 2. Does the current expression exceeds `maxCharsPerLine` limit?
//
// The level of a expression determines if it should be indented or not.
// The answer to the 1 is NO if the level passed is 0. This means, the
// parent expression did not apply a new line, so the current Node must not
// apply any indentation as prefix.
// If level > 1, a new line is applied by the parent. So, the current expression
// should prefix an indentation before writing any of its content. This indentation
// will be ([level/depth of current expression] * " ").
//
// The answer to 2 is YES if the normalized length of the current expression exceeds
// the `maxCharsPerLine` limit. Hence, it applies the indentation equal to
// its depth and increments the level by 1 before passing down the child.
// If the answer is NO, the current expression returns the normalized string value of itself.
//
var (
// maxCharsPerLine is used to qualify whether some LogQL expressions are worth `splitting` into new lines.
maxCharsPerLine = 100
)
func Prettify(e Expr) string {
return e.Pretty(0)
}
// e.g: `{foo="bar"}`
func (e *MatchersExpr) Pretty(level int) string {
return commonPrefixIndent(level, e)
}
// e.g: `{foo="bar"} | logfmt | level="error"`
// Here, left = `{foo="bar"}` and multistages would collection of each stage in pipeline, here `logfmt` and `level="error"`
func (e *PipelineExpr) Pretty(level int) string {
if !needSplit(e) {
return indent(level) + e.String()
}
s := fmt.Sprintf("%s\n", e.Left.Pretty(level))
for i, ms := range e.MultiStages {
s += ms.Pretty(level + 1)
//NOTE: Needed because, we tend to format multiple stage in pipeline as each stage in single line
// e.g:
// | logfmt
// | level = "error"
// But all the stages will have same indent level. So here we don't increase level.
if i < len(e.MultiStages)-1 {
s += "\n"
}
}
return s
}
// e.g: `|= "error" != "memcache" |= ip("192.168.0.1")`
// NOTE: here `ip` is Op in this expression.
func (e *LineFilterExpr) Pretty(level int) string {
if !needSplit(e) {
return indent(level) + e.String()
}
var s string
if e.Left != nil {
// s += indent(level)
s += e.Left.Pretty(level)
// NOTE: Similar to PiplelinExpr, we also have to format every LineFilterExpr in new line. But with same indendation level.
// e.g:
// |= "error"
// != "memcached"
// |= ip("192.168.0.1")
s += "\n"
}
s += indent(level)
// We re-use LineFilterExpr's String() implementation to avoid duplication.
// We create new LineFilterExpr without `Left`.
ne := newLineFilterExpr(e.Ty, e.Op, e.Match)
s += ne.String()
return s
}
// e.g:
// `| logfmt`
// `| json`
// `| regexp`
// `| pattern`
// `| unpack`
func (e *LabelParserExpr) Pretty(level int) string {
return commonPrefixIndent(level, e)
}
// e.g: | level!="error"
func (e *LabelFilterExpr) Pretty(level int) string {
return commonPrefixIndent(level, e)
}
// e.g: | line_format "{{ .label }}"
func (e *LineFmtExpr) Pretty(level int) string {
return commonPrefixIndent(level, e)
}
// e.g: | decolorize
func (e *DecolorizeExpr) Pretty(level int) string {
return e.String()
}
// e.g: | label_format dst="{{ .src }}"
func (e *LabelFmtExpr) Pretty(level int) string {
return commonPrefixIndent(level, e)
}
// e.g: | json label="expression", another="expression"
func (e *JSONExpressionParser) Pretty(level int) string {
return commonPrefixIndent(level, e)
}
// e.g: sum_over_time({foo="bar"} | logfmt | unwrap bytes_processed [5m])
func (e *UnwrapExpr) Pretty(level int) string {
s := indent(level)
if e.Operation != "" {
s += fmt.Sprintf("%s %s %s(%s)", OpPipe, OpUnwrap, e.Operation, e.Identifier)
} else {
s += fmt.Sprintf("%s %s %s", OpPipe, OpUnwrap, e.Identifier)
}
for _, f := range e.PostFilters {
s += fmt.Sprintf("\n%s%s %s", indent(level), OpPipe, f)
}
return s
}
// e.g: `{foo="bar"}|logfmt[5m]`
// TODO(kavi): Rename `LogRange` -> `LogRangeExpr` (to be consistent with other expressions?)
func (e *LogRange) Pretty(level int) string {
s := e.Left.Pretty(level)
if e.Unwrap != nil {
// NOTE: | unwrap should go to newline
s += "\n"
s += e.Unwrap.Pretty(level + 1)
}
// TODO: this will put [1m] on the same line, not in new line as people used to now.
s = fmt.Sprintf("%s [%s]", s, model.Duration(e.Interval))
if e.Offset != 0 {
oe := OffsetExpr{Offset: e.Offset}
s += oe.Pretty(level)
}
return s
}
// e.g: count_over_time({foo="bar"}[5m] offset 3h)
// TODO(kavi): why does offset not work in log queries? e.g: `{foo="bar"} offset 1h`? is it bug? or anything else?
// NOTE: Also offset expression never to be indented. It always goes with its parent expression (usually RangeExpr).
func (e *OffsetExpr) Pretty(level int) string {
// using `model.Duration` as it can format ignoring zero units.
// e.g: time.Duration(2 * Hour) -> "2h0m0s"
// but model.Duration(2 * Hour) -> "2h"
return fmt.Sprintf(" %s %s", OpOffset, model.Duration(e.Offset))
}
// e.g: count_over_time({foo="bar"}[5m])
func (e *RangeAggregationExpr) Pretty(level int) string {
s := indent(level)
if !needSplit(e) {
return s + e.String()
}
s += e.Operation // e.g: quantile_over_time
s += "(\n"
// print args to the function.
if e.Params != nil {
s = fmt.Sprintf("%s%s%s,", s, indent(level+1), fmt.Sprint(*e.Params))
s += "\n"
}
s += e.Left.Pretty(level + 1)
s += "\n" + indent(level) + ")"
if e.Grouping != nil {
s += e.Grouping.Pretty(level)
}
return s
}
// e.g:
// sum(count_over_time({foo="bar"}[5m])) by (container)
// topk(10, count_over_time({foo="bar"}[5m])) by (container)
// Syntax: <aggr-op>([parameter,] <vector expression>) [without|by (<label list>)]
// <aggr-op> - sum, avg, bottomk, topk, etc.
// [parameters,] - optional params, used only by bottomk and topk for now.
// <vector expression> - vector on which aggregation is done.
// [without|by (<label list)] - optional labels to aggregate either with `by` or `without` clause.
func (e *VectorAggregationExpr) Pretty(level int) string {
s := indent(level)
if !needSplit(e) {
return s + e.String()
}
var params []string
// level + 1 because arguments to function will be in newline.
left := e.Left.Pretty(level + 1)
switch e.Operation {
// e.Params default value (0) can mean a legit param for topk and bottomk
case OpTypeBottomK, OpTypeTopK:
params = []string{fmt.Sprintf("%s%d", indent(level+1), e.Params), left}
default:
if e.Params != 0 {
params = []string{fmt.Sprintf("%s%d", indent(level+1), e.Params), left}
} else {
params = []string{left}
}
}
s += e.Operation
if e.Grouping != nil {
s += e.Grouping.Pretty(level)
}
// (\n [params,\n])
s += "(\n"
for i, v := range params {
s += v
// LogQL doesn't allow `,` at the end of last argument.
if i < len(params)-1 {
s += ","
}
s += "\n"
}
s += indent(level) + ")"
return s
}
// e.g: Any operations involving
// "or", "and" and "unless" (logical/set)
// "+", "-", "*", "/", "%", "^" (arithmetic)
// "==", "!=", ">", ">=", "<", "<=" (comparison)
func (e *BinOpExpr) Pretty(level int) string {
s := indent(level)
if !needSplit(e) {
return s + e.String()
}
s = e.SampleExpr.Pretty(level+1) + "\n"
op := formatBinaryOp(e.Op, e.Opts)
s += indent(level) + op + "\n"
s += e.RHS.Pretty(level + 1)
return s
}
// e.g: 4.6
func (e *LiteralExpr) Pretty(level int) string {
return commonPrefixIndent(level, e)
}
// e.g: label_replace(rate({job="api-server",service="a:c"}[5m]), "foo", "$1", "service", "(.*):.*")
func (e *LabelReplaceExpr) Pretty(level int) string {
s := indent(level)
if !needSplit(e) {
return s + e.String()
}
s += OpLabelReplace
s += "(\n"
params := []string{
e.Left.Pretty(level + 1),
indent(level+1) + strconv.Quote(e.Dst),
indent(level+1) + strconv.Quote(e.Replacement),
indent(level+1) + strconv.Quote(e.Src),
indent(level+1) + strconv.Quote(e.Regex),
}
for i, v := range params {
s += v
// LogQL doesn't allow `,` at the end of last argument.
if i < len(params)-1 {
s += ","
}
s += "\n"
}
s += indent(level) + ")"
return s
}
// e.g: vector(5)
func (e *VectorExpr) Pretty(level int) string {
return commonPrefixIndent(level, e)
}
// Grouping is techincally not expression type. But used in both range and vector aggregations (`by` and `without` clause)
// So by implenting `Pretty` for Grouping, we can re use it for both.
// NOTE: indent is ignored for `Grouping`, because grouping always stays in the same line of it's parent expression.
// e.g:
// by(container,namespace) -> by (container, namespace)
func (g *Grouping) Pretty(_ int) string {
var s string
if g.Without {
s += " without"
} else if len(g.Groups) > 0 {
s += " by"
}
if len(g.Groups) > 0 {
s += " ("
s += strings.Join(g.Groups, ", ")
s += ")"
}
return s
}
// Helpers
func commonPrefixIndent(level int, current Expr) string {
return fmt.Sprintf("%s%s", indent(level), current.String())
}
func needSplit(e Expr) bool {
if e == nil {
return false
}
return len(e.String()) > maxCharsPerLine
}
const indentString = " "
func indent(level int) string {
return strings.Repeat(indentString, level)
}
func formatBinaryOp(op string, opts *BinOpOptions) string {
if opts == nil {
return op
}
if opts.ReturnBool {
// e.g: ">= bool 1"
op += " bool"
}
if opts.VectorMatching != nil {
group := "" // default one-to-one
if opts.VectorMatching.Card == CardManyToOne {
group = OpGroupLeft
}
if opts.VectorMatching.Card == CardOneToMany {
group = OpGroupRight
}
if len(opts.VectorMatching.Include) > 0 {
// e.g: group_left (node, name)
group = fmt.Sprintf("%s (%s)", group, strings.Join(opts.VectorMatching.Include, ", "))
}
if len(opts.VectorMatching.MatchingLabels) > 0 {
on := OpOn
if !opts.VectorMatching.On {
on = OpIgnoring
}
// e.g: * on (cluster, namespace) group_left
op = fmt.Sprintf("%s %s (%s) %s", op, on, strings.Join(opts.VectorMatching.MatchingLabels, ", "), group)
}
}
return op
}

@ -0,0 +1,406 @@
package syntax
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFormat(t *testing.T) {
maxCharsPerLine = 20
cases := []struct {
name string
in string
exp string
}{
{
name: "basic stream selector",
in: `{job="loki", instance="localhost"}`,
exp: `{job="loki", instance="localhost"}`,
},
{
name: "pipeline_label_filter",
in: `{job="loki", instance="localhost"}|logfmt|level="error" `,
exp: `{job="loki", instance="localhost"}
| logfmt
| level="error"`,
},
{
name: "pipeline_line_filter",
in: `{job="loki", instance="localhost"}|= "error" != "memcached" |= ip("192.168.0.1") |logfmt`,
exp: `{job="loki", instance="localhost"}
|= "error"
!= "memcached"
|= ip("192.168.0.1")
| logfmt`,
},
{
name: "pipeline_line_format",
in: `{job="loki", instance="localhost"}|logfmt|line_format "{{.error}}"`,
exp: `{job="loki", instance="localhost"}
| logfmt
| line_format "{{.error}}"`,
},
{
name: "pipeline_label_format",
in: `{job="loki", instance="localhost"}|logfmt|label_format dst="{{.src}}"`,
exp: `{job="loki", instance="localhost"}
| logfmt
| label_format dst="{{.src}}"`,
},
{
name: "aggregation",
in: `count_over_time({job="loki", instance="localhost"}|logfmt[1m])`,
exp: `count_over_time(
{job="loki", instance="localhost"}
| logfmt [1m]
)`,
},
{
name: "aggregation_with_offset",
in: `count_over_time({job="loki", instance="localhost"}|= "error"[5m] offset 20m)`,
exp: `count_over_time(
{job="loki", instance="localhost"}
|= "error" [5m] offset 20m
)`,
},
{
name: "unwrap",
in: `quantile_over_time(0.99,{container="ingress-nginx",service="hosted-grafana"}| json| unwrap response_latency_seconds| __error__=""[1m]) by (cluster)`,
exp: `quantile_over_time(
0.99,
{container="ingress-nginx", service="hosted-grafana"}
| json
| unwrap response_latency_seconds
| __error__="" [1m]
) by (cluster)`,
},
{
name: "pipeline_aggregation_line_filter",
in: `count_over_time({job="loki", instance="localhost"}|= "error" != "memcached" |= ip("192.168.0.1") |logfmt[1m])`,
exp: `count_over_time(
{job="loki", instance="localhost"}
|= "error"
!= "memcached"
|= ip("192.168.0.1")
| logfmt [1m]
)`,
},
{
name: "jsonparserExpr",
in: `{job="loki", namespace="loki-prod", container="nginx-ingress"}| json first_server="servers[0]", ua="request.headers[\"User-Agent\"]" | level="error"`,
exp: `{job="loki", namespace="loki-prod", container="nginx-ingress"}
| json first_server="servers[0]",ua="request.headers[\"User-Agent\"]"
| level="error"`,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
expr, err := ParseExpr(c.in)
require.NoError(t, err)
got := Prettify(expr)
assert.Equal(t, c.exp, got)
})
}
}
func TestFormat_VectorAggregation(t *testing.T) {
maxCharsPerLine = 20
cases := []struct {
name string
in string
exp string
}{
{
name: "sum",
in: `sum(count_over_time({foo="bar",namespace="loki",instance="localhost"}[5m])) by (container)`,
exp: `sum by (container)(
count_over_time(
{foo="bar", namespace="loki", instance="localhost"} [5m]
)
)`,
},
{
name: "topk",
in: `topk(5, count_over_time({foo="bar",namespace="loki",instance="localhost"}[5m])) by (container)`,
exp: `topk by (container)(
5,
count_over_time(
{foo="bar", namespace="loki", instance="localhost"} [5m]
)
)`,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
expr, err := ParseExpr(c.in)
require.NoError(t, err)
got := Prettify(expr)
assert.Equal(t, c.exp, got)
})
}
}
func TestFormat_LabelReplace(t *testing.T) {
maxCharsPerLine = 20
cases := []struct {
name string
in string
exp string
}{
{
name: "label_replace",
in: `label_replace(rate({job="api-server",service="a:c"}|= "err" [5m]), "foo", "$1", "service", "(.*):.*")`,
exp: `label_replace(
rate(
{job="api-server", service="a:c"}
|= "err" [5m]
),
"foo",
"$1",
"service",
"(.*):.*"
)`,
},
{
name: "label_replace_nested",
in: `label_replace(label_replace(rate({job="api-server",service="a:c"}|= "err" [5m]), "foo", "$1", "service", "(.*):.*"), "foo", "$1", "service", "(.*):.*")`,
exp: `label_replace(
label_replace(
rate(
{job="api-server", service="a:c"}
|= "err" [5m]
),
"foo",
"$1",
"service",
"(.*):.*"
),
"foo",
"$1",
"service",
"(.*):.*"
)`,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
expr, err := ParseExpr(c.in)
require.NoError(t, err)
got := Prettify(expr)
assert.Equal(t, c.exp, got)
})
}
}
func TestFormat_BinOp(t *testing.T) {
maxCharsPerLine = 20
cases := []struct {
name string
in string
exp string
}{
{
name: "single binop",
in: `sum(rate({job="loki", namespace="loki-prod", instance="localhost"}[5m]))/sum(count_over_time({job="loki", namespace="loki-prod", instance="localhost"}[5m]))`,
exp: ` sum(
rate(
{job="loki", namespace="loki-prod", instance="localhost"} [5m]
)
)
/
sum(
count_over_time(
{job="loki", namespace="loki-prod", instance="localhost"} [5m]
)
)`,
},
{
name: "multiple binops",
in: `sum(rate({job="loki"}[5m])) + sum(rate({job="loki-dev"}[5m])) / sum(rate({job="loki-prod"}[5m]))`,
exp: ` sum(
rate(
{job="loki"} [5m]
)
)
+
sum(
rate(
{job="loki-dev"} [5m]
)
)
/
sum(
rate(
{job="loki-prod"} [5m]
)
)`,
},
// NOTE: LogQL binary arithmetic ops have following precedences rules
// 1. * / % - higher priority
// 2. + - - lower priority.
// 3. Between same priority ops, whichever comes first takes precedence.
// Following `_precedence*` tests makes sure LogQL formatter respects that.
{
name: "multiple binops check precedence",
in: `sum(rate({job="loki"}[5m])) / sum(rate({job="loki-dev"}[5m])) + sum(rate({job="loki-prod"}[5m]))`,
exp: ` sum(
rate(
{job="loki"} [5m]
)
)
/
sum(
rate(
{job="loki-dev"} [5m]
)
)
+
sum(
rate(
{job="loki-prod"} [5m]
)
)`,
},
{
name: "multiple binops check precedence2",
in: `sum(rate({job="loki"}[5m])) - sum(rate({job="loki-stage"}[5m])) / sum(rate({job="loki-dev"}[5m])) + sum(rate({job="loki-prod"}[5m]))`,
exp: ` sum(
rate(
{job="loki"} [5m]
)
)
-
sum(
rate(
{job="loki-stage"} [5m]
)
)
/
sum(
rate(
{job="loki-dev"} [5m]
)
)
+
sum(
rate(
{job="loki-prod"} [5m]
)
)`,
},
{
name: "multiple binops check precedence3",
in: `sum(rate({job="loki"}[5m])) - sum(rate({job="loki-stage"}[5m])) % sum(rate({job="loki-dev"}[5m])) + sum(rate({job="loki-prod"}[5m]))`,
exp: ` sum(
rate(
{job="loki"} [5m]
)
)
-
sum(
rate(
{job="loki-stage"} [5m]
)
)
%
sum(
rate(
{job="loki-dev"} [5m]
)
)
+
sum(
rate(
{job="loki-prod"} [5m]
)
)`,
},
{
name: "multiple binops check precedence4",
in: `sum(rate({job="loki"}[5m])) / sum(rate({job="loki-stage"}[5m])) % sum(rate({job="loki-dev"}[5m])) + sum(rate({job="loki-prod"}[5m]))`,
exp: ` sum(
rate(
{job="loki"} [5m]
)
)
/
sum(
rate(
{job="loki-stage"} [5m]
)
)
%
sum(
rate(
{job="loki-dev"} [5m]
)
)
+
sum(
rate(
{job="loki-prod"} [5m]
)
)`,
},
{
name: "multiple binops check precedence5",
in: `sum(rate({job="loki"}[5m])) / sum(rate({job="loki-stage"}[5m])) % sum(rate({job="loki-dev"}[5m])) * sum(rate({job="loki-prod"}[5m]))`,
exp: ` sum(
rate(
{job="loki"} [5m]
)
)
/
sum(
rate(
{job="loki-stage"} [5m]
)
)
%
sum(
rate(
{job="loki-dev"} [5m]
)
)
*
sum(
rate(
{job="loki-prod"} [5m]
)
)`,
},
{
name: "binops with options", // options - on, ignoring, group_left, group_right
in: `sum(rate({job="loki"}[5m])) * on(instance, job) group_left (node) sum(rate({job="loki-prod"}[5m]))`,
exp: ` sum(
rate(
{job="loki"} [5m]
)
)
* on (instance, job) group_left (node)
sum(
rate(
{job="loki-prod"} [5m]
)
)`,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
expr, err := ParseExpr(c.in)
require.NoError(t, err)
got := Prettify(expr)
assert.Equal(t, c.exp, got)
})
}
}

@ -0,0 +1,24 @@
package loki
import (
"fmt"
"net/http"
"github.com/grafana/loki/pkg/logql/syntax"
serverutil "github.com/grafana/loki/pkg/util/server"
)
func formatQueryHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
expr, err := syntax.ParseExpr(r.FormValue("query"))
if err != nil {
serverutil.WriteError(err, w)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "%s", syntax.Prettify(expr))
}
}

@ -483,6 +483,7 @@ func (t *Loki) Run(opts RunOpts) error {
t.Server.HTTP.Path("/loki/api/v1/status/buildinfo").Methods("GET").HandlerFunc(versionHandler())
t.Server.HTTP.Path("/debug/fgprof").Methods("GET", "POST").Handler(fgprof.Handler())
t.Server.HTTP.Path("/loki/api/v1/format_query").Methods("GET", "POST").HandlerFunc(formatQueryHandler())
// Let's listen for events from this manager, and log them.
healthy := func() { level.Info(util_log.Logger).Log("msg", "Loki started") }

Loading…
Cancel
Save