From ff7c7b89335e3d3c463a57cd24321a1419a587ed Mon Sep 17 00:00:00 2001 From: Ivan Topolnjak Date: Sun, 24 Feb 2019 18:49:59 +0100 Subject: implement a generic abstraction for handling Tags --- .../src/test/scala/kamon/tag/TagsSpec.scala | 180 +++++++++++ kamon-core/src/main/scala/kamon/tag/Lookups.scala | 149 +++++++++ kamon-core/src/main/scala/kamon/tag/Tags.scala | 346 +++++++++++++++++++++ 3 files changed, 675 insertions(+) create mode 100644 kamon-core-tests/src/test/scala/kamon/tag/TagsSpec.scala create mode 100644 kamon-core/src/main/scala/kamon/tag/Lookups.scala create mode 100644 kamon-core/src/main/scala/kamon/tag/Tags.scala diff --git a/kamon-core-tests/src/test/scala/kamon/tag/TagsSpec.scala b/kamon-core-tests/src/test/scala/kamon/tag/TagsSpec.scala new file mode 100644 index 00000000..1a83e1c9 --- /dev/null +++ b/kamon-core-tests/src/test/scala/kamon/tag/TagsSpec.scala @@ -0,0 +1,180 @@ +package kamon.tag + +import java.util.Optional + +import org.scalatest.{Matchers, WordSpec} + +import scala.collection.JavaConverters.mapAsJavaMapConverter + +class TagsSpec extends WordSpec with Matchers { + import Lookups._ + + "Tags" should { + "silently drop null and unacceptable keys and/or values when constructed from the companion object builders" in { + Tags.from(NullString, NullString).all().size shouldBe 0 + Tags.from(EmptyString, NullString).all().size shouldBe 0 + Tags.from(EmptyString, "value").all().size shouldBe 0 + Tags.from(NullString, "value").all().size shouldBe 0 + Tags.from("key", NullString).all().size shouldBe 0 + Tags.from("key", NullBoolean).all().size shouldBe 0 + Tags.from("key", NullLong).all().size shouldBe 0 + + Tags.from(BadScalaTagMap).all().size shouldBe 0 + Tags.from(BadJavaTagMap).all().size shouldBe 0 + } + + "silently drop null keys and/or values when created with the .withTag, withTags or .and methods" in { + val tags = Tags.from("initialKey", "initialValue") + .withTag(NullString, NullString) + .withTag(EmptyString, NullString) + .withTag(EmptyString, "value") + .withTag(NullString, "value") + .withTag("key", NullString) + .withTag("key", NullBoolean) + .withTag("key", NullLong) + .and(NullString, NullString) + .and(EmptyString, NullString) + .and(EmptyString, "value") + .and(NullString, "value") + .and("key", NullString) + .and("key", NullBoolean) + .and("key", NullLong) + + tags.all().length shouldBe 1 + tags.all().head.asInstanceOf[Tag.String].key shouldBe "initialKey" + tags.all().head.asInstanceOf[Tag.String].value shouldBe "initialValue" + } + + "create a properly populated instance when valid pairs are provided" in { + Tags.from("isAwesome", true).all().size shouldBe 1 + Tags.from("name", "kamon").all().size shouldBe 1 + Tags.from("age", 5L).all().size shouldBe 1 + + Tags.from(GoodScalaTagMap).all().size shouldBe 3 + Tags.from(GoodJavaTagMap).all().size shouldBe 3 + + Tags.from("initial", "initial") + .withTag("isAwesome", true) + .withTag("name", "Kamon") + .withTag("age", 5L) + .and("isAvailable", true) + .and("website", "kamon.io") + .and("supportedPlatforms", 1L) + .all().size shouldBe 7 + } + + "override pre-existent tags when merging with other Tags instance" in { + val leftTags = Tags.from(GoodScalaTagMap) + val rightTags = Tags + .from("name", "New Kamon") + .and("age", 42L) + .and("isAwesome", false) // just for testing :) + + val tags = leftTags.withTags(rightTags) + tags.get(plain("name")) shouldBe "New Kamon" + tags.get(plainLong("age")) shouldBe 42L + tags.get(plainBoolean("isAwesome")) shouldBe false + + val andTags = tags and leftTags + andTags.get(plain("name")) shouldBe "Kamon" + andTags.get(plainLong("age")) shouldBe 5L + andTags.get(plainBoolean("isAwesome")) shouldBe true + } + + "provide typed access to the contained pairs when looking up values" in { + val tags = Tags.from(GoodScalaTagMap) + + tags.get(plain("name")) shouldBe "Kamon" + tags.get(plain("none")) shouldBe null + tags.get(option("name")) shouldBe Option("Kamon") + tags.get(option("none")) shouldBe None + tags.get(optional("name")) shouldBe Optional.of("Kamon") + tags.get(optional("none")) shouldBe Optional.empty() + + tags.get(plainLong("age")) shouldBe 5L + tags.get(plainLong("nil")) shouldBe null + tags.get(longOption("age")) shouldBe Option(5L) + tags.get(longOption("nil")) shouldBe None + tags.get(longOptional("age")) shouldBe Optional.of(5L) + tags.get(longOptional("nil")) shouldBe Optional.empty() + + tags.get(plainBoolean("isAwesome")) shouldBe true + tags.get(plainBoolean("isUnknown")) shouldBe null + tags.get(booleanOption("isAwesome")) shouldBe Some(true) + tags.get(booleanOption("isUnknown")) shouldBe None + tags.get(booleanOptional("isAwesome")) shouldBe Optional.of(true) + tags.get(booleanOptional("isUnknown")) shouldBe Optional.empty() + + tags.get(coerce("age")) shouldBe "5" + tags.get(coerce("isAwesome")) shouldBe "true" + tags.get(coerce("unknown")) shouldBe "unknown" + } + + "allow iterating over all contained tags" in { + val tags = Tags.from(Map( + "age" -> 5L, + "name" -> "Kamon", + "isAwesome" -> true, + "hasTracing" -> true, + "website" -> "kamon.io", + "luckyNumber" -> 7L + )) + + tags.iterator().length shouldBe 6 + tags.iterator().find(matchPair("age", 5L)) shouldBe defined + tags.iterator().find(matchPair("luckyNumber", 7L)) shouldBe defined + tags.iterator().find(matchPair("hasTracing", true)) shouldBe defined + tags.iterator().find(matchPair("isAwesome", true)) shouldBe defined + tags.iterator().find(matchPair("website", "kamon.io")) shouldBe defined + tags.iterator().find(matchPair("name", "Kamon")) shouldBe defined + } + + "be equal to other Tags instance with the same tags" in { + Tags.from(GoodScalaTagMap) shouldBe Tags.from(GoodScalaTagMap) + Tags.from(GoodJavaTagMap) shouldBe Tags.from(GoodJavaTagMap) + } + + "have a readable toString implementation" in { + Tags.from(GoodScalaTagMap).toString() should include("age=5") + Tags.from(GoodScalaTagMap).toString() should include("name=Kamon") + Tags.from(GoodScalaTagMap).toString() should include("isAwesome=true") + } + } + + def matchPair(key: String, value: Any) = { tag: Tag => { + tag match { + case t: Tag.String => t.key == key && t.value == value + case t: Tag.Long => t.key == key && t.value == value + case t: Tag.Boolean => t.key == key && t.value == value + } + + }} + + + val NullString: java.lang.String = null + val NullBoolean: java.lang.Boolean = NullString.asInstanceOf[java.lang.Boolean] + val NullLong: java.lang.Long = null + val EmptyString: java.lang.String = "" + + val GoodScalaTagMap: Map[String, Any] = Map( + "age" -> 5L, + "name" -> "Kamon", + "isAwesome" -> true + ) + + val BadScalaTagMap: Map[String, Any] = Map( + NullString -> NullString, + EmptyString -> NullString, + NullString -> NullString, + EmptyString -> NullString, + EmptyString -> "value", + NullString -> "value", + "key" -> NullString, + "key" -> NullBoolean, + "key" -> NullLong + ) + + val GoodJavaTagMap = GoodScalaTagMap.asJava + val BadJavaTagMap = BadScalaTagMap.asJava + +} diff --git a/kamon-core/src/main/scala/kamon/tag/Lookups.scala b/kamon-core/src/main/scala/kamon/tag/Lookups.scala new file mode 100644 index 00000000..44ffb6f4 --- /dev/null +++ b/kamon-core/src/main/scala/kamon/tag/Lookups.scala @@ -0,0 +1,149 @@ +package kamon.tag + +import java.util.Optional +import java.lang.{Boolean => JBoolean, Long => JLong, String => JString} + +import kamon.tag.Tags.Lookup + +import scala.reflect.ClassTag + +object Lookups { + + /** + * Finds a String value associated to the provided key and returns it. If the key is not present or the value + * associated with they is not a String then a null is returned. + */ + def plain(key: JString) = new Lookup[JString] { + override def run(storage: Map[JString, Any]): JString = + findAndTransform(key, storage, _plainString, null) + } + + + /** + * Finds a String value associated to the provided key and returns it, wrapped in an Option[String]. If the key is + * not present or the value associated with they is not a String then a None is returned. + */ + def option(key: JString) = new Lookup[Option[JString]] { + override def run(storage: Map[JString, Any]): Option[JString] = + findAndTransform(key, storage, _stringOption, None) + } + + + /** + * Finds a String value associated to the provided key and returns it, wrapped in an Optional[String]. If the key + * is not present or the value associated with they is not a String then Optional.empty() is returned. + */ + def optional(key: JString) = new Lookup[Optional[String]] { + override def run(storage: Map[String, Any]): Optional[String] = + findAndTransform(key, storage, _stringOptional, Optional.empty()) + } + + + /** + * Finds the value associated to the provided key and coerces it to a String representation. If the key is not + * present then "unknown" (as a String) will be returned. If the value associated with the key is not a String then + * the value of the key will be transformed into a String and returned. + * + * This lookup type is guaranteed to return a non-null String representation of value. + */ + def coerce(key: String) = new Lookup[String] { + override def run(storage: Map[String, Any]): String = { + val value = storage(key) + if(value == null) + "unknown" + else + value.toString + } + } + + + /** + * Finds a Boolean value associated to the provided key and returns it. If the key is not present or the value + * associated with they is not a Boolean then a null is returned. + */ + def plainBoolean(key: String) = new Lookup[JBoolean] { + override def run(storage: Map[String, Any]): JBoolean = + findAndTransform(key, storage, _plainBoolean, null) + } + + + /** + * Finds a Boolean value associated to the provided key and returns it, wrapped in an Option[Boolean]. If the key + * is not present or the value associated with they is not a Boolean then a None is returned. + */ + def booleanOption(key: String) = new Lookup[Option[JBoolean]] { + override def run(storage: Map[String, Any]): Option[JBoolean] = + findAndTransform(key, storage, _booleanOption, None) + } + + + /** + * Finds a Boolean value associated to the provided key and returns it, wrapped in an Optional[Boolean]. If the key + * is not present or the value associated with they is not a Boolean then Optional.empty() is returned. + */ + def booleanOptional(key: String) = new Lookup[Optional[JBoolean]] { + override def run(storage: Map[String, Any]): Optional[JBoolean] = + findAndTransform(key, storage, _booleanOptional, Optional.empty()) + } + + + /** + * Finds a Long value associated to the provided key and returns it. If the key is not present or the value + * associated with they is not a Long then a null is returned. + */ + def plainLong(key: String) = new Lookup[JLong] { + override def run(storage: Map[String, Any]): JLong = + findAndTransform(key, storage, _plainLong, null) + } + + + /** + * Finds a Long value associated to the provided key and returns it, wrapped in an Option[Long]. If the key is + * not present or the value associated with they is not a Long then a None is returned. + */ + def longOption(key: String) = new Lookup[Option[JLong]] { + override def run(storage: Map[String, Any]): Option[JLong] = + findAndTransform(key, storage, _longOption, None) + } + + + /** + * Finds a Long value associated to the provided key and returns it, wrapped in an Optional[Long]. If the key + * is not present or the value associated with they is not a Long then Optional.empty() is returned. + */ + def longOptional(key: String) = new Lookup[Optional[JLong]] { + override def run(storage: Map[String, Any]): Optional[JLong] = + findAndTransform(key, storage, _longOptional, Optional.empty()) + } + + + //////////////////////////////////////////////////////////////// + // Transformation helpers for the lookup DSL // + //////////////////////////////////////////////////////////////// + + private def findAndTransform[T, R](key: String, storage: Map[String, Any], transform: R => T, default: T) + (implicit ct: ClassTag[R]): T = { + + // This assumes that this code will only be used to lookup values from a Tags instance + // for which the underlying map always has "null" as the default value. + val value = storage(key) + + if(value == null || !ct.runtimeClass.isInstance(value)) + default + else + transform(value.asInstanceOf[R]) + } + + private val _plainString = (a: JString) => a + private val _stringOption = (a: JString) => Option(a) + private val _stringOptional = (a: JString) => Optional.of(a) + + private val _plainLong = (a: JLong) => a + private val _longOption = (a: JLong) => Option(a) + private val _longOptional = (a: JLong) => Optional.of(a) + + private val _plainBoolean = (a: JBoolean) => a + private val _booleanOption = (a: JBoolean) => Option(a) + private val _booleanOptional = (a: JBoolean) => Optional.of(a) + +} diff --git a/kamon-core/src/main/scala/kamon/tag/Tags.scala b/kamon-core/src/main/scala/kamon/tag/Tags.scala new file mode 100644 index 00000000..b7813da6 --- /dev/null +++ b/kamon-core/src/main/scala/kamon/tag/Tags.scala @@ -0,0 +1,346 @@ +package kamon.tag + +import kamon.tag.Tags.Lookup + +import scala.collection.JavaConverters.asScalaIteratorConverter +import java.lang.{Boolean => JBoolean, Long => JLong, String => JString} + +import org.slf4j.LoggerFactory + + +/** + * Marker trait for allowed Tag implementations. Users are not meant to create implementations of this trait outside + * of Kamon. + */ +sealed trait Tag + +object Tag { + + /** + * Represents a String key pointing to a String value. + */ + trait String extends Tag { + def key: JString + def value: JString + } + + /** + * Represents a String key pointing to a Boolean value. + */ + trait Boolean extends Tag { + def key: JString + def value: JBoolean + } + + /** + * Represents a String key pointing to a Long value. + */ + trait Long extends Tag { + def key: JString + def value: JLong + } +} + + +/** + * A immutable collection of key/value pairs with specialized support for storing String keys pointing to String, Long + * and/or Boolean values. + * + * Instances of Tags store all pairs in the same data structure, but preserving type information for the stored pairs + * and providing a simple DSL for accessing those values and expressing type expectations. It is also possible to + * lookup pairs without prescribing a mechanism for handling missing values. I.e. users of this class can decide + * whether to receive a null, java.util.Optional, scala.Option or any other value when looking up a pair. + * + * Tags can only be created from the builder functions on the Tags companion object. There are two different options + * to read the contained pairs from a Tags instance: + * + * 1. Using the lookup DSL. You can use the Lookup DSL when you know exactly that you are trying to get out of the + * tags instance. The lookup DSL is biased towards String keys since they are by far the most common case. For + * example, to get a given tag as an Option[String] and another as an Option[Boolean] the following code should + * suffice: + * + * import kamon.tag.Tags.Lookup._ + * val tags = Tags.from(tagMap) + * val name = tags.get(option("name")) + * val isSignedIn = tags.get(booleanOption("isSignedIn")) + * + * 2. Using the .all() and .iterator variants. This option requires you to test the returned instances to verify + * whether they are a Tag.String, Tag.Long or Tag.Boolean instance and act accordingly. Fortunately this + * cumbersome operation is rarely necessary on user-facing code. + * + */ +class Tags private(private val _tags: Map[String, Any]) { + import Tags.withPair + + /** + * Creates a new Tags instance that includes the provided key/value pair. If the provided key was already associated + * with another value then the previous value will be discarded and overwritten with the provided one. + */ + def withTag(key: String, value: JString): Tags = + withPair(this, key, value) + + + /** + * Creates a new Tags instance that includes the provided key/value pair. If the provided key was already associated + * with another value then the previous value will be discarded and overwritten with the provided one. + */ + def withTag(key: String, value: JBoolean): Tags = + withPair(this, key, value) + + + /** + * Creates a new Tags instance that includes the provided key/value pair. If the provided key was already associated + * with another value then the previous value will be discarded and overwritten with the provided one. + */ + def withTag(key: String, value: JLong): Tags = + withPair(this, key, value) + + + /** + * Creates a new Tags instance that includes all the tags from the provided Tags instance. If any of the tags in this + * instance are associated to a key present on the provided instance then the previous value will be discarded and + * overwritten with the provided one. + */ + def withTags(other: Tags): Tags = + new Tags(_tags ++ other._tags) + + + /** + * Creates a new Tags instance that includes the provided key/value pair. If the provided key was already associated + * with another value then the previous value will be discarded and overwritten with the provided one. + */ + def and(key: String, value: JString): Tags = + withPair(this, key, value) + + + /** + * Creates a new Tags instance that includes the provided key/value pair. If the provided key was already associated + * with another value then the previous value will be discarded and overwritten with the provided one. + */ + def and(key: String, value: JBoolean): Tags = + withPair(this, key, value) + + + /** + * Creates a new Tags instance that includes the provided key/value pair. If the provided key was already associated + * with another value then the previous value will be discarded and overwritten with the provided one. + */ + def and(key: String, value: JLong): Tags = + withPair(this, key, value) + + + /** + * Creates a new Tags instance that includes all the tags from the provided Tags instance. If any of the tags in this + * instance are associated to a key present on the provided instance then the previous value will be discarded and + * overwritten with the provided one. + */ + def and(other: Tags): Tags = + new Tags(_tags ++ other._tags) + + /** + * Executes a tag lookup on the instance. The return type of this function will depend on the provided Lookup + * instance. Take a look at the built-in lookups on the Tags.Lookup companion object for more information. + */ + def get[T](lookup: Lookup[T]): T = + lookup.run(_tags) + + /** + * Returns a immutable sequence of tags created from the contained tags internal representation. Calling this method + * will cause the creation of a new data structure. Unless you really need to have all the tags as immutable + * instances it is recommended to use the .iterator() function instead. + * + * The returned sequence contains immutable values and is safe to share across threads. + */ + def all(): Seq[Tag] = + _tags.foldLeft(List.empty[Tag]) { + case (ts, (key, value)) => value match { + case v: String => new immutable.String(key, v) :: ts + case v: Boolean => new immutable.Boolean(key, v) :: ts + case v: Long => new immutable.Long(key, v) :: ts + } + } + + /** + * Returns an iterator of tags. The underlying iterator reuses the Tag instances to avoid unnecessary intermediate + * allocations and thus, it is not safe to share across threads. The most common case for tags iterators is on + * reporters which will need to iterate through all existent tags only to copy their values into a separate data + * structure that will be sent to the external systems. + */ + def iterator(): Iterator[Tag] = new Iterator[Tag] { + private val _entriesIterator = _tags.iterator + private var _longTag: mutable.Long = null + private var _stringTag: mutable.String = null + private var _booleanTag: mutable.Boolean = null + + override def hasNext: Boolean = + _entriesIterator.hasNext + + override def next(): Tag = { + val (key, value) = _entriesIterator.next() + value match { + case v: String => stringTag(key, v) + case v: Boolean => booleanTag(key, v) + case v: Long => longTag(key, v) + } + } + + private def stringTag(key: JString, value: JString): Tag.String = + if(_stringTag == null) { + _stringTag = new mutable.String(key, value) + _stringTag + } else _stringTag.updated(key, value) + + private def booleanTag(key: JString, value: JBoolean): Tag.Boolean = + if(_booleanTag == null) { + _booleanTag = new mutable.Boolean(key, value) + _booleanTag + } else _booleanTag.updated(key, value) + + private def longTag(key: JString, value: JLong): Tag.Long = + if(_longTag == null) { + _longTag = new mutable.Long(key, value) + _longTag + } else _longTag.updated(key, value) + } + + + override def equals(other: Any): Boolean = + other != null && other.isInstanceOf[Tags] && other.asInstanceOf[Tags]._tags == this._tags + + + override def toString: JString = { + val sb = new StringBuilder() + sb.append("Tags{") + + var hasTags = false + _tags.foreach { case (k, v) => + if(hasTags) + sb.append(",") + + sb.append(k) + .append("=") + .append(v) + + hasTags = true + } + + sb.append("}").toString() + } + + private object immutable { + class String(val key: JString, val value: JString) extends Tag.String + class Boolean(val key: JString, val value: JBoolean) extends Tag.Boolean + class Long(val key: JString, val value: JLong) extends Tag.Long + } + + private object mutable { + class String(var key: JString, var value: JString) extends Tag.String with Updateable[JString] + class Boolean(var key: JString, var value: JBoolean) extends Tag.Boolean with Updateable[JBoolean] + class Long(var key: JString, var value: JLong) extends Tag.Long with Updateable[JLong] + + trait Updateable[T] { + var key: JString + var value: T + + def updated(key: JString, value: T): this.type = { + this.key = key + this.value = value + this + } + } + } +} + +object Tags { + + /** + * A valid instance of tags that doesn't contain any pairs. + */ + val Empty = new Tags(Map.empty.withDefaultValue(null)) + + + /** + * Construct a new Tags instance with a single key/value pair. + */ + def from(key: String, value: JString): Tags = + withPair(Empty, key, value) + + + /** + * Construct a new Tags instance with a single key/value pair. + */ + def from(key: String, value: JBoolean): Tags = + withPair(Empty, key, value) + + + /** + * Construct a new Tags instance with a single key/value pair. + */ + def from(key: String, value: JLong): Tags = + withPair(Empty, key, value) + + + /** + * Constructs a new Tags instance from a Map. The returned Tags will only contain the entries that have String, Long + * or Boolean values from the supplied map, any other entry in the map will be ignored. + */ + def from(map: Map[String, Any]): Tags = + new Tags(map.filter { case (k, v) => isValidPair(k, v) } withDefaultValue(null)) + + + /** + * Constructs a new Tags instance from a Map. The returned Tags will only contain the entries that have String, Long + * or Boolean values from the supplied map, any other entry in the map will be ignored. + */ + def from(map: java.util.Map[String, Any]): Tags = { + val allowedTags = Map.newBuilder[String, Any] + map.entrySet() + .iterator() + .asScala + .foreach(e => if(isValidPair(e.getKey, e.getValue)) allowedTags += (e.getKey -> e.getValue)) + + new Tags(allowedTags.result().withDefaultValue(null)) + } + + + private val _logger = LoggerFactory.getLogger(classOf[Tags]) + + private def withPair(parent: Tags, key: String, value: Any): Tags = + if(isValidPair(key, value)) + new Tags(parent._tags.updated(key, value)) + else + parent + + private def isValidPair(key: String, value: Any): Boolean = { + val isValidKey = key != null && key.nonEmpty + val isValidValue = isAllowedTagValue(value) + val isValid = isValidKey && isValidValue + + if(!isValid && _logger.isDebugEnabled) { + if(!isValidKey && !isValidValue) + _logger.debug(s"Dismissing tag with invalid key [$key] and invalid value [$value]") + else if(!isValidKey) + _logger.debug(s"Dismissing tag with invalid key [$key] and value [$value]") + else + _logger.debug(s"Dismissing tag with key [$key] and invalid value [$value]") + } + + isValid + } + + private def isAllowedTagValue(v: Any): Boolean = + v != null && (v.isInstanceOf[String] || v.isInstanceOf[Boolean] || v.isInstanceOf[Long]) + + + /** + * Describes a strategy to lookup values from a Tags instance. Implementations of this interface will be provided + * with the actual data structure containing the tags and must perform any necessary runtime type checks to ensure + * that the returned value is in assignable to the expected type T. + * + * Several implementation are provided in the Lookup companion object and it is recommended to import and use those + * definitions when looking up keys from a Tags instance. + */ + trait Lookup[T] { + def run(storage: Map[String, Any]): T + } +} \ No newline at end of file -- cgit v1.2.3