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

goodmetrics.downstream.OpentelemetryClient.kt Maven / Gradle / Ivy

There is a newer version: 0.7.2
Show newest version
package goodmetrics.downstream

import goodmetrics.Metrics
import goodmetrics.io.opentelemetry.proto.collector.metrics.v1.MetricsServiceGrpcKt
import goodmetrics.io.opentelemetry.proto.collector.metrics.v1.exportMetricsServiceRequest
import goodmetrics.io.opentelemetry.proto.common.v1.KeyValue
import goodmetrics.io.opentelemetry.proto.common.v1.anyValue
import goodmetrics.io.opentelemetry.proto.common.v1.instrumentationScope
import goodmetrics.io.opentelemetry.proto.common.v1.keyValue
import goodmetrics.io.opentelemetry.proto.metrics.v1.AggregationTemporality
import goodmetrics.io.opentelemetry.proto.metrics.v1.Metric
import goodmetrics.io.opentelemetry.proto.metrics.v1.ResourceMetrics
import goodmetrics.io.opentelemetry.proto.metrics.v1.ScopeMetrics
import goodmetrics.io.opentelemetry.proto.metrics.v1.gauge
import goodmetrics.io.opentelemetry.proto.metrics.v1.histogram
import goodmetrics.io.opentelemetry.proto.metrics.v1.histogramDataPoint
import goodmetrics.io.opentelemetry.proto.metrics.v1.metric
import goodmetrics.io.opentelemetry.proto.metrics.v1.numberDataPoint
import goodmetrics.io.opentelemetry.proto.metrics.v1.resourceMetrics
import goodmetrics.io.opentelemetry.proto.metrics.v1.scopeMetrics
import goodmetrics.io.opentelemetry.proto.metrics.v1.sum
import goodmetrics.io.opentelemetry.proto.resource.v1.resource
import goodmetrics.pipeline.AggregatedBatch
import goodmetrics.pipeline.Aggregation
import goodmetrics.pipeline.bucket
import goodmetrics.pipeline.bucketBelow
import io.grpc.CallOptions
import io.grpc.ClientInterceptor
import io.grpc.ManagedChannel
import io.grpc.netty.GrpcSslContexts
import io.grpc.netty.NettyChannelBuilder
import io.netty.handler.ssl.util.InsecureTrustManagerFactory
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.LongAdder
import kotlin.time.Duration
import kotlin.time.Duration.Companion.nanoseconds
import kotlin.time.Duration.Companion.seconds

sealed interface PrescientDimensions {
    /**
     * Include resource dimensions on the OTLP resource.
     */
    data class AsResource(val resourceDimensions: Map) : PrescientDimensions

    /**
     * Include resource dimensions on each metric instead of on the Resource. You'd use this for
     * downstreams that either do not support or do something undesirable with Resource dimensions.
     */
    data class AsDimensions(val sharedDimensions: Map) : PrescientDimensions
}

enum class SecurityMode {
    Plaintext,
    Insecure,
    Tls,
}

sealed interface CompressionMode {
    object None : CompressionMode
    object Gzip : CompressionMode
    data class IKnowWhatIWant(val explicitMode: String) : CompressionMode
}

/**
 * This client should be used as a last resort, in defeat, if you
 * cannot use the goodmetrics protocol. Opentelemetry is highly
 * lossy and inflexible. I'm doing my best here, but you're not
 * getting the full goodmetrics experience if you're still
 * addicted to opentelemetry line protocol.
 */
class OpentelemetryClient(
    private val channel: ManagedChannel,
    private val prescientResourceDimensions: PrescientDimensions.AsResource,
    private val prescientSharedDimensions: PrescientDimensions.AsDimensions,
    private val timeout: Duration,
    private val logRawPayload: (ResourceMetrics) -> Unit = { },
    private val compressionMode: CompressionMode,
) : AutoCloseable {
    companion object {
        fun connect(
            sillyOtlpHostname: String = "localhost",
            port: Int = 5001,
            prescientResourceDimensions: PrescientDimensions.AsResource,
            prescientSharedDimensions: PrescientDimensions.AsDimensions,
            securityMode: SecurityMode,
            /**
             * stuff like MetadataUtils.newAttachHeadersInterceptor()
             */
            interceptors: List,
            timeout: Duration = 5.seconds,
            logRawPayload: (ResourceMetrics) -> Unit = { },
            compressionMode: CompressionMode = CompressionMode.None,
        ): OpentelemetryClient {
            val channelBuilder = NettyChannelBuilder.forAddress(sillyOtlpHostname, port)
            when (securityMode) {
                SecurityMode.Tls -> {
                    channelBuilder.useTransportSecurity()
                }
                SecurityMode.Insecure -> {
                    channelBuilder.useTransportSecurity()
                    channelBuilder.sslContext(
                        GrpcSslContexts.forClient()
                            .trustManager(InsecureTrustManagerFactory.INSTANCE)
                            .build()
                    )
                }
                SecurityMode.Plaintext -> {
                    channelBuilder.usePlaintext()
                }
            }
            channelBuilder.intercept(interceptors)
            return OpentelemetryClient(channelBuilder.build(), prescientResourceDimensions, prescientSharedDimensions, timeout, logRawPayload, compressionMode)
        }
    }
    private fun stub(): MetricsServiceGrpcKt.MetricsServiceCoroutineStub {
        val defaultCallOptions = CallOptions.DEFAULT
            .withDeadlineAfter(timeout.inWholeMilliseconds, TimeUnit.MILLISECONDS)
        val callOptions = when (compressionMode) {
            CompressionMode.None -> defaultCallOptions
            CompressionMode.Gzip -> defaultCallOptions.withCompression("gzip")
            is CompressionMode.IKnowWhatIWant -> defaultCallOptions.withCompression(compressionMode.explicitMode)
        }

        return MetricsServiceGrpcKt.MetricsServiceCoroutineStub(channel, callOptions)
    }

    suspend fun sendMetricsBatch(batch: List) {
        val resourceMetricsBatch = asResourceMetrics(batch)
        logRawPayload(resourceMetricsBatch)
        stub().export(
            exportMetricsServiceRequest {
                resourceMetrics.add(resourceMetricsBatch)
            }
        )
    }

    suspend fun sendPreaggregatedBatch(batch: List) {
        val resourceMetricsBatch = asResourceMetricsFromBatch(batch)
        logRawPayload(resourceMetricsBatch)
        stub().export(
            exportMetricsServiceRequest {
                resourceMetrics.add(resourceMetricsBatch)
            }
        )
    }

    private fun asResourceMetricsFromBatch(batch: List): ResourceMetrics {
        return resourceMetrics {
            resource = prescientResource
            for (aggregate in batch) {
                this.scopeMetrics.add(aggregate.asOtlpScopeMetrics())
            }
        }
    }

    private fun AggregatedBatch.asOtlpScopeMetrics(): ScopeMetrics = scopeMetrics {
        scope = library
        metrics.addAll([email protected]().asIterable())
    }

    private fun asResourceMetrics(batch: List): ResourceMetrics = resourceMetrics {
        resource = prescientResource
        this.scopeMetrics.add(asScopeMetrics(batch))
    }

    private fun asScopeMetrics(batch: List): ScopeMetrics = scopeMetrics {
        scope = library
        metrics.addAll(batch.asSequence().flatMap { it.asGoofyOtlpMetricSequence() }.asIterable())
    }

    private fun AggregatedBatch.asGoofyOtlpMetricSequence(): Sequence = sequence {
        for ((position, measurements) in [email protected]) {
            // Push down our shared dimensions to each datum leaf if required. For systems that may ingest OTLP metrics
            // but use a different backing system (e.g. OTLP -> Prometheus)
            val otlpDimensions = position.map { it.asOtlpKeyValue() } + prescientSharedDimensions.sharedDimensions.asOtlpDimensions()
            for ((measurementName, aggregation) in measurements) {
                when (aggregation) {
                    is Aggregation.Histogram -> {
                        yield(
                            metric {
                                name = "${[email protected]}_$measurementName"
                                unit = "1"
                                histogram = aggregation.asOtlpHistogram(otlpDimensions, [email protected], aggregationWidth)
                            }
                        )
                    }
                    is Aggregation.StatisticSet -> {
                        yieldAll(aggregation.statisticSetToOtlp([email protected], measurementName, timestampNanos, aggregationWidth, otlpDimensions))
                    }
                }
            }
        }
    }

    private fun Aggregation.StatisticSet.statisticSetToOtlp(
        metric: String,
        measurementName: String,
        timestampNanos: Long,
        aggregationWidth: Duration,
        dimensions: Iterable,
    ): Sequence = sequence {
        yield(statisticSetDataPointGauge(metric, measurementName, "min", min, timestampNanos, aggregationWidth, dimensions))
        yield(statisticSetDataPointGauge(metric, measurementName, "max", max, timestampNanos, aggregationWidth, dimensions))
        yield(statisticSetDataPointCounter(metric, measurementName, "sum", sum, timestampNanos, aggregationWidth, dimensions))
        yield(statisticSetDataPointCounter(metric, measurementName, "count", count, timestampNanos, aggregationWidth, dimensions))
    }

    private fun statisticSetDataPointCounter(
        metricName: String,
        measurementName: String,
        statisticSetComponent: String,
        value: Number,
        timestampNanos: Long,
        aggregationWidth: Duration,
        dimensions: Iterable,
    ): Metric = metric {
        name = "${metricName}_${measurementName}_$statisticSetComponent"
        unit = "1"
        sum = sum {
            isMonotonic = true
            // because cumulative is bullshit
            aggregationTemporality = AggregationTemporality.AGGREGATION_TEMPORALITY_DELTA
            dataPoints.add(newNumberDataPoint(value, timestampNanos, aggregationWidth, dimensions))
        }
    }

    private fun statisticSetDataPointGauge(
        metricName: String,
        measurementName: String,
        statisticSetComponent: String,
        value: Number,
        timestampNanos: Long,
        aggregationWidth: Duration,
        dimensions: Iterable,
    ): Metric = metric {
        name = "${metricName}_${measurementName}_$statisticSetComponent"
        unit = "1"
        gauge = gauge {
            dataPoints.add(newNumberDataPoint(value, timestampNanos, aggregationWidth, dimensions))
        }
    }


    private fun Metrics.asGoofyOtlpMetricSequence(): Sequence {
        val otlpDimensions = metricDimensions.values.map { it.asOtlpKeyValue() }
        return sequence {
            for ((measurementName, value) in [email protected]) {
                yield(
                    metric {
                        // name: format!("{metric_name}_{measurement_name}", metric_name = datum.metric, measurement_name=name),
                        name = "${[email protected]}_$measurementName"
                        unit = "1"
                        gauge = gauge {
                            this.dataPoints.add(newNumberDataPoint(value, timestampNanos, (System.nanoTime() - startNanoTime).nanoseconds, otlpDimensions.asIterable()))
                        }
                    }
                )
            }
            for ((measurementName, value) in [email protected]) {
                yield(
                    metric {
                        // name: format!("{metric_name}_{measurement_name}", metric_name = datum.metric, measurement_name=name),
                        name = "${[email protected]}_$measurementName"
                        unit = "1"
                        histogram = asOtlpHistogram(otlpDimensions, value)
                    }
                )
            }
        }
    }

    private fun newNumberDataPoint(value: Number, timestampNanos: Long, aggregationWidth: Duration, dimensions: Iterable) = numberDataPoint {
        this.timeUnixNano = timestampNanos
        this.startTimeUnixNano = timestampNanos - aggregationWidth.inWholeNanoseconds
        attributes.addAll(dimensions)
        if (value is Long || value is LongAdder) {
            asInt = value.toLong()
        } else {
            asDouble = value.toDouble()
        }
    }

    private fun Metrics.asOtlpHistogram(
        otlpDimensions: Iterable,
        value: Long
    ) = histogram {
        // Because cumulative is bullshit for service metrics. Change my mind.
        aggregationTemporality = AggregationTemporality.AGGREGATION_TEMPORALITY_DELTA
        dataPoints.add(
            histogramDataPoint {
                attributes.addAll(otlpDimensions)
                startTimeUnixNano = timestampNanos - (System.nanoTime() - startNanoTime) // approximate, whatever.
                timeUnixNano = timestampNanos
                count = 1

                val bucketValue = bucket(value)
                if (0 < bucketValue) {
                    // This little humdinger is here so Lightstep can interpret the boundary for the _real_ measurement
                    // below. It's similar to the 0 that opentelemetry demands, but different in that it is actually a
                    // reasonable ask.
                    // Lightstep has an internal representation of histograms & while I don't pretend  to understand
                    // how they've implemented them, they told me that they interpret the absence of a lower bounding
                    // bucket as an infinite lower bound. That's not consistent with my read of otlp BUT it makes
                    // infinitely more sense than imposing an upper infinity bucket upon your protocol.
                    // Prometheus is a cataclysm from which there is no redemption: It ruins developers' minds with
                    // its broken and much lauded blunders; it shames my profession by its protocol as well as those
                    // spawned through its vile influence and disappoints the thoughtful by its existence.
                    // But, you know, this particular thing for Lightstep seems fine because there's technical merit.
                    explicitBounds.add(bucketBelow(value).toDouble())
                    bucketCounts.add(0)
                }

                explicitBounds.add(bucketValue.toDouble())
                bucketCounts.add(1)
                bucketCounts.add(0) // otlp go die in a fire
            }
        )
    }

    private fun Aggregation.Histogram.asOtlpHistogram(
        otlpDimensions: Iterable,
        timestampNanos: Long,
        aggregationWidth: Duration,
    ) = histogram {
        // Because cumulative is bullshit for service metrics. Change my mind.
        aggregationTemporality = AggregationTemporality.AGGREGATION_TEMPORALITY_DELTA
        dataPoints.add(
            histogramDataPoint {
                attributes.addAll(otlpDimensions)
                startTimeUnixNano = timestampNanos - aggregationWidth.inWholeNanoseconds
                timeUnixNano = timestampNanos
                val sorted = [email protected]()

                count = [email protected] { it.sum() }
                for ((bucket, count) in sorted) {
                    val below = bucketBelow(bucket)
                    if (0 < below && [email protected](below)) {
                        // And THIS little humdinger is here so Lightstep can interpret the boundary for all non-zero
                        // buckets. Lightstep histogram implementation wants non-zero-count ranges to have lower bounds.
                        // Not how I've done histograms in the past but :shrug: whatever, looks like the opentelemetry
                        // metrics spec is at fault for this one; they refused to improve the specification from
                        // openmetrics, which was bastardized in turn by that root of all monitoring evil: Prometheus.
                        // Lightstep is a business which must adhere to de-facto standards, so I don't fault them for
                        // this; though I would love it if they were to also adopt a good protocol.
                        explicitBounds.add(below.toDouble())
                        bucketCounts.add(0L)
                    }

                    explicitBounds.add(bucket.toDouble())
                    bucketCounts.add(count.sum())
                }

                bucketCounts.add(0) // because OTLP is _stupid_ and defined histogram format to have an implicit infinity bucket.
            }
        )
    }

    private val library = instrumentationScope {
        name = "goodmetrics_kotlin"
        version = OpentelemetryClient::class.java.`package`.implementationVersion ?: "development"
    }

    private val prescientResource by lazy {
        resource {
            attributes.addAll(prescientResourceDimensions.resourceDimensions.asOtlpDimensions().asIterable())
        }
    }

    private fun Map.asOtlpDimensions(): Sequence = sequence {
        for (dimension in this@asOtlpDimensions) {
            yield(dimension.value.asOtlpKeyValue())
        }
    }

    private fun Metrics.Dimension.asOtlpKeyValue(): KeyValue = keyValue {
        key = [email protected]
        when (val v = this@asOtlpKeyValue) {
            is Metrics.Dimension.Boolean -> {
                value = anyValue { boolValue = v.value }
            }
            is Metrics.Dimension.Number -> {
                value = anyValue { intValue = v.value }
            }
            is Metrics.Dimension.String -> {
                value = anyValue { stringValue = v.value }
            }
        }
    }

    override fun close() {
        channel.shutdown()
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy