Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
1c5e426
Add the report from Claude Chat and implementation plan from Claude U…
alexander-yevsyukov Jun 27, 2026
cb401fb
Add `otel-backend` Phase 0 implementation
alexander-yevsyukov Jun 27, 2026
9c7ce04
Complete OpenTelemetry backend: events, bootstrap, correlation
alexander-yevsyukov Jun 27, 2026
2b5386e
Apply pre-PR review fixes
alexander-yevsyukov Jun 27, 2026
ea132ee
uUpdate docs
alexander-yevsyukov Jun 27, 2026
dfe5885
Address PR review: resolve the OTel logger lazily; wire publishing
alexander-yevsyukov Jun 27, 2026
bf314b3
Reformat MD table
alexander-yevsyukov Jun 27, 2026
215ff0d
Add tests for OTel attribute value types and edge cases
alexander-yevsyukov Jun 28, 2026
e144c05
Use bare metadata attribute names; keep a `tag.` namespace
alexander-yevsyukov Jun 28, 2026
beb0846
Update dependency reports
alexander-yevsyukov Jun 28, 2026
da16ff7
Avoid `spine-` prefix in the root project name
alexander-yevsyukov Jun 28, 2026
864efc2
Harden OTLP bootstrap and centralize the logger-name convention
alexander-yevsyukov Jun 28, 2026
4c59160
Merge branch 'master' into otel
alexander-yevsyukov Jun 28, 2026
ebc3225
Accept a lazy message supplier in `logEvent`
alexander-yevsyukov Jun 28, 2026
65d9194
Address automated PR review (Copilot, Codex)
alexander-yevsyukov Jun 28, 2026
d05dca5
Honor the logs-specific OTLP endpoint in `fromEnvironment()`
alexander-yevsyukov Jun 28, 2026
43eed30
Bump version -> `2.0.0-SNAPSHOT.419`
alexander-yevsyukov Jun 28, 2026
07d993c
Update dependency reports
alexander-yevsyukov Jun 28, 2026
822b267
Fix nullability issue
alexander-yevsyukov Jun 28, 2026
3980b78
Cover bootstrap shutdown and endpoint paths (fix codecov/project)
alexander-yevsyukov Jun 28, 2026
f2f6cff
Mark `reportShutdown` as `@VisibleForTesting`
alexander-yevsyukov Jun 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
263 changes: 263 additions & 0 deletions .agents/tasks/otel-backend-implementation.md

Large diffs are not rendered by default.

642 changes: 642 additions & 0 deletions .agents/tasks/otel-backend-report.md

Large diffs are not rendered by default.

12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ and the introduction of fluent logging API in [SLF4J v2.0.0][fluent-slf4j].

## Current status: Experimental

Please note that this library is still in the experimental phase of development,
and hence, its API may undergo significant changes. As such, we advise using
this library cautiously in your projects until it has reached a stable
release stage.
This library is actively used across the SpineEventEngine family of projects, and its
JVM API is expected to be stable enough for use in JVM-based projects.

The status remains **Experimental** because we are evolving the library toward Kotlin
Multiplatform (KMP). That work may introduce API changes, so until it settles we advise
JVM users to pin a specific version and review the release notes before upgrading.

## Simple example

Expand Down Expand Up @@ -94,6 +96,8 @@ The following backends are available:

* `io.spine:spine-logging-jul-backend` – the default JUL-based backend.
* `io.spine:spine-logging-log4j2-backend` – Log4j2 backend.
* `io.spine:spine-logging-otel-backend` – OpenTelemetry backend.
See [the OpenTelemetry backend guide](docs/otel-backend.md).

The default backend is supplied along the logging library itself. To use it,
one just needs to not supply any other backend. Then JUL backend will be
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,14 @@ public abstract class AbstractJulBackend : LoggerBackend {
}

/**
* Constructs an abstract backend for the given class name.
* Constructs an abstract backend for the logger named [loggingClass].
*
* Nested or inner class names (containing '$') are converted to names matching the
* standard JDK logger namespace by converting '$' to '.'.
* The name is used as given: callers pass a logger name already derived from the
* logging class through the shared `BackendFactory.loggerName` convention
* (`$` replaced by `.`).
*/
protected constructor(loggingClass: String) : this(
Logger.getLogger(loggingClass.replace('$', '.'))
Logger.getLogger(loggingClass)
)

public override val loggerName: String?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,9 @@ import java.util.logging.Logger
*
* It allows forced publishing of logging records.
*
* @param loggingClass A name of the logger created for this backend.
* A better name for the parameter would be `loggerName`, but we keep the naming
* consistent with the API we extend. Please also see the constructor
* of `AbstractBackend` which accepts `String` for the operation with
* the given class name.
* @param loggingClass The name of the logger created for this backend, already derived
* from the logging class by [JulBackendFactory]. A better name for the parameter
* would be `loggerName`, but we keep it consistent with the API we extend.
* @see AbstractJulBackend
*/
internal class JulBackend(loggingClass: String): AbstractJulBackend(loggingClass) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ import io.spine.logging.backend.LoggerBackend
*/
public class JulBackendFactory: BackendFactory() {

public override fun create(loggingClass: String): LoggerBackend = JulBackend(loggingClass)
public override fun create(loggingClass: String): LoggerBackend =
JulBackend(loggerName(loggingClass))

/**
* Returns a fully-qualified name of this class.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,7 @@ import org.apache.logging.log4j.core.Logger
public class Log4j2BackendFactory : BackendFactory() {

override fun create(loggingClass: String): LoggerBackend {
// Compute the logger name the same way as in SimpleBackendFactory.
val name = loggingClass.replace('$', '.')
val name = loggerName(loggingClass)

// There is log4j.Logger interface and log4j.core.Logger implementation.
// Implementation exposes more methods that are needed by the backend.
Expand Down
48 changes: 48 additions & 0 deletions backends/otel-backend-bootstrap/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
* Copyright 2026, TeamDev. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Redistribution and use in source and/or binary forms, with or without
* modification, must retain the above copyright notice and the following
* disclaimer.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/

import io.spine.dependency.kotlinx.Coroutines
import io.spine.dependency.lib.OpenTelemetryKotlin

plugins {
`jvm-module`
}

dependencies {
// The backend whose `OpenTelemetry` instance this module installs.
implementation(project(":otel-backend"))

// The native Kotlin SDK entry point (`createOpenTelemetry { … }`), the export
// machinery (`batchLogRecordProcessor`), and the OTLP exporters. Only this
// bootstrap depends on them; the backend stays API-only.
implementation(OpenTelemetryKotlin.implementation)
implementation(OpenTelemetryKotlin.exportersCore)
implementation(OpenTelemetryKotlin.exportersOtlp)
implementation(OpenTelemetryKotlin.noop)

// `runBlocking`, to invoke the SDK's suspending `shutdown()` from `close()`.
implementation(Coroutines.core)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/*
* Copyright 2026, TeamDev. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Redistribution and use in source and/or binary forms, with or without
* modification, must retain the above copyright notice and the following
* disclaimer.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/

@file:OptIn(ExperimentalApi::class)

package io.spine.logging.backend.otel.bootstrap

import io.opentelemetry.kotlin.ExperimentalApi
import io.opentelemetry.kotlin.NoopOpenTelemetry
import io.opentelemetry.kotlin.createOpenTelemetry
import io.opentelemetry.kotlin.export.OperationResultCode
import io.opentelemetry.kotlin.export.TelemetryCloseable
import io.opentelemetry.kotlin.logging.export.batchLogRecordProcessor
import io.opentelemetry.kotlin.logging.export.otlpHttpLogRecordExporter
import io.spine.annotation.VisibleForTesting
import io.spine.logging.backend.otel.OtelBackendSettings
import java.util.logging.Logger
import kotlinx.coroutines.runBlocking

/**
* Turnkey wiring of the OpenTelemetry logging backend.
*
* The core backend stays API-only and expects an `OpenTelemetry` instance to be
* injected via [OtelBackendSettings]. This optional module builds a real native
* Kotlin SDK with an OTLP/HTTP export pipeline and installs it.
*
* The returned [AutoCloseable] uninstalls the backend (restoring the no-op
* instance) and shuts down the SDK it created when closed.
*/
public object OtelLogging {

/**
* The default OTLP/HTTP endpoint of a local OpenTelemetry Collector.
*
* Port `4318` is the OpenTelemetry-standard OTLP/HTTP port, so this default
* matches a collector started with stock settings. When your collector listens
* elsewhere, override it: pass an explicit endpoint to [installOtlpHttp], or set
* the `OTEL_EXPORTER_OTLP_ENDPOINT` environment variable and use [fromEnvironment].
*/
public const val DEFAULT_OTLP_HTTP_ENDPOINT: String = "http://localhost:4318"

/**
* The signal-specific environment variable that overrides
* [DEFAULT_OTLP_HTTP_ENDPOINT], taking precedence over [OTLP_ENDPOINT_ENV].
*/
private const val OTLP_LOGS_ENDPOINT_ENV: String = "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT"

/**
* The generic OTLP environment variable that overrides [DEFAULT_OTLP_HTTP_ENDPOINT]
* when [OTLP_LOGS_ENDPOINT_ENV] is not set.
*/
private const val OTLP_ENDPOINT_ENV: String = "OTEL_EXPORTER_OTLP_ENDPOINT"

/**
* Builds a native Kotlin OpenTelemetry SDK that exports log records over
* OTLP/HTTP to [endpoint] and installs it into [OtelBackendSettings].
*
* @param endpoint The OTLP/HTTP endpoint. Defaults to [DEFAULT_OTLP_HTTP_ENDPOINT].
* @return A handle that uninstalls the backend when closed.
*/
public fun installOtlpHttp(endpoint: String = DEFAULT_OTLP_HTTP_ENDPOINT): AutoCloseable {
val openTelemetry = createOpenTelemetry {
loggerProvider {
export {
batchLogRecordProcessor(
otlpHttpLogRecordExporter(endpoint)
)
}
}
}
OtelBackendSettings.use(openTelemetry)
return AutoCloseable {
// Stop routing new records to this SDK, then flush and shut it down.
OtelBackendSettings.use(NoopOpenTelemetry)
val result =
if (openTelemetry is TelemetryCloseable) runBlocking { openTelemetry.shutdown() }
else null
reportShutdown(openTelemetry, result)
}
}

/**
* Warns, via JUL, when the SDK could not be shut down cleanly: it is not a
* [TelemetryCloseable] (so it was never shut down), or its `shutdown()` reported a
* failure. Either way buffered records may be lost, so the outcome must not pass
* silently — JUL is used because the Spine OTel backend has just been pointed at the
* no-op instance, so logging through it here would itself be dropped.
*
* Exposed as `internal` (with an injectable [logger]) so both outcomes can be verified
* without provoking a real SDK failure.
*/
@VisibleForTesting
internal fun reportShutdown(
sdk: Any,
result: OperationResultCode?,
logger: Logger = Logger.getLogger(OtelLogging::class.java.name),
) {
val warning = when {
sdk !is TelemetryCloseable ->
"The OpenTelemetry SDK is not a TelemetryCloseable; it was not shut down, " +
"so buffered log records may not have been exported."
result == OperationResultCode.Failure ->
"The OpenTelemetry SDK reported a failure while shutting down; " +
"some buffered log records may not have been exported."
else -> return
}
logger.warning(warning)
}

/**
* Installs an OTLP/HTTP pipeline using the endpoint resolved from the environment.
*
* The signal-specific `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` takes precedence over the
* generic `OTEL_EXPORTER_OTLP_ENDPOINT`, falling back to [DEFAULT_OTLP_HTTP_ENDPOINT]
* when neither is set.
*/
public fun fromEnvironment(): AutoCloseable =
installOtlpHttp(endpointFromEnvironment())

/**
* Resolves the OTLP/HTTP base endpoint from the environment, preferring the
* signal-specific [OTLP_LOGS_ENDPOINT_ENV] over the generic [OTLP_ENDPOINT_ENV] and
* falling back to [DEFAULT_OTLP_HTTP_ENDPOINT] when neither is set.
*
* The value is treated as a base URL: the exporter appends the OTLP/HTTP logs path
* (`/v1/logs`) to it, just as it does for the default.
*
* Exposed as `internal` so the resolution can be verified without touching the real
* environment or the network.
*/
internal fun endpointFromEnvironment(): String =
endpointFromEnvironment { System.getenv(it) }

/**
* Resolves the endpoint as [endpointFromEnvironment], looking variables up through
* [getenv]. Separated so the precedence can be tested without mutating the process
* environment.
*/
internal fun endpointFromEnvironment(getenv: (String) -> String?): String =
getenv(OTLP_LOGS_ENDPOINT_ENV) ?: getenv(OTLP_ENDPOINT_ENV) ?: DEFAULT_OTLP_HTTP_ENDPOINT
}
Loading
Loading