diff --git a/atlas-core/src/main/scala/com/netflix/atlas/core/model/QueryVocabulary.scala b/atlas-core/src/main/scala/com/netflix/atlas/core/model/QueryVocabulary.scala index 40d2836ac..f744c53cd 100644 --- a/atlas-core/src/main/scala/com/netflix/atlas/core/model/QueryVocabulary.scala +++ b/atlas-core/src/main/scala/com/netflix/atlas/core/model/QueryVocabulary.scala @@ -19,6 +19,7 @@ import com.netflix.atlas.core.stacklang.SimpleWord import com.netflix.atlas.core.stacklang.StandardVocabulary import com.netflix.atlas.core.stacklang.Vocabulary import com.netflix.atlas.core.stacklang.Word +import com.netflix.spectator.impl.matcher.PatternUtils object QueryVocabulary extends Vocabulary { @@ -39,6 +40,7 @@ object QueryVocabulary extends Vocabulary { GreaterThanEqual, Regex, RegexIgnoreCase, + Contains, In, And, Or, @@ -329,6 +331,38 @@ object QueryVocabulary extends Vocabulary { List("name,DiscoveryStatus_(UP|DOWN)", "name,discoverystatus_(Up|Down)", "ERROR:name") } + case object Contains extends KeyValueWord { + + override def name: String = "contains" + + def newInstance(k: String, v: String): Query = Query.Regex(k, s".*${PatternUtils.escape(v)}") + + override def summary: String = + """ + |Query expression that matches time series with a value that contains the given + |sequence of characters. This version is case sensitive. + | + |> :warning: This operation always requires a full scan and should be avoided if at all + |possible. Queries using this operation may be de-priortized. + | + |Suppose you have four time series: + | + |* `name=http.requests, status=200, nf.app=server` + |* `name=sys.cpu, type=user, nf.app=foo` + |* `name=sys.cpu, type=user, nf.app=bar` + |* `name=sys.cpu, type=user, nf.app=foobar` + | + |The query `nf.app,bar,:contains` would match series with "bar" anywhere in + |the string: + | + |* `name=sys.cpu, type=user, nf.app=bar` + |* `name=sys.cpu, type=user, nf.app=foobar` + """.stripMargin.trim + + override def examples: List[String] = + List("name,request", "result,error") + } + case object In extends SimpleWord { override def name: String = "in" diff --git a/atlas-core/src/test/scala/com/netflix/atlas/core/db/MemoryDatabaseSuite.scala b/atlas-core/src/test/scala/com/netflix/atlas/core/db/MemoryDatabaseSuite.scala index 5f9f792c5..2050da22f 100644 --- a/atlas-core/src/test/scala/com/netflix/atlas/core/db/MemoryDatabaseSuite.scala +++ b/atlas-core/src/test/scala/com/netflix/atlas/core/db/MemoryDatabaseSuite.scala @@ -117,6 +117,10 @@ class MemoryDatabaseSuite extends FunSuite { assertEquals(exec("name,[ab]$,:re"), List(ts("sum(name~/^[ab]$/)", 1, 4.0, 4.0, 4.0))) } + test(":contains query") { + assertEquals(exec("name,a,:contains"), List(ts("sum(name~/^.*a/)", 1, 1.0, 2.0, 3.0))) + } + test(":has query") { assertEquals(exec("name,:has"), List(ts("sum(has(name))", 1, 19.0, 22.0, 25.0))) } diff --git a/atlas-core/src/test/scala/com/netflix/atlas/core/model/ModelExtractorsSuite.scala b/atlas-core/src/test/scala/com/netflix/atlas/core/model/ModelExtractorsSuite.scala index 7c246fec0..c9b820bd6 100644 --- a/atlas-core/src/test/scala/com/netflix/atlas/core/model/ModelExtractorsSuite.scala +++ b/atlas-core/src/test/scala/com/netflix/atlas/core/model/ModelExtractorsSuite.scala @@ -33,7 +33,7 @@ class ModelExtractorsSuite extends FunSuite { } completionTest("name", 8) - completionTest("name,sps", 19) + completionTest("name,sps", 20) completionTest("name,sps,:eq", 20) completionTest("name,sps,:eq,app,foo,:eq", 41) completionTest("name,sps,:eq,app,foo,:eq,:and,(,asg,)", 12) diff --git a/atlas-core/src/test/scala/com/netflix/atlas/core/model/QuerySuite.scala b/atlas-core/src/test/scala/com/netflix/atlas/core/model/QuerySuite.scala index 3e1bf273d..faf56ecca 100644 --- a/atlas-core/src/test/scala/com/netflix/atlas/core/model/QuerySuite.scala +++ b/atlas-core/src/test/scala/com/netflix/atlas/core/model/QuerySuite.scala @@ -233,7 +233,7 @@ class QuerySuite extends FunSuite { } test("matchesAny re with key match") { - val q = GreaterThan("foo", "b") + val q = Regex("foo", "b") assert(matchesAny(q, Map("foo" -> List("bar"), "bar" -> List("foo")))) assert(matchesAny(q, Map("foo" -> List("foo", "bar"), "bar" -> List("foo")))) assert(matchesAny(q, Map("foo" -> List("bar", "baz"), "bar" -> List("foo")))) @@ -650,4 +650,5 @@ class QuerySuite extends FunSuite { val q = Or(Equal("a", "1"), In("b", List("1", "2"))) assertEquals(Query.expandInClauses(q, 1), List(q)) } + } diff --git a/atlas-core/src/test/scala/com/netflix/atlas/core/model/QueryVocabularySuite.scala b/atlas-core/src/test/scala/com/netflix/atlas/core/model/QueryVocabularySuite.scala new file mode 100644 index 000000000..8dd365c52 --- /dev/null +++ b/atlas-core/src/test/scala/com/netflix/atlas/core/model/QueryVocabularySuite.scala @@ -0,0 +1,34 @@ +package com.netflix.atlas.core.model + +import com.netflix.atlas.core.model.Query.Regex +import com.netflix.atlas.core.stacklang.Interpreter +import munit.FunSuite + +class QueryVocabularySuite extends FunSuite { + + val interpreter = new Interpreter(QueryVocabulary.allWords) + + test("contains, escape") { + var exp = interpreter.execute("a,^$.?*+[](){}\\#&!%,:contains").stack(0) + assertEquals( + exp.asInstanceOf[Regex].pattern.toString, + ".*\\^\\$\\.\\?\\*\\+\\[\\]\\(\\)\\{\\}\\\\#&!%" + ) + exp = interpreter.execute("a,space and ~,:contains").stack(0) + assertEquals( + exp.asInstanceOf[Regex].pattern.toString, + ".*space\\u0020and\\u0020~" + ) + } + + test("contains, matches escaped") { + val q = interpreter + .execute("foo,my $var. [work-in-progress],:contains") + .stack(0) + .asInstanceOf[Regex] + assert(q.matches(Map("foo" -> "my $var. [work-in-progress]"))) + assert(q.matches(Map("foo" -> "initialize my $var. [work-in-progress], not a range"))) + assert(!q.matches(Map("foo" -> "my $var. [work-in progress]"))) + } + +} diff --git a/atlas-core/src/test/scala/com/netflix/atlas/core/model/TimeSeriesExprSuite.scala b/atlas-core/src/test/scala/com/netflix/atlas/core/model/TimeSeriesExprSuite.scala index 604e186cc..e2a7df800 100644 --- a/atlas-core/src/test/scala/com/netflix/atlas/core/model/TimeSeriesExprSuite.scala +++ b/atlas-core/src/test/scala/com/netflix/atlas/core/model/TimeSeriesExprSuite.scala @@ -52,6 +52,7 @@ class TimeSeriesExprSuite extends FunSuite { "name,1,:eq" -> const(ts(Map("name" -> "1"), 1)), "name,1,:re" -> const(ts(unknownTag, 11)), "name,2,:re" -> const(ts(unknownTag, 2)), + "name,2,:contains" -> const(ts(unknownTag, 2)), "name,(,1,10,),:in" -> const(ts(unknownTag, 11)), "name,1,:eq,name,10,:eq,:or" -> const(ts(unknownTag, 11)), ":true,:abs" -> const(ts(unknownTag, "abs(name=unknown)", 55.0)), diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 1429d9699..3e3f55c13 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -18,7 +18,7 @@ object Dependencies { val log4j = "2.18.0" val scala = "2.13.8" val slf4j = "1.7.36" - val spectator = "1.3.7" + val spectator = "1.3.8" val spring = "5.3.22" val crossScala = Seq(scala)