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

net.nemerosa.ontrack.extension.elastic.metrics.DefaultElasticMetricsClient.kt Maven / Gradle / Ivy

package net.nemerosa.ontrack.extension.elastic.metrics

import io.micrometer.core.instrument.MeterRegistry
import io.micrometer.core.instrument.binder.MeterBinder
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import net.nemerosa.ontrack.json.asJson
import net.nemerosa.ontrack.json.toJsonMap
import net.nemerosa.ontrack.model.metrics.increment
import net.nemerosa.ontrack.model.structure.SearchNodeResults
import net.nemerosa.ontrack.model.structure.SearchResultNode
import net.nemerosa.ontrack.model.support.time
import org.apache.http.HttpHost
import org.apache.http.auth.AuthScope
import org.apache.http.auth.Credentials
import org.apache.http.auth.UsernamePasswordCredentials
import org.apache.http.impl.client.BasicCredentialsProvider
import org.elasticsearch.action.admin.indices.delete.DeleteIndexRequest
import org.elasticsearch.action.admin.indices.refresh.RefreshRequest
import org.elasticsearch.action.bulk.BulkRequest
import org.elasticsearch.action.index.IndexRequest
import org.elasticsearch.action.search.SearchRequest
import org.elasticsearch.client.RequestOptions
import org.elasticsearch.client.RestClient
import org.elasticsearch.client.RestHighLevelClient
import org.elasticsearch.client.RestHighLevelClientBuilder
import org.elasticsearch.index.query.MultiMatchQueryBuilder
import org.elasticsearch.search.builder.SearchSourceBuilder
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.stereotype.Component
import java.net.URI
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock

@DelicateCoroutinesApi
@Component
@ConditionalOnProperty(
    prefix = ElasticMetricsConfigProperties.ELASTIC_METRICS_PREFIX,
    name = ["enabled"],
    havingValue = "true",
    matchIfMissing = false,
)
class DefaultElasticMetricsClient(
    private val elasticMetricsConfigProperties: ElasticMetricsConfigProperties,
    private val defaultClient: RestHighLevelClient,
) : ElasticMetricsClient, MeterBinder {

    private val logger: Logger = LoggerFactory.getLogger(DefaultElasticMetricsClient::class.java)

    private var registry: MeterRegistry? = null

    private fun debug(message: String) {
        if (elasticMetricsConfigProperties.debug && logger.isDebugEnabled) {
            logger.debug(message)
        }
    }

    /**
     * Internal in-memory queue
     */
    private val queue = Channel(capacity = elasticMetricsConfigProperties.queue.capacity.toInt())

    /**
     * Channels do not have a measurable size, so keeping our own count.
     */
    private val queueSize = AtomicLong()

    /**
     * Buffer of entries
     */
    private val buffer = ConcurrentLinkedQueue()

    /**
     * Buffer lock
     */
    private val bufferLock = ReentrantLock()

    override fun bindTo(registry: MeterRegistry) {
        this.registry = registry
        // Size of the queue
        registry.gauge(ElasticMetricsClientMetrics.queue, this) {
            queueSize.get().toDouble()
        }
        // Size of the buffer
        registry.gauge(ElasticMetricsClientMetrics.buffer, this) {
            buffer.size.toDouble()
        }
    }

    override fun saveMetrics(entries: Collection) {
        try {
            if (elasticMetricsConfigProperties.index.immediate) {
                // Direct registration
                val indexName = elasticMetricsConfigProperties.index.name
                entries.forEach { entry ->
                    val source = entry.asJson().toJsonMap()
                    client.index(
                        IndexRequest(indexName).id(entry.computeId()).source(source),
                        RequestOptions.DEFAULT
                    )
                }
                // ... and flushing immediately
                val refreshRequest = RefreshRequest(indexName)
                client.indices().refresh(refreshRequest, RequestOptions.DEFAULT)
            } else {
                runBlocking {
                    launch {
                        entries.forEach { entry ->
                            queueSize.incrementAndGet()
                            queue.send(entry)
                        }
                    }
                }
            }
        } catch (ex: Throwable) {
            logger.error("Cannot queue ${entries.size} metrics for export to ElasticSearch", ex)
        }
    }

    override fun saveMetric(entry: ECSEntry) {
        saveMetrics(listOf(entry))
    }

    init {
        /**
         * Launching the queue processing at startup
         */
        GlobalScope.launch {
            receiveEvents()
        }
        /**
         * Launching the queue regular purging
         */
        GlobalScope.launch {
            flushEvents()
        }
    }

    /**
     * Processing of the events
     */
    private suspend fun receiveEvents() {
        while (true) {
            try {
                val entry = queue.receive()
                queueSize.decrementAndGet()
                debug("Entry received: $entry")
                runBlocking {
                    launch(Job()) {
                        processEvent(entry)
                    }
                }
            } catch (any: Exception) {
                logger.error("Error on dequeuing an ECS entry", any)
            }
        }
    }

    private fun processEvent(entry: ECSEntry) {
        buffer.add(entry)
        debug("Entry buffered (${buffer.size}/${elasticMetricsConfigProperties.queue.buffer}) $entry")
        if (buffer.size >= elasticMetricsConfigProperties.queue.buffer.toInt()) {
            // Flushing the buffer
            debug("Buffer flushing")
            flushing()
        }
    }

    /**
     * Flushing the events regularly
     */
    private suspend fun flushEvents() {
        runBlocking {
            while (true) {
                try {
                    // Wait between each flushing session
                    delay(elasticMetricsConfigProperties.queue.flushing.toMillis())
                    // Flushing
                    debug("Timed flushing")
                    flushing()
                } catch (any: Exception) {
                    logger.error("Error on timed flushing", any)
                }
            }
        }
    }

    private fun flushing() {
        debug("Trying to get lock on buffer")
        bufferLock.withLock { }
        if (bufferLock.tryLock(1, TimeUnit.MINUTES)) {
            debug("Locked on the buffer")
            try {
                if (buffer.isNotEmpty()) {
                    debug("Flushing all entries (${buffer.size})")
                    // Copy of the elements
                    val entries = buffer.toTypedArray()
                    // Flushing the buffer
                    buffer.clear()
                    // Creating the bulk request
                    val indexName = elasticMetricsConfigProperties.index.name
                    val request = BulkRequest()
                    entries.forEach {
                        val source = it.asJson().toJsonMap()
                        request.add(IndexRequest(indexName).id(it.computeId()).source(source))
                    }
                    // Bulk request execution
                    registry?.time(ElasticMetricsClientMetrics.time) {
                        try {
                            debug("Sending the bulk request (${request.numberOfActions()})")
                            val response = client.bulk(request, RequestOptions.DEFAULT)
                            if (response.hasFailures()) {
                                val errorMessage = response.buildFailureMessage()
                                logger.error("Errors while exporting metrics to ElasticSearch. $errorMessage")
                                registry?.increment(ElasticMetricsClientMetrics.errors)
                            }
                        } catch (any: Exception) {
                            logger.error("Cannot export ${entries.size} metrics to ElasticSearch", any)
                            registry?.increment(ElasticMetricsClientMetrics.errors)
                        }
                    }
                } else {
                    debug("Nothing to flush")
                }
            } finally {
                bufferLock.unlock()
                debug("Unlocking the buffer")
            }
        } else {
            debug("Could not acquire lock. Skipping.")
        }
    }

    override fun dropIndex() {
        if (elasticMetricsConfigProperties.allowDrop) {
            logger.info("Dropping the ${elasticMetricsConfigProperties.index.name} index before re-export")
            client.indices().delete(
                DeleteIndexRequest(elasticMetricsConfigProperties.index.name),
                RequestOptions.DEFAULT
            )
        }
    }

    override fun rawSearch(
        token: String,
        offset: Int,
        size: Int,
    ): SearchNodeResults {
        val esRequest = SearchRequest().source(
            SearchSourceBuilder().query(
                MultiMatchQueryBuilder(token).type(MultiMatchQueryBuilder.Type.BEST_FIELDS)
            ).from(
                offset
            ).size(
                size
            )
        ).indices(elasticMetricsConfigProperties.index.name)

        // Getting the result of the search
        val response = client.search(esRequest, RequestOptions.DEFAULT)

        // Pagination information
        val responseHits = response.hits
        val totalHits = responseHits.totalHits?.value ?: 0

        // Hits as JSON nodes
        val hits = responseHits.hits.map {
            SearchResultNode(
                it.index,
                it.id,
                it.score.toDouble(),
                it.sourceAsMap
            )
        }

        // Transforming into search results
        return SearchNodeResults(
            items = hits,
            offset = offset,
            total = totalHits.toInt(),
            message = when {
                totalHits <= 0 -> "The number of total matches is not known and pagination is not possible."
                else -> null
            }
        )
    }

    private val client: RestHighLevelClient by lazy {
        when (elasticMetricsConfigProperties.target) {
            // Using the main ES instance
            ElasticMetricsTarget.MAIN -> defaultClient
            // Using the custom ES instance
            ElasticMetricsTarget.CUSTOM -> customClient()
        }
    }

    private fun customClient(): RestHighLevelClient {
        val hosts = elasticMetricsConfigProperties.custom.uris.map { value ->
            val uri = URI(value)
            HttpHost.create(
                URI(
                    uri.scheme, null, uri.host, uri.port, uri.path, uri.query, uri.fragment
                ).toString()
            )
        }
        val builder = RestClient.builder(*hosts.toTypedArray())
        if (!elasticMetricsConfigProperties.custom.username.isNullOrBlank()) {
            builder.setHttpClientConfigCallback {
                it.setDefaultCredentialsProvider(
                    BasicCredentialsProvider().apply {
                        val credentials: Credentials = UsernamePasswordCredentials(
                            elasticMetricsConfigProperties.custom.username,
                            elasticMetricsConfigProperties.custom.password
                        )
                        setCredentials(AuthScope.ANY, credentials)
                    }
                )
            }
        }
        if (elasticMetricsConfigProperties.custom.pathPrefix != null) {
            builder.setPathPrefix(elasticMetricsConfigProperties.custom.pathPrefix)
        }
        val client = builder.build()
        return RestHighLevelClientBuilder(client)
            .setApiCompatibilityMode(elasticMetricsConfigProperties.apiCompatibilityMode)
            .build()
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy