All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.ovoenergy.natchez.extras.dogstatsd.Dogstatsd.scala Maven / Gradle / Ivy

There is a newer version: 8.1.1
Show newest version
package com.ovoenergy.natchez.extras.dogstatsd

import cats.effect.Resource
import com.comcast.ip4s.{IpAddress, SocketAddress}
import com.ovoenergy.natchez.extras.dogstatsd.Events.Event
import com.ovoenergy.natchez.extras.metrics.Metrics
import com.ovoenergy.natchez.extras.metrics.Metrics.Metric
import fs2.Chunk.array
import fs2.io.net.{Datagram, DatagramSocket, Network}

import java.nio.charset.StandardCharsets.UTF_8

object Dogstatsd {

  // just for readability
  type UTF8Bytes = Array[Byte]

  private implicit class StringOps(s: String) {
    def utf8Bytes: UTF8Bytes = s.getBytes(UTF_8)
  }

  private implicit class ByteArrayOps(bs: UTF8Bytes) {
    def sep(char: Char)(other: UTF8Bytes): UTF8Bytes = (bs :+ char.toByte) ++ other
    def colon: UTF8Bytes => UTF8Bytes = sep(':')
    def pipe: UTF8Bytes => UTF8Bytes = sep('|')
  }

  case class Config(
    agentHost: SocketAddress[IpAddress],
    metricPrefix: Option[String],
    globalTags: Map[String, String]
  )

  /**
   * https://docs.datadoghq.com/developers/metrics/#naming-custom-metrics
   */
  val maxMetricNameLength = 200

  /**
   * https://docs.datadoghq.com/tagging/
   */
  val maxTagLength = 200

  /**
   * Apparently the maximum UDP packet size is 65535 bytes (at the absolute maximum)
   * and we have many different strings in a packet so we only allow each one to be 2k chars
   */
  val maxStringLength = 2000

  /**
   * A basic sanity check for the maximum number of tags to send to Datadog
   * 20 x 200 (max key + value size) = 4k chars so seems okay
   */
  val maximumTagCount = 20

  /**
   * Datadog enforces all metrics must start with a letter
   * and not contain any chars other than letters, numbers and underscores
   */
  private[extras] def filterName(s: String): String =
    s.dropWhile(!_.isLetter).replaceAll("[^A-Za-z0-9.]+", "_").take(maxMetricNameLength)

  /**
   * More lenient filtering for tag values,
   * we allow alpha numeric characters, slashes, hyphens and numbers
   */
  private[extras] def filterTagValue(s: String): String =
    s.replaceAll("[^A-Za-z0-9./\\-]+", "_")

  /**
   * Datadog receives events as binary byte arrays then converts them into UTF-8 strings
   * https://github.com/DataDog/datadog-agent/blob/21a80ab80de389adb9c74e3e9a7162f83fda3e0c/pkg/dogstatsd/parse_events.go#L62
   * As such we obtain the byte values from the string in UTF-8
   */
  private[extras] def filterEventText(s: String): UTF8Bytes =
    s.take(maxStringLength).replaceAll("[\\r\\n]", "\\\\n").utf8Bytes

  private def serialiseTags(t: Map[String, String]): UTF8Bytes = {
    t.toList
      .take(maximumTagCount)
      .map { case (k, v) => s"${filterName(k)}:${filterTagValue(v)}" }
      .filter(tag => tag.length <= maxTagLength)
      .reduceOption(_ + "," + _)
      .fold(Array.empty[Byte])(ts => s"|#$ts".utf8Bytes)
  }

  private[extras] def serialiseCounter(m: Metric, value: Long): UTF8Bytes =
    s"${filterName(m.name)}:$value|c".utf8Bytes ++ serialiseTags(m.tags)

  private[extras] def serialiseHistogram(m: Metric, value: Long): UTF8Bytes =
    s"${filterName(m.name)}:$value|h|@1.0".utf8Bytes ++ serialiseTags(m.tags)

  private[extras] def serialiseGauge(m: Metric, value: Long): UTF8Bytes =
    s"${filterName(m.name)}:$value|g".utf8Bytes ++ serialiseTags(m.tags)

  private[extras] def serialiseDistribution(m: Metric, value: Long): UTF8Bytes =
    s"${filterName(m.name)}:$value|d|@1.0".utf8Bytes ++ serialiseTags(m.tags)

  private[extras] def serialiseEvent(e: Event): UTF8Bytes = {
    val body = filterEventText(e.body)
    val title = filterEventText(e.title)
    val lengths = s"{${title.length},${body.length}}".utf8Bytes
    val meta = s"t:${e.alertType.value}|p:${e.priority.value}".utf8Bytes
    "_e".utf8Bytes ++ lengths.colon(title).pipe(body).pipe(meta ++ serialiseTags(e.tags))
  }

  private[extras] def applyConfig(m: Metric, config: Config): Metric =
    Metric((config.metricPrefix.toList :+ m.name).mkString("."), config.globalTags ++ m.tags)

  /**
   * Take care of the gymnastics required to send a string to the `to` destination through
   * a socket in F.
   */
  private def send[F[_]](s: DatagramSocket[F], to: SocketAddress[IpAddress], what: UTF8Bytes): F[Unit] =
    s.write(Datagram(to, array(what)))

  /**
   * Create an instance of Metrics that uses a UDP socket to communicate with Datadog.
   */
  def apply[F[_]: Network](config: Config): Resource[F, Metrics[F] with Events[F]] = {
    Network[F].openDatagramSocket().map { sock =>
      new Metrics[F] with Events[F] {
        def counter(m: Metric)(value: Long): F[Unit] =
          send[F](sock, config.agentHost, serialiseCounter(applyConfig(m, config), value))
        def histogram(m: Metric)(value: Long): F[Unit] =
          send[F](sock, config.agentHost, serialiseHistogram(applyConfig(m, config), value))
        def event(event: Event): F[Unit] =
          send[F](sock, config.agentHost, serialiseEvent(event.withTags(config.globalTags ++ event.tags)))
        def gauge(m: Metric)(value: Long): F[Unit] =
          send[F](sock, config.agentHost, serialiseGauge(applyConfig(m, config), value))
        def distribution(m: Metric)(value: Long): F[Unit] =
          send[F](sock, config.agentHost, serialiseDistribution(applyConfig(m, config), value))
      }
    }
  }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy