diff --git a/build.sbt b/build.sbt index 31c6c178ab..017029696b 100644 --- a/build.sbt +++ b/build.sbt @@ -82,7 +82,7 @@ addCommandAlias( "testJVM", "typeidJVM/test; chunkJVM/test; combinatorsJVM/test; ringbufferJVM/test; schemaJVM/test; streamsJVM/test; schema-toonJVM/test; schema-messagepackJVM/test; schema-avro/test; " + "schema-thrift/test; schema-bson/test; schema-xmlJVM/test; schema-yamlJVM/test; schema-csvJVM/test; contextJVM/test; scopeJVM/test; mediatypeJVM/test; http-modelJVM/test; " + - "http-model-schemaJVM/test; openapiJVM/test; smithy/test; zioGolemModelJVM/test; zioGolemCoreJVM/test; zioGolemMacros/test; zioGolemTools/test" + "http-model-schemaJVM/test; openapiJVM/test; smithy/test; zioGolemModelJVM/test; zioGolemCoreJVM/test; zioGolemMacros/test; zioGolemTools/test; otelJVM/test" ) addCommandAlias( @@ -133,6 +133,8 @@ lazy val root = project `scope-examples`, schema.jvm, schema.js, + otel.jvm, + otel.js, `schema-avro`, `schema-messagepack`.jvm, `schema-messagepack`.js, @@ -375,6 +377,38 @@ lazy val schema = crossProject(JSPlatform, JVMPlatform) }) ) +lazy val otel = crossProject(JSPlatform, JVMPlatform) + .crossType(CrossType.Full) + .dependsOn(context, chunk) + .settings(stdSettings("zio-blocks-otel")) + .settings(crossProjectSettings) + .settings(buildInfoSettings("zio.blocks.otel")) + .enablePlugins(BuildInfoPlugin) + .settings( + libraryDependencies ++= Seq( + "dev.zio" %%% "zio-test" % "2.1.24" % Test, + "dev.zio" %%% "zio-test-sbt" % "2.1.24" % Test + ) ++ (CrossVersion.partialVersion(scalaVersion.value) match { + case Some((2, _)) => + Seq("org.scala-lang" % "scala-reflect" % scalaVersion.value) + case _ => + Seq() + }), + coverageMinimumStmtTotal := 80, + coverageMinimumBranchTotal := 70, + coverageExcludedFiles := Seq( + ".*PlatformExecutor.*", + ".*BuildInfo.*" + ).mkString(";"), + Compile / scalacOptions ++= { + if (scalaVersion.value.startsWith("2.")) + Seq("-Wconf:cat=unchecked:s") + else Nil + } + ) + .jvmSettings(mimaSettings(failOnProblem = false)) + .jsSettings(jsSettings) + lazy val streams = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) .settings(stdSettings("zio-blocks-streams")) diff --git a/otel/js/src/main/scala/zio/blocks/otel/ContextStorage.scala b/otel/js/src/main/scala/zio/blocks/otel/ContextStorage.scala new file mode 100644 index 0000000000..5f0e0a657d --- /dev/null +++ b/otel/js/src/main/scala/zio/blocks/otel/ContextStorage.scala @@ -0,0 +1,46 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +sealed trait ContextStorage[A] { + def get(): A + def set(value: A): Unit + def scoped[B](value: A)(f: => B): B +} + +object ContextStorage { + + val hasLoom: Boolean = false + val implementationName: String = "JSGlobal" + + def create[A](initial: A): ContextStorage[A] = new JsStorage[A](initial) + + private final class JsStorage[A](initial: A) extends ContextStorage[A] { + private var current: A = initial + + def get(): A = current + + def set(value: A): Unit = current = value + + def scoped[B](value: A)(f: => B): B = { + val prev = current + current = value + try f + finally { current = prev } + } + } +} diff --git a/otel/js/src/main/scala/zio/blocks/otel/LogAnnotations.scala b/otel/js/src/main/scala/zio/blocks/otel/LogAnnotations.scala new file mode 100644 index 0000000000..de2321cdf9 --- /dev/null +++ b/otel/js/src/main/scala/zio/blocks/otel/LogAnnotations.scala @@ -0,0 +1,30 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +private[otel] object LogAnnotations { + private var current: Map[String, String] = Map.empty + + def get(): Map[String, String] = current + + def scoped[A](annotations: Map[String, String])(f: => A): A = { + val prev = current + current = prev ++ annotations + try f + finally { current = prev } + } +} diff --git a/otel/jvm/src/main/scala/zio/blocks/otel/BatchProcessor.scala b/otel/jvm/src/main/scala/zio/blocks/otel/BatchProcessor.scala new file mode 100644 index 0000000000..f5c44db852 --- /dev/null +++ b/otel/jvm/src/main/scala/zio/blocks/otel/BatchProcessor.scala @@ -0,0 +1,120 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger + +sealed trait ExportResult + +object ExportResult { + case object Success extends ExportResult + final case class Failure(retryable: Boolean, message: String) extends ExportResult +} + +final class BatchProcessor[A]( + exportFn: Seq[A] => ExportResult, + executor: ScheduledExecutorService, + maxQueueSize: Int = 2048, + maxBatchSize: Int = 512, + flushIntervalMillis: Long = 5000, + maxRetries: Int = 5, + retryBaseMillis: Long = 1000L +) { + private val queue: ConcurrentLinkedQueue[A] = new ConcurrentLinkedQueue[A]() + private val queueSize: AtomicInteger = new AtomicInteger(0) + private val isShutdown: AtomicBoolean = new AtomicBoolean(false) + + private val flushTask: Runnable = new Runnable { + def run(): Unit = doFlush() + } + + private val scheduledFuture: ScheduledFuture[_] = + executor.scheduleAtFixedRate(flushTask, flushIntervalMillis, flushIntervalMillis, TimeUnit.MILLISECONDS) + + def enqueue(item: A): Unit = + if (!isShutdown.get()) { + queue.add(item) + val size = queueSize.incrementAndGet() + if (size > maxQueueSize) { + val removed = queue.poll() + if (removed != null) { + queueSize.decrementAndGet() + System.err.println( + "[zio-blocks-otel] BatchProcessor queue full (" + maxQueueSize + "). Dropping oldest item." + ) + } + } + } + + def forceFlush(): Unit = doFlush() + + def shutdown(): Unit = + if (isShutdown.compareAndSet(false, true)) { + scheduledFuture.cancel(false) + doFlush() + } + + private def doFlush(): Unit = { + var hasMore = true + while (hasMore) { + val batch = drain(maxBatchSize) + hasMore = batch.nonEmpty + if (hasMore) exportWithRetry(batch, 0) + } + } + + private def drain(max: Int): Seq[A] = { + val builder = Seq.newBuilder[A] + var count = 0 + while (count < max) { + val item = queue.poll() + if (item == null) { + count = max // exit loop + } else { + builder += item + queueSize.decrementAndGet() + count += 1 + } + } + builder.result() + } + + private def exportWithRetry(batch: Seq[A], attempt: Int): Unit = + exportFn(batch) match { + case ExportResult.Success => () + case ExportResult.Failure(retryable, message) => + if (!retryable) { + System.err.println( + "[zio-blocks-otel] BatchProcessor export failed (non-retryable): " + message + ". Dropping " + batch.size + " items." + ) + } else if (attempt >= maxRetries) { + System.err.println( + "[zio-blocks-otel] BatchProcessor export failed after " + (attempt + 1) + " attempts: " + message + ". Dropping " + batch.size + " items." + ) + } else { + val delayMs = math.min(retryBaseMillis * (1L << attempt), 30000L) + try Thread.sleep(delayMs) + catch { case _: InterruptedException => Thread.currentThread().interrupt() } + exportWithRetry(batch, attempt + 1) + } + } +} diff --git a/otel/jvm/src/main/scala/zio/blocks/otel/ContextStorage.scala b/otel/jvm/src/main/scala/zio/blocks/otel/ContextStorage.scala new file mode 100644 index 0000000000..8dc5bf0475 --- /dev/null +++ b/otel/jvm/src/main/scala/zio/blocks/otel/ContextStorage.scala @@ -0,0 +1,152 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import java.lang.invoke.MethodHandle +import java.lang.invoke.MethodHandles + +sealed trait ContextStorage[A] { + def get(): A + def set(value: A): Unit + def scoped[B](value: A)(f: => B): B +} + +object ContextStorage { + + val hasLoom: Boolean = + try { + Class.forName("java.lang.ScopedValue") + true + } catch { + case _: ClassNotFoundException => false + } + + val implementationName: String = + if (hasLoom) "ScopedValue" else "ThreadLocal" + + def create[A](initial: A): ContextStorage[A] = + if (hasLoom) new ScopedValueStorage[A](initial) + else new ThreadLocalStorage[A](initial) + + private final class ThreadLocalStorage[A](initial: A) extends ContextStorage[A] { + private val threadLocal: ThreadLocal[A] = new ThreadLocal[A] { + override def initialValue(): A = initial + } + + def get(): A = threadLocal.get() + + def set(value: A): Unit = threadLocal.set(value) + + def scoped[B](value: A)(f: => B): B = { + val prev = threadLocal.get() + threadLocal.set(value) + try f + finally threadLocal.set(prev) + } + } + + /** + * ScopedValue-based storage using reflection for JDK 21+ compatibility. + * + * All access to java.lang.ScopedValue is via MethodHandle to avoid + * compile-time JDK 21 requirement. + * + * ScopedValue does not support set() — context changes only via scoped(). To + * provide get() outside a scoped block, we also maintain a ThreadLocal + * fallback for set() calls and as default when no ScopedValue binding exists. + */ + private final class ScopedValueStorage[A](initial: A) extends ContextStorage[A] { + // Fallback ThreadLocal for set() support and default get() + private val fallback: ThreadLocal[A] = new ThreadLocal[A] { + override def initialValue(): A = initial + } + + // ScopedValue instance (java.lang.ScopedValue) + private val scopedValue: AnyRef = ScopedValueStorage.newInstance() + + // MethodHandles for ScopedValue operations + private val getHandle: MethodHandle = ScopedValueStorage.getHandle + private val isBoundHandle: MethodHandle = ScopedValueStorage.isBoundHandle + private val whereHandle: MethodHandle = ScopedValueStorage.whereHandle + private val carrierRunHandle: MethodHandle = ScopedValueStorage.carrierRunHandle + + def get(): A = { + val bound = isBoundHandle.invoke(scopedValue).asInstanceOf[Boolean] + if (bound) getHandle.invoke(scopedValue).asInstanceOf[A] + else fallback.get() + } + + def set(value: A): Unit = + fallback.set(value) + + def scoped[B](value: A)(f: => B): B = { + // ScopedValue.where(scopedValue, value).run(() => { result = f }) + val carrier = whereHandle.invoke(scopedValue, value.asInstanceOf[AnyRef]) + var result: B = null.asInstanceOf[B] + var thrown: Throwable = null + carrierRunHandle.invoke( + carrier, + new Runnable { + def run(): Unit = + try result = f + catch { case t: Throwable => thrown = t } + } + ) + if (thrown != null) throw thrown + result + } + } + + private object ScopedValueStorage { + private val lookup: MethodHandles.Lookup = MethodHandles.lookup() + private val svClass: Class[_] = Class.forName("java.lang.ScopedValue") + + // ScopedValue.newInstance() — static factory + private val newInstanceHandle: MethodHandle = { + val m = svClass.getMethod("newInstance") + lookup.unreflect(m) + } + + // ScopedValue.get() — instance method + val getHandle: MethodHandle = { + val m = svClass.getMethod("get") + lookup.unreflect(m) + } + + // ScopedValue.isBound() — instance method + val isBoundHandle: MethodHandle = { + val m = svClass.getMethod("isBound") + lookup.unreflect(m) + } + + // ScopedValue.where(ScopedValue, Object) — static method returning Carrier + val whereHandle: MethodHandle = { + val m = svClass.getMethod("where", svClass, classOf[Object]) + lookup.unreflect(m) + } + + // Carrier.run(Runnable) — stable across JDK 21-25 + val carrierRunHandle: MethodHandle = { + val carrierClass = Class.forName("java.lang.ScopedValue$Carrier") + val m = carrierClass.getMethod("run", classOf[Runnable]) + lookup.unreflect(m) + } + + def newInstance(): AnyRef = + newInstanceHandle.invoke().asInstanceOf[AnyRef] + } +} diff --git a/otel/jvm/src/main/scala/zio/blocks/otel/HttpSender.scala b/otel/jvm/src/main/scala/zio/blocks/otel/HttpSender.scala new file mode 100644 index 0000000000..0b4737596a --- /dev/null +++ b/otel/jvm/src/main/scala/zio/blocks/otel/HttpSender.scala @@ -0,0 +1,71 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import java.net.URI +import java.net.http.{HttpClient, HttpRequest, HttpResponse => JdkHttpResponse} +import java.time.Duration + +final case class HttpResponse( + statusCode: Int, + body: Array[Byte], + headers: Map[String, String] +) + +trait HttpSender { + def send(url: String, headers: Map[String, String], body: Array[Byte]): HttpResponse + def shutdown(): Unit +} + +final class JdkHttpSender( + timeout: Duration = Duration.ofSeconds(30) +) extends HttpSender { + private val client = HttpClient + .newBuilder() + .connectTimeout(timeout) + .build() + + def send(url: String, headers: Map[String, String], body: Array[Byte]): HttpResponse = { + val builder = HttpRequest + .newBuilder() + .uri(URI.create(url)) + .POST(HttpRequest.BodyPublishers.ofByteArray(body)) + .timeout(timeout) + + headers.foreach { case (k, v) => + builder.header(k, v) + } + + val request = builder.build() + val response = client.send(request, JdkHttpResponse.BodyHandlers.ofByteArray()) + + val responseHeaders = scala.collection.mutable.Map[String, String]() + response.headers().map().forEach { case (name, values) => + if (!values.isEmpty) { + responseHeaders(name) = values.get(0) + } + } + + HttpResponse( + statusCode = response.statusCode(), + body = response.body(), + headers = responseHeaders.toMap + ) + } + + def shutdown(): Unit = () +} diff --git a/otel/jvm/src/main/scala/zio/blocks/otel/LogAnnotations.scala b/otel/jvm/src/main/scala/zio/blocks/otel/LogAnnotations.scala new file mode 100644 index 0000000000..5316ec11d6 --- /dev/null +++ b/otel/jvm/src/main/scala/zio/blocks/otel/LogAnnotations.scala @@ -0,0 +1,39 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +/** + * Internal storage for scoped log annotations. Annotations are key-value pairs + * that automatically attach to all log records within a `log.annotated` block. + * + * Uses ThreadLocal for thread-safe, fiber-unaware scoping on JVM. Will be + * replaced with a platform-specific implementation when cross-compiling for JS. + */ +private[otel] object LogAnnotations { + private val storage: ThreadLocal[Map[String, String]] = new ThreadLocal[Map[String, String]] { + override def initialValue(): Map[String, String] = Map.empty + } + + def get(): Map[String, String] = storage.get() + + def scoped[A](annotations: Map[String, String])(f: => A): A = { + val prev = storage.get() + storage.set(prev ++ annotations) + try f + finally storage.set(prev) + } +} diff --git a/otel/jvm/src/main/scala/zio/blocks/otel/OtlpJsonExporter.scala b/otel/jvm/src/main/scala/zio/blocks/otel/OtlpJsonExporter.scala new file mode 100644 index 0000000000..8d60dd86b1 --- /dev/null +++ b/otel/jvm/src/main/scala/zio/blocks/otel/OtlpJsonExporter.scala @@ -0,0 +1,141 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import java.time.Duration + +final case class ExporterConfig( + endpoint: String = "http://localhost:4318", + headers: Map[String, String] = Map.empty, + timeout: Duration = Duration.ofSeconds(30), + maxQueueSize: Int = 2048, + maxBatchSize: Int = 512, + flushIntervalMillis: Long = 5000 +) + +object OtlpJsonExporter { + + private val retryableStatusCodes: Set[Int] = Set(429, 502, 503, 504) + + def mapResponse(response: HttpResponse): ExportResult = + if (response.statusCode >= 200 && response.statusCode < 300) ExportResult.Success + else if (retryableStatusCodes.contains(response.statusCode)) + ExportResult.Failure(retryable = true, message = "HTTP " + response.statusCode) + else ExportResult.Failure(retryable = false, message = "HTTP " + response.statusCode) + + private[otel] def mergeHeaders(config: ExporterConfig): Map[String, String] = + config.headers + ("Content-Type" -> "application/json") +} + +final class OtlpJsonTraceExporter( + config: ExporterConfig, + resource: Resource, + scope: InstrumentationScope, + httpSender: HttpSender, + platformExecutor: PlatformExecutor +) extends SpanProcessor { + + private val url = config.endpoint + "/v1/traces" + private val headers = OtlpJsonExporter.mergeHeaders(config) + + private val batchProcessor: BatchProcessor[SpanData] = new BatchProcessor[SpanData]( + exportFn = { batch => + val body = OtlpJsonEncoder.encodeTraces(batch, resource, scope) + val response = httpSender.send(url, headers, body) + OtlpJsonExporter.mapResponse(response) + }, + executor = platformExecutor.executor, + maxQueueSize = config.maxQueueSize, + maxBatchSize = config.maxBatchSize, + flushIntervalMillis = config.flushIntervalMillis + ) + + def onStart(span: Span): Unit = () + + def onEnd(spanData: SpanData): Unit = + batchProcessor.enqueue(spanData) + + def shutdown(): Unit = { + batchProcessor.shutdown() + httpSender.shutdown() + } + + def forceFlush(): Unit = + batchProcessor.forceFlush() +} + +final class OtlpJsonLogExporter( + config: ExporterConfig, + resource: Resource, + scope: InstrumentationScope, + httpSender: HttpSender, + platformExecutor: PlatformExecutor +) extends LogRecordProcessor { + + private val url = config.endpoint + "/v1/logs" + private val headers = OtlpJsonExporter.mergeHeaders(config) + + private val batchProcessor: BatchProcessor[LogRecord] = new BatchProcessor[LogRecord]( + exportFn = { batch => + val body = OtlpJsonEncoder.encodeLogs(batch, resource, scope) + val response = httpSender.send(url, headers, body) + OtlpJsonExporter.mapResponse(response) + }, + executor = platformExecutor.executor, + maxQueueSize = config.maxQueueSize, + maxBatchSize = config.maxBatchSize, + flushIntervalMillis = config.flushIntervalMillis + ) + + def onEmit(logRecord: LogRecord): Unit = + batchProcessor.enqueue(logRecord) + + def shutdown(): Unit = { + batchProcessor.shutdown() + httpSender.shutdown() + } + + def forceFlush(): Unit = + batchProcessor.forceFlush() +} + +final class OtlpJsonMetricExporter( + config: ExporterConfig, + resource: Resource, + scope: InstrumentationScope, + httpSender: HttpSender, + collectFn: () => Seq[NamedMetric] +) { + + private val url = config.endpoint + "/v1/metrics" + private val headers = OtlpJsonExporter.mergeHeaders(config) + + def exportMetrics(): ExportResult = { + val metrics = collectFn() + if (metrics.isEmpty) ExportResult.Success + else { + val body = OtlpJsonEncoder.encodeMetrics(metrics, resource, scope) + val response = httpSender.send(url, headers, body) + OtlpJsonExporter.mapResponse(response) + } + } + + def shutdown(): Unit = + httpSender.shutdown() + + def forceFlush(): Unit = () +} diff --git a/otel/jvm/src/main/scala/zio/blocks/otel/PlatformExecutor.scala b/otel/jvm/src/main/scala/zio/blocks/otel/PlatformExecutor.scala new file mode 100644 index 0000000000..8fb1bb21a5 --- /dev/null +++ b/otel/jvm/src/main/scala/zio/blocks/otel/PlatformExecutor.scala @@ -0,0 +1,83 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import java.lang.invoke.MethodHandles +import java.util.concurrent._ +import java.util.concurrent.atomic.AtomicLong + +final class PlatformExecutor private (val executor: ScheduledExecutorService) { + + def schedule(initialDelay: Long, period: Long, unit: TimeUnit)(task: Runnable): ScheduledFuture[_] = + executor.scheduleAtFixedRate(task, initialDelay, period, unit) + + def shutdown(): Unit = + executor.shutdown() +} + +object PlatformExecutor { + + val hasLoom: Boolean = ContextStorage.hasLoom + + def create(): PlatformExecutor = + new PlatformExecutor(createExecutor()) + + private def createExecutor(): ScheduledExecutorService = + if (hasLoom) createVirtualThreadExecutor() + else createDaemonThreadExecutor() + + private def createVirtualThreadExecutor(): ScheduledExecutorService = + try { + val lookup = MethodHandles.lookup() + val threadClass = classOf[Thread] + + // Thread.ofVirtual() — static method returning Thread.Builder.OfVirtual + val ofVirtualMethod = threadClass.getMethod("ofVirtual") + val ofVirtualHandle = lookup.unreflect(ofVirtualMethod) + val builder = ofVirtualHandle.invoke() + + // builder.name("otel-", 0) — returns Thread.Builder.OfVirtual + // The name(String, long) method is on Thread.Builder.OfVirtual interface + val ofVirtualClass = Class.forName("java.lang.Thread$Builder$OfVirtual") + val nameMethod = ofVirtualClass.getMethod("name", classOf[String], java.lang.Long.TYPE) + val nameHandle = lookup.unreflect(nameMethod) + val namedBuilder = nameHandle.invoke(builder, "otel-", java.lang.Long.valueOf(0L)) + + // builder.factory() — returns ThreadFactory (on Thread.Builder interface) + val builderClass = Class.forName("java.lang.Thread$Builder") + val factoryMethod = builderClass.getMethod("factory") + val factoryHandle = lookup.unreflect(factoryMethod) + val factory = factoryHandle.invoke(namedBuilder).asInstanceOf[ThreadFactory] + + Executors.newScheduledThreadPool(1, factory) + } catch { + case _: Exception => + createDaemonThreadExecutor() + } + + private def createDaemonThreadExecutor(): ScheduledExecutorService = { + val counter = new AtomicLong(0L) + val factory: ThreadFactory = new ThreadFactory { + def newThread(r: Runnable): Thread = { + val t = new Thread(r, "otel-daemon-" + counter.getAndIncrement()) + t.setDaemon(true) + t + } + } + Executors.newSingleThreadScheduledExecutor(factory) + } +} diff --git a/otel/jvm/src/test/scala/zio/blocks/otel/BatchProcessorSpec.scala b/otel/jvm/src/test/scala/zio/blocks/otel/BatchProcessorSpec.scala new file mode 100644 index 0000000000..b8e3e1ced1 --- /dev/null +++ b/otel/jvm/src/test/scala/zio/blocks/otel/BatchProcessorSpec.scala @@ -0,0 +1,200 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import zio.test._ + +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference + +object BatchProcessorSpec extends ZIOSpecDefault { + + private def collectingExporter[A](): (Seq[A] => ExportResult, AtomicReference[List[Seq[A]]]) = { + val captured = new AtomicReference[List[Seq[A]]](Nil) + val fn: Seq[A] => ExportResult = { batch => + var done = false + while (!done) { + val current = captured.get() + done = captured.compareAndSet(current, current :+ batch) + } + ExportResult.Success + } + (fn, captured) + } + + def spec: Spec[Any, Nothing] = suite("BatchProcessor")( + test("enqueue items and forceFlush delivers them to exportFn") { + val pe = PlatformExecutor.create() + val (exportFn, captured) = collectingExporter[String]() + val processor = + new BatchProcessor[String](exportFn, executor = pe.executor, flushIntervalMillis = 600000L) + try { + processor.enqueue("a") + processor.enqueue("b") + processor.enqueue("c") + processor.forceFlush() + val batches = captured.get() + assertTrue(batches.flatten.toSet == Set("a", "b", "c")) + } finally { + processor.shutdown() + pe.shutdown() + } + }, + test("exports in batches of maxBatchSize when more items than maxBatchSize") { + val pe = PlatformExecutor.create() + val (exportFn, captured) = collectingExporter[Int]() + val processor = + new BatchProcessor[Int](exportFn, executor = pe.executor, maxBatchSize = 3, flushIntervalMillis = 600000L) + try { + (1 to 7).foreach(processor.enqueue) + processor.forceFlush() + val batches = captured.get() + assertTrue( + batches.length >= 3 && + batches.forall(_.size <= 3) && + batches.flatten.toSet == (1 to 7).toSet + ) + } finally { + processor.shutdown() + pe.shutdown() + } + }, + test("drops oldest items when queue exceeds maxQueueSize") { + val pe = PlatformExecutor.create() + val (exportFn, captured) = collectingExporter[Int]() + val processor = + new BatchProcessor[Int]( + exportFn, + executor = pe.executor, + maxQueueSize = 5, + maxBatchSize = 100, + flushIntervalMillis = 600000L + ) + try { + (1 to 10).foreach(processor.enqueue) + processor.forceFlush() + val items = captured.get().flatten + assertTrue(items.size <= 5) + } finally { + processor.shutdown() + pe.shutdown() + } + }, + test("retries on retryable failure up to maxRetries") { + val pe = PlatformExecutor.create() + val attempts = new AtomicInteger(0) + val exportFn: Seq[String] => ExportResult = { _ => + val n = attempts.incrementAndGet() + if (n < 3) ExportResult.Failure(retryable = true, message = s"fail $n") + else ExportResult.Success + } + val processor = + new BatchProcessor[String]( + exportFn, + executor = pe.executor, + maxRetries = 5, + flushIntervalMillis = 600000L, + retryBaseMillis = 1L + ) + try { + processor.enqueue("x") + processor.forceFlush() + assertTrue(attempts.get() == 3) + } finally { + processor.shutdown() + pe.shutdown() + } + }, + test("drops batch on non-retryable failure without retry") { + val pe = PlatformExecutor.create() + val attempts = new AtomicInteger(0) + val exportFn: Seq[String] => ExportResult = { _ => + attempts.incrementAndGet() + ExportResult.Failure(retryable = false, message = "fatal") + } + val processor = + new BatchProcessor[String](exportFn, executor = pe.executor, flushIntervalMillis = 600000L) + try { + processor.enqueue("x") + processor.forceFlush() + assertTrue(attempts.get() == 1) + } finally { + processor.shutdown() + pe.shutdown() + } + }, + test("drops batch after max retries exceeded") { + val pe = PlatformExecutor.create() + val attempts = new AtomicInteger(0) + val exportFn: Seq[String] => ExportResult = { _ => + attempts.incrementAndGet() + ExportResult.Failure(retryable = true, message = "always fails") + } + val processor = + new BatchProcessor[String]( + exportFn, + executor = pe.executor, + maxRetries = 3, + flushIntervalMillis = 600000L, + retryBaseMillis = 1L + ) + try { + processor.enqueue("x") + processor.forceFlush() + assertTrue(attempts.get() == 4) // 1 initial + 3 retries + } finally { + processor.shutdown() + pe.shutdown() + } + }, + test("shutdown flushes remaining items") { + val pe = PlatformExecutor.create() + val (exportFn, captured) = collectingExporter[String]() + val processor = + new BatchProcessor[String](exportFn, executor = pe.executor, flushIntervalMillis = 600000L) + processor.enqueue("a") + processor.enqueue("b") + processor.shutdown() + pe.shutdown() + val items = captured.get().flatten + assertTrue(items.toSet == Set("a", "b")) + }, + test("background flush triggers periodically") { + val pe = PlatformExecutor.create() + val latch = new CountDownLatch(1) + val (exportFn, captured) = collectingExporter[String]() + val wrappedExportFn: Seq[String] => ExportResult = { batch => + val result = exportFn(batch) + latch.countDown() + result + } + val processor = + new BatchProcessor[String](wrappedExportFn, executor = pe.executor, flushIntervalMillis = 50L) + try { + processor.enqueue("auto") + val flushed = latch.await(2, TimeUnit.SECONDS) + val items = captured.get().flatten + assertTrue(flushed && items.contains("auto")) + } finally { + processor.shutdown() + pe.shutdown() + } + } + ) @@ TestAspect.sequential +} diff --git a/otel/jvm/src/test/scala/zio/blocks/otel/BranchCoverageSpec.scala b/otel/jvm/src/test/scala/zio/blocks/otel/BranchCoverageSpec.scala new file mode 100644 index 0000000000..1826863a99 --- /dev/null +++ b/otel/jvm/src/test/scala/zio/blocks/otel/BranchCoverageSpec.scala @@ -0,0 +1,398 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import zio.test._ + +import scala.collection.mutable.ArrayBuffer + +object BranchCoverageSpec extends ZIOSpecDefault { + + private class TestProcessor extends SpanProcessor { + val started: ArrayBuffer[Span] = ArrayBuffer.empty + val ended: ArrayBuffer[SpanData] = ArrayBuffer.empty + def onStart(span: Span): Unit = started += span + def onEnd(spanData: SpanData): Unit = ended += spanData + def shutdown(): Unit = () + def forceFlush(): Unit = () + } + + private class TestLogProcessor extends LogRecordProcessor { + val emitted: ArrayBuffer[LogRecord] = ArrayBuffer.empty + def onEmit(logRecord: LogRecord): Unit = emitted += logRecord + def shutdown(): Unit = () + def forceFlush(): Unit = () + } + + private val regionKey = AttributeKey.string("region") + private val usEast = Attributes.of(regionKey, "us-east-1") + private val traceIdHex = "4bf92f3577b34da6a3ce929d0e0e4736" + private val spanIdHex = "00f067aa0ba902b7" + private val hGetter: (Map[String, String], String) => Option[String] = (c, k) => c.get(k) + + def spec = suite("BranchCoverage")( + severitySuite, + spanSuite, + tracerSuite, + meterSuite, + loggerSuite, + attributesSuite, + b3Suite, + w3cSuite, + ctxSuite, + batchSuite + ) + + private val severitySuite = suite("Severity.fromNumber all arms")( + test("fromNumber covers all 24 levels and invalid") { + val expected = Seq( + (1, Some(Severity.Trace)), + (2, Some(Severity.Trace2)), + (3, Some(Severity.Trace3)), + (4, Some(Severity.Trace4)), + (5, Some(Severity.Debug)), + (6, Some(Severity.Debug2)), + (7, Some(Severity.Debug3)), + (8, Some(Severity.Debug4)), + (9, Some(Severity.Info)), + (10, Some(Severity.Info2)), + (11, Some(Severity.Info3)), + (12, Some(Severity.Info4)), + (13, Some(Severity.Warn)), + (14, Some(Severity.Warn2)), + (15, Some(Severity.Warn3)), + (16, Some(Severity.Warn4)), + (17, Some(Severity.Error)), + (18, Some(Severity.Error2)), + (19, Some(Severity.Error3)), + (20, Some(Severity.Error4)), + (21, Some(Severity.Fatal)), + (22, Some(Severity.Fatal2)), + (23, Some(Severity.Fatal3)), + (24, Some(Severity.Fatal4)), + (0, None), + (25, None), + (-1, None) + ) + assertTrue(expected.forall { case (n, e) => Severity.fromNumber(n) == e }) + }, + test("all severity sub-level number and text") { + val all = Seq( + Severity.Trace2, + Severity.Trace3, + Severity.Trace4, + Severity.Debug2, + Severity.Debug3, + Severity.Debug4, + Severity.Info2, + Severity.Info3, + Severity.Info4, + Severity.Warn2, + Severity.Warn3, + Severity.Warn4, + Severity.Error2, + Severity.Error3, + Severity.Error4, + Severity.Fatal2, + Severity.Fatal3, + Severity.Fatal4 + ) + assertTrue(all.forall(s => s.number >= 1 && s.number <= 24 && s.text.nonEmpty)) + } + ) + + private val spanSuite = suite("Span branch coverage")( + test("setAttribute with typed key after end") { + val span = SpanBuilder("s").startSpan(); span.end(); span.setAttribute(AttributeKey.string("k"), "v") + assertTrue(span.toSpanData.attributes.isEmpty) + }, + test("setAttribute(String, Long) after end") { + val span = SpanBuilder("s").startSpan(); span.end(); span.setAttribute("k", 42L) + assertTrue(span.toSpanData.attributes.isEmpty) + }, + test("setAttribute(String, Double) after end") { + val span = SpanBuilder("s").startSpan(); span.end(); span.setAttribute("k", 3.14) + assertTrue(span.toSpanData.attributes.isEmpty) + }, + test("setAttribute(String, Boolean) after end") { + val span = SpanBuilder("s").startSpan(); span.end(); span.setAttribute("k", true) + assertTrue(span.toSpanData.attributes.isEmpty) + }, + test("addEvent(String) after end") { + val span = SpanBuilder("s").startSpan(); span.end(); span.addEvent("ev") + assertTrue(span.toSpanData.events.isEmpty) + }, + test("addEvent(String, Attributes) after end") { + val span = SpanBuilder("s").startSpan(); span.end(); span.addEvent("ev", Attributes.empty) + assertTrue(span.toSpanData.events.isEmpty) + }, + test("addEvent(String, Long, Attributes) after end") { + val span = SpanBuilder("s").startSpan(); span.end(); span.addEvent("ev", 123L, Attributes.empty) + assertTrue(span.toSpanData.events.isEmpty) + }, + test("setStatus after end") { + val span = SpanBuilder("s").startSpan(); span.end(); span.setStatus(SpanStatus.Error("oops")) + assertTrue(span.toSpanData.status == SpanStatus.Unset) + }, + test("end(Long) after already ended") { + val span = SpanBuilder("s").startSpan(); span.end(100L); span.end(200L) + assertTrue(span.toSpanData.endTimeNanos == 100L) + }, + test("toAttributeValue handles all seq types") { + val span = SpanBuilder("seq-span").startSpan() + span.setAttribute(AttributeKey.stringSeq("ss"), Seq("a", "b")) + span.setAttribute(AttributeKey.longSeq("ls"), Seq(1L, 2L)) + span.setAttribute(AttributeKey.doubleSeq("ds"), Seq(1.0, 2.0)) + span.setAttribute(AttributeKey.booleanSeq("bs"), Seq(true, false)) + span.end() + val map = span.toSpanData.attributes.toMap + assertTrue(map.contains("ss") && map.contains("ls") && map.contains("ds") && map.contains("bs")) + } + ) + + private def mkSampler(decision: SamplingDecision, attrs: Attributes, ts: String = ""): Sampler = new Sampler { + def shouldSample( + pc: Option[SpanContext], + tid: TraceId, + n: String, + k: SpanKind, + a: Attributes, + l: Seq[SpanLink] + ): SamplingResult = + SamplingResult(decision, attrs, ts) + def description: String = "TestSampler" + } + + private val tracerSuite = suite("Tracer branch coverage")( + test("RecordOnly with all attribute types") { + val attrs = Attributes.builder + .put(AttributeKey.string("s"), "v") + .put(AttributeKey.long("l"), 1L) + .put(AttributeKey.double("d"), 2.0) + .put(AttributeKey.boolean("b"), true) + .build + val processor = new TestProcessor + val tracer = TracerProvider.builder + .setSampler(mkSampler(SamplingDecision.RecordOnly, attrs)) + .addSpanProcessor(processor) + .build() + .get("t") + val spanAttrs = Attributes.builder + .put(AttributeKey.string("as"), "av") + .put(AttributeKey.long("al"), 10L) + .put(AttributeKey.double("ad"), 20.0) + .put(AttributeKey.boolean("ab"), false) + .build + tracer.span("ro", SpanKind.Internal, spanAttrs) { span => + val recording = span.isRecording; assertTrue(recording) + } + }, + test("RecordAndSample with all attribute types") { + val attrs = Attributes.builder + .put(AttributeKey.string("s"), "v") + .put(AttributeKey.long("l"), 1L) + .put(AttributeKey.double("d"), 2.0) + .put(AttributeKey.boolean("b"), true) + .build + val processor = new TestProcessor + val tracer = TracerProvider.builder + .setSampler(mkSampler(SamplingDecision.RecordAndSample, attrs)) + .addSpanProcessor(processor) + .build() + .get("t") + val spanAttrs = Attributes.builder + .put(AttributeKey.string("as"), "av") + .put(AttributeKey.long("al"), 10L) + .put(AttributeKey.double("ad"), 20.0) + .put(AttributeKey.boolean("ab"), false) + .build + tracer.span("ras", SpanKind.Internal, spanAttrs) { span => + val recording = span.isRecording; assertTrue(recording) + } + }, + test("RecordOnly with seq attribute hits catch-all") { + val processor = new TestProcessor + val tracer = TracerProvider.builder + .setSampler( + mkSampler(SamplingDecision.RecordOnly, Attributes.builder.put(AttributeKey.stringSeq("seq"), Seq("a")).build) + ) + .addSpanProcessor(processor) + .build() + .get("t") + tracer.span( + "ro-seq", + SpanKind.Internal, + Attributes.builder.put(AttributeKey.longSeq("lseq"), Seq(1L, 2L)).build + ) { span => + val recording = span.isRecording; assertTrue(recording) + } + }, + test("RecordAndSample with seq attribute hits catch-all") { + val processor = new TestProcessor + val tracer = TracerProvider.builder + .setSampler( + mkSampler( + SamplingDecision.RecordAndSample, + Attributes.builder.put(AttributeKey.doubleSeq("seq"), Seq(1.0)).build + ) + ) + .addSpanProcessor(processor) + .build() + .get("t") + tracer.span( + "ras-seq", + SpanKind.Internal, + Attributes.builder.put(AttributeKey.booleanSeq("bseq"), Seq(true)).build + ) { span => + val recording = span.isRecording; assertTrue(recording) + } + } + ) + + private val meterSuite = suite("Meter collectInstruments")( + test("collectAllMetrics covers all 7 instrument types") { + val provider = MeterProvider.builder.build() + val meter = provider.get("cov-lib") + meter.counterBuilder("c").setDescription("d").setUnit("1").build().add(1L, usEast) + meter.upDownCounterBuilder("ud").setDescription("d").setUnit("1").build().add(10L, usEast) + meter.histogramBuilder("h").setDescription("d").setUnit("ms").build().record(5.0, usEast) + meter.gaugeBuilder("g").setDescription("d").setUnit("%").build().record(50.0, usEast) + meter.counterBuilder("oc").buildWithCallback(cb => cb.record(100.0, usEast)) + meter.upDownCounterBuilder("oud").buildWithCallback(cb => cb.record(-5.0, usEast)) + meter.gaugeBuilder("og").buildWithCallback(cb => cb.record(42.0, usEast)) + assertTrue(provider.reader.collectAllMetrics().size == 7) + } + ) + + private val loggerSuite = suite("Logger attribute types")( + test("log with Boolean attribute") { + val p = new TestLogProcessor + LoggerProvider.builder + .addLogRecordProcessor(p) + .build() + .get("t") + .info("m", "b" -> AttributeValue.BooleanValue(true)) + assertTrue(p.emitted.head.attributes.toMap("b") == AttributeValue.BooleanValue(true)) + }, + test("log with Double attribute") { + val p = new TestLogProcessor + LoggerProvider.builder + .addLogRecordProcessor(p) + .build() + .get("t") + .info("m", "d" -> AttributeValue.DoubleValue(3.14)) + assertTrue(p.emitted.head.attributes.toMap("d") == AttributeValue.DoubleValue(3.14)) + }, + test("log with seq attribute hits catch-all") { + val p = new TestLogProcessor + LoggerProvider.builder + .addLogRecordProcessor(p) + .build() + .get("t") + .info("m", "s" -> AttributeValue.StringSeqValue(Seq("a"))) + assertTrue(p.emitted.head.attributes.toMap.contains("s")) + } + ) + + private val attributesSuite = suite("Attributes branch coverage")( + test("++ merges with conflict") { + val merged = Attributes.builder + .put("k1", "v1") + .put("k2", "v2") + .build ++ Attributes.builder.put("k2", "v2b").put("k3", "v3").build + assertTrue(merged.size == 3 && merged.get(AttributeKey.string("k2")).contains("v2b")) + }, + test("get StringSeq via valueToType") { + val k = AttributeKey.stringSeq("ss"); assertTrue(Attributes.of(k, Seq("a", "b")).get(k).contains(Seq("a", "b"))) + }, + test("get LongSeq via valueToType") { + val k = AttributeKey.longSeq("ls"); assertTrue(Attributes.of(k, Seq(1L, 2L)).get(k).contains(Seq(1L, 2L))) + }, + test("get DoubleSeq via valueToType") { + val k = AttributeKey.doubleSeq("ds"); assertTrue(Attributes.of(k, Seq(1.0, 2.0)).get(k).contains(Seq(1.0, 2.0))) + }, + test("get BooleanSeq via valueToType") { + val k = AttributeKey.booleanSeq("bs"); + assertTrue(Attributes.of(k, Seq(true, false)).get(k).contains(Seq(true, false))) + }, + test("builder put same key updates") { + assertTrue(Attributes.builder.put("k", "v1").put("k", "v2").build.get(AttributeKey.string("k")).contains("v2")) + }, + test("of with Long") { + assertTrue(Attributes.of(AttributeKey.long("l"), 42L).get(AttributeKey.long("l")).contains(42L)) + }, + test("of with Double") { + assertTrue(Attributes.of(AttributeKey.double("d"), 3.14).get(AttributeKey.double("d")).contains(3.14)) + }, + test("of with Boolean") { + assertTrue(Attributes.of(AttributeKey.boolean("b"), true).get(AttributeKey.boolean("b")).contains(true)) + } + ) + + private val b3Suite = suite("B3Propagator branch coverage")( + test("single header unknown sampling flag") { + val r = B3Propagator.single.extract(Map("b3" -> s"$traceIdHex-$spanIdHex-x"), hGetter) + assertTrue(r.isDefined && !r.get.traceFlags.isSampled) + }, + test("single header only one part returns None") { + assertTrue(B3Propagator.single.extract(Map("b3" -> traceIdHex), hGetter).isEmpty) + } + ) + + private val w3cSuite = suite("W3CTraceContext branch coverage")( + test("traceparent wrong delimiters returns None") { + val bad = ("00X" + traceIdHex + "X" + spanIdHex + "X01").padTo(55, '0').take(55) + assertTrue(W3CTraceContextPropagator.extract(Map("traceparent" -> bad), hGetter).isEmpty) + } + ) + + private val ctxSuite = suite("ContextStorage branch coverage")( + test("hasLoom matches implementationName") { + assertTrue( + (ContextStorage.hasLoom && ContextStorage.implementationName == "ScopedValue") || + (!ContextStorage.hasLoom && ContextStorage.implementationName == "ThreadLocal") + ) + }, + test("scoped restores on normal exit") { + val s = ContextStorage.create("initial") + assertTrue(s.scoped("scoped")(s.get()) == "scoped" && s.get() == "initial") + }, + test("scoped restores on exception") { + val s = ContextStorage.create("initial") + try s.scoped("scoped")(throw new RuntimeException("boom")) + catch { case _: RuntimeException => () } + assertTrue(s.get() == "initial") + } + ) + + private val batchSuite = suite("BatchProcessor branch coverage")( + test("enqueue below capacity does not drop") { + val pe = PlatformExecutor.create() + var exported = List.empty[Seq[String]] + val p = new BatchProcessor[String]( + batch => { exported = batch :: exported; ExportResult.Success }, + executor = pe.executor, + maxQueueSize = 10, + maxBatchSize = 5, + maxRetries = 1, + flushIntervalMillis = 60000L + ) + p.enqueue("a"); p.enqueue("b"); p.forceFlush(); p.shutdown(); pe.shutdown() + assertTrue(exported.flatten.toSet == Set("a", "b")) + } + ) +} diff --git a/otel/jvm/src/test/scala/zio/blocks/otel/ContextStorageSpec.scala b/otel/jvm/src/test/scala/zio/blocks/otel/ContextStorageSpec.scala new file mode 100644 index 0000000000..72b1b9c000 --- /dev/null +++ b/otel/jvm/src/test/scala/zio/blocks/otel/ContextStorageSpec.scala @@ -0,0 +1,138 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import zio.test._ + +object ContextStorageSpec extends ZIOSpecDefault { + + def spec: Spec[Any, Nothing] = suite("ContextStorage")( + suite("factory")( + test("create returns a ContextStorage") { + val storage = ContextStorage.create("initial") + assertTrue(storage != null) + }, + test("implementationName is ScopedValue or ThreadLocal") { + val name = ContextStorage.implementationName + assertTrue(name == "ScopedValue" || name == "ThreadLocal") + }, + test("hasLoom is consistent with implementationName") { + val hasLoom = ContextStorage.hasLoom + val name = ContextStorage.implementationName + assertTrue( + (hasLoom && name == "ScopedValue") || + (!hasLoom && name == "ThreadLocal") + ) + } + ), + suite("get")( + test("returns initial value") { + val storage = ContextStorage.create("initial") + assertTrue(storage.get() == "initial") + }, + test("returns initial value for numeric type") { + val storage = ContextStorage.create(42) + assertTrue(storage.get() == 42) + } + ), + suite("set")( + test("updates value visible to get") { + val storage = ContextStorage.create("initial") + storage.set("updated") + assertTrue(storage.get() == "updated") + }, + test("set multiple times returns last value") { + val storage = ContextStorage.create(0) + storage.set(1) + storage.set(2) + storage.set(3) + assertTrue(storage.get() == 3) + } + ), + suite("scoped")( + test("get returns scoped value inside block") { + val storage = ContextStorage.create("initial") + val inside = storage.scoped("scoped") { + storage.get() + } + assertTrue(inside == "scoped") + }, + test("get returns previous value after scoped block") { + val storage = ContextStorage.create("initial") + storage.scoped("scoped") { + () + } + assertTrue(storage.get() == "initial") + }, + test("scoped restores after exception") { + val storage = ContextStorage.create("initial") + try { + storage.scoped("scoped") { + throw new RuntimeException("boom") + } + } catch { + case _: RuntimeException => () + } + assertTrue(storage.get() == "initial") + }, + test("nested scoped blocks restore correctly - 3 levels deep") { + val storage = ContextStorage.create("L0") + val r0Before = storage.get() + val (r1, r2, r3) = storage.scoped("L1") { + val v1 = storage.get() + val (v2, v3) = storage.scoped("L2") { + val vv2 = storage.get() + val vv3 = storage.scoped("L3") { + storage.get() + } + assertTrue(storage.get() == "L2") + (vv2, vv3) + } + assertTrue(storage.get() == "L1") + (v1, v2, v3) + } + assertTrue( + r0Before == "L0" && + r1 == "L1" && + r2 == "L2" && + r3 == "L3" && + storage.get() == "L0" + ) + }, + test("scoped returns the block result") { + val storage = ContextStorage.create(0) + val result = storage.scoped(42) { + storage.get() * 2 + } + assertTrue(result == 84) + } + ), + suite("cross-thread isolation")( + test("implementationName reports correctly") { + // Verifies that the storage detects the correct JVM capability + val name = ContextStorage.implementationName + assertTrue(name == "ScopedValue" || name == "ThreadLocal") + }, + test("set on one storage does not affect another") { + val s1 = ContextStorage.create("a") + val s2 = ContextStorage.create("b") + s1.set("x") + assertTrue(s1.get() == "x" && s2.get() == "b") + } + ) + ) +} diff --git a/otel/jvm/src/test/scala/zio/blocks/otel/HttpSenderSpec.scala b/otel/jvm/src/test/scala/zio/blocks/otel/HttpSenderSpec.scala new file mode 100644 index 0000000000..f645222f7c --- /dev/null +++ b/otel/jvm/src/test/scala/zio/blocks/otel/HttpSenderSpec.scala @@ -0,0 +1,63 @@ +package zio.blocks.otel + +import zio.test._ +import java.time.Duration + +object HttpSenderSpec extends ZIOSpecDefault { + def spec = suite("HttpSenderSpec")( + test("HttpResponse case class creation") { + val response = HttpResponse( + statusCode = 200, + body = "test body".getBytes(), + headers = Map("content-type" -> "application/json") + ) + assertTrue( + response.statusCode == 200 && + response.body.sameElements("test body".getBytes()) && + response.headers.get("content-type").contains("application/json") + ) + }, + test("HttpResponse case class with empty body") { + val response = HttpResponse( + statusCode = 204, + body = Array.empty[Byte], + headers = Map.empty[String, String] + ) + assertTrue( + response.statusCode == 204 && + response.body.isEmpty && + response.headers.isEmpty + ) + }, + test("HttpResponse case class with multiple headers") { + val response = HttpResponse( + statusCode = 201, + body = Array.empty[Byte], + headers = Map( + "x-header-1" -> "value1", + "x-header-2" -> "value2", + "content-type" -> "text/plain" + ) + ) + assertTrue( + response.headers.size == 3 && + response.headers.get("x-header-1").contains("value1") && + response.headers.get("x-header-2").contains("value2") && + response.headers.get("content-type").contains("text/plain") + ) + }, + test("JdkHttpSender construction with default timeout") { + val sender = new JdkHttpSender() + assertTrue(sender != null) + }, + test("JdkHttpSender construction with custom timeout") { + val sender = new JdkHttpSender(timeout = Duration.ofSeconds(60)) + assertTrue(sender != null) + }, + test("JdkHttpSender shutdown completes without error") { + val sender = new JdkHttpSender() + val result = sender.shutdown() + assertTrue(result == ()) + } + ) +} diff --git a/otel/jvm/src/test/scala/zio/blocks/otel/MetricSpec.scala b/otel/jvm/src/test/scala/zio/blocks/otel/MetricSpec.scala new file mode 100644 index 0000000000..5f066c255a --- /dev/null +++ b/otel/jvm/src/test/scala/zio/blocks/otel/MetricSpec.scala @@ -0,0 +1,386 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import zio.test._ + +object MetricSpec extends ZIOSpecDefault { + + private val regionKey = AttributeKey.string("region") + private val usEast = Attributes.of(regionKey, "us-east-1") + private val euWest = Attributes.of(regionKey, "eu-west-1") + + private def sumPoints(data: MetricData): List[SumDataPoint] = data match { + case MetricData.SumData(points) => points + case MetricData.HistogramData(_) => Nil + case MetricData.GaugeData(_) => Nil + } + + private def histogramPoints(data: MetricData): List[HistogramDataPoint] = data match { + case MetricData.SumData(_) => Nil + case MetricData.HistogramData(points) => points + case MetricData.GaugeData(_) => Nil + } + + private def gaugePoints(data: MetricData): List[GaugeDataPoint] = data match { + case MetricData.SumData(_) => Nil + case MetricData.HistogramData(_) => Nil + case MetricData.GaugeData(points) => points + } + + def spec = suite("Metrics")( + suite("Measurement")( + test("stores value and attributes") { + val m = Measurement(42.0, usEast) + assertTrue(m.value == 42.0 && m.attributes.get(regionKey).contains("us-east-1")) + }, + test("supports empty attributes") { + val m = Measurement(0.0, Attributes.empty) + assertTrue(m.value == 0.0 && m.attributes.isEmpty) + } + ), + suite("MetricData")( + test("SumData holds data points") { + val dp = SumDataPoint(usEast, 100L, 200L, 5L) + val data = MetricData.SumData(List(dp)) + val points = sumPoints(data) + assertTrue( + points.size == 1 && + points.head.value == 5L && + points.head.startTimeNanos == 100L && + points.head.timeNanos == 200L + ) + }, + test("HistogramData holds data points") { + val dp = HistogramDataPoint( + usEast, + 100L, + 200L, + count = 3L, + sum = 15.0, + min = 1.0, + max = 10.0, + bucketCounts = Array(1L, 1L, 1L, 0L), + boundaries = Array(5.0, 10.0, 25.0) + ) + val data = MetricData.HistogramData(List(dp)) + val points = histogramPoints(data) + assertTrue( + points.size == 1 && + points.head.count == 3L && + points.head.sum == 15.0 && + points.head.min == 1.0 && + points.head.max == 10.0 + ) + }, + test("GaugeData holds data points") { + val dp = GaugeDataPoint(usEast, 200L, 42.5) + val data = MetricData.GaugeData(List(dp)) + val points = gaugePoints(data) + assertTrue(points.size == 1 && points.head.value == 42.5 && points.head.timeNanos == 200L) + } + ), + suite("Counter")( + test("add increments for a given attribute set") { + val counter = Counter("requests", "Total requests", "1") + counter.add(5L, usEast) + counter.add(3L, usEast) + val points = sumPoints(counter.collect()) + assertTrue(points.size == 1 && points.head.value == 8L) + }, + test("tracks multiple attribute sets independently") { + val counter = Counter("requests", "Total requests", "1") + counter.add(5L, usEast) + counter.add(3L, euWest) + val points = sumPoints(counter.collect()) + val values = points.map(_.value).toSet + assertTrue(points.size == 2 && values == Set(5L, 3L)) + }, + test("add with zero is a no-op") { + val counter = Counter("requests", "Total requests", "1") + counter.add(0L, usEast) + val points = sumPoints(counter.collect()) + assertTrue(points.size == 1 && points.head.value == 0L) + }, + test("add with negative value is silently ignored") { + val counter = Counter("requests", "Total requests", "1") + counter.add(5L, usEast) + counter.add(-3L, usEast) + val points = sumPoints(counter.collect()) + assertTrue(points.size == 1 && points.head.value == 5L) + }, + test("concurrent adds are safe") { + val counter = Counter("concurrent", "Concurrent counter", "1") + val threads = (1 to 10).map { _ => + new Thread(() => { + var i = 0 + while (i < 1000) { + counter.add(1L, usEast) + i += 1 + } + }) + } + threads.foreach(_.start()) + threads.foreach(_.join()) + val points = sumPoints(counter.collect()) + assertTrue(points.head.value == 10000L) + } + ), + suite("UpDownCounter")( + test("allows positive and negative additions") { + val counter = UpDownCounter("queue.size", "Queue depth", "1") + counter.add(10L, usEast) + counter.add(-3L, usEast) + val points = sumPoints(counter.collect()) + assertTrue(points.size == 1 && points.head.value == 7L) + }, + test("can go negative") { + val counter = UpDownCounter("balance", "Account balance", "1") + counter.add(-5L, usEast) + val points = sumPoints(counter.collect()) + assertTrue(points.head.value == -5L) + }, + test("tracks multiple attribute sets independently") { + val counter = UpDownCounter("queue.size", "Queue depth", "1") + counter.add(10L, usEast) + counter.add(20L, euWest) + counter.add(-3L, usEast) + val points = sumPoints(counter.collect()) + val values = points.map(_.value).toSet + assertTrue(points.size == 2 && values == Set(7L, 20L)) + } + ), + suite("Histogram")( + test("records values and computes statistics") { + val histogram = Histogram("latency", "Request latency", "ms") + histogram.record(5.0, usEast) + histogram.record(10.0, usEast) + histogram.record(25.0, usEast) + val points = histogramPoints(histogram.collect()) + assertTrue( + points.size == 1 && + points.head.count == 3L && + points.head.sum == 40.0 && + points.head.min == 5.0 && + points.head.max == 25.0 + ) + }, + test("distributes values into buckets") { + val histogram = Histogram("latency", "Request latency", "ms", Array(5.0, 10.0, 25.0)) + histogram.record(3.0, usEast) + histogram.record(7.0, usEast) + histogram.record(20.0, usEast) + histogram.record(100.0, usEast) + val points = histogramPoints(histogram.collect()) + val bc = points.head.bucketCounts + assertTrue( + points.head.count == 4L && + bc(0) == 1L && + bc(1) == 1L && + bc(2) == 1L && + bc(3) == 1L + ) + }, + test("uses default boundaries when none provided") { + val histogram = Histogram("latency", "Request latency", "ms") + histogram.record(5.0, usEast) + val points = histogramPoints(histogram.collect()) + assertTrue(points.head.boundaries.nonEmpty) + }, + test("tracks multiple attribute sets independently") { + val histogram = Histogram("latency", "Request latency", "ms") + histogram.record(5.0, usEast) + histogram.record(10.0, euWest) + val points = histogramPoints(histogram.collect()) + assertTrue(points.size == 2) + }, + test("concurrent records are safe") { + val histogram = Histogram("concurrent", "Concurrent histogram", "ms") + val threads = (1 to 10).map { _ => + new Thread(() => { + var i = 0 + while (i < 1000) { + histogram.record(1.0, usEast) + i += 1 + } + }) + } + threads.foreach(_.start()) + threads.foreach(_.join()) + val points = histogramPoints(histogram.collect()) + assertTrue(points.head.count == 10000L) + } + ), + suite("Gauge")( + test("records the latest value") { + val gauge = Gauge("temperature", "Current temperature", "celsius") + gauge.record(20.0, usEast) + gauge.record(25.0, usEast) + val points = gaugePoints(gauge.collect()) + assertTrue(points.size == 1 && points.head.value == 25.0) + }, + test("tracks multiple attribute sets independently") { + val gauge = Gauge("temperature", "Current temperature", "celsius") + gauge.record(20.0, usEast) + gauge.record(30.0, euWest) + val points = gaugePoints(gauge.collect()) + val values = points.map(_.value).toSet + assertTrue(points.size == 2 && values == Set(20.0, 30.0)) + }, + test("concurrent records settle on one value") { + val gauge = Gauge("concurrent", "Concurrent gauge", "1") + val threads = (1 to 10).map { i => + new Thread(() => gauge.record(i.toDouble, usEast)) + } + threads.foreach(_.start()) + threads.foreach(_.join()) + val points = gaugePoints(gauge.collect()) + assertTrue(points.size == 1 && points.head.value > 0.0) + } + ), + suite("ObservableCounter")( + test("collects via callback") { + val counter = ObservableCounter("system.cpu.time", "CPU time", "s") { cb => + cb.record(150.0, usEast) + } + val points = sumPoints(counter.collect()) + assertTrue(points.size == 1 && points.head.value == 150L) + }, + test("callback invoked on each collect") { + var callCount = 0 + val counter = ObservableCounter("invocations", "Invocation count", "1") { cb => + callCount += 1 + cb.record(callCount.toDouble, usEast) + } + counter.collect() + counter.collect() + val points = sumPoints(counter.collect()) + assertTrue(callCount == 3 && points.head.value == 3L) + } + ), + suite("ObservableUpDownCounter")( + test("collects via callback with negative values") { + val counter = ObservableUpDownCounter("pool.active", "Active pool connections", "1") { cb => + cb.record(-5.0, usEast) + } + val points = sumPoints(counter.collect()) + assertTrue(points.head.value == -5L) + } + ), + suite("ObservableGauge")( + test("collects via callback") { + val gauge = ObservableGauge("system.memory.usage", "Memory usage", "By") { cb => + cb.record(1024.0, usEast) + cb.record(2048.0, euWest) + } + val points = gaugePoints(gauge.collect()) + val values = points.map(_.value).toSet + assertTrue(points.size == 2 && values == Set(1024.0, 2048.0)) + }, + test("each collect invokes callback fresh") { + var temp = 20.0 + val gauge = ObservableGauge("temperature", "Current temp", "celsius") { cb => + cb.record(temp, usEast) + } + gauge.collect() + temp = 25.0 + val points = gaugePoints(gauge.collect()) + assertTrue(points.head.value == 25.0) + } + ), + suite("AttributeValue type coverage in SyncInstruments")( + test("covers all AttributeValue types in Counter") { + val counter = Counter("test", "Test counter", "1") + val attrs = Attributes.builder + .put("str", "value") + .put("bool", true) + .put("long", 42L) + .put("double", 3.14) + .put(AttributeKey.stringSeq("str_seq"), Seq("a", "b")) + .put(AttributeKey.longSeq("long_seq"), Seq(1L, 2L)) + .put(AttributeKey.doubleSeq("double_seq"), Seq(1.1, 2.2)) + .put(AttributeKey.booleanSeq("bool_seq"), Seq(true, false)) + .build + counter.add(1L, attrs) + val points = sumPoints(counter.collect()) + assertTrue( + points.size == 1 && + points.head.attributes.get(AttributeKey.string("str")).contains("value") && + points.head.attributes.get(AttributeKey.string("bool")).isDefined + ) + }, + test("covers all AttributeValue types in Histogram") { + val histogram = Histogram("test", "Test histogram", "1") + val attrs = Attributes.builder + .put("str", "value") + .put("bool", true) + .put("long", 42L) + .put("double", 3.14) + .put(AttributeKey.stringSeq("str_seq"), Seq("a", "b")) + .put(AttributeKey.longSeq("long_seq"), Seq(1L, 2L)) + .put(AttributeKey.doubleSeq("double_seq"), Seq(1.1, 2.2)) + .put(AttributeKey.booleanSeq("bool_seq"), Seq(true, false)) + .build + histogram.record(5.0, attrs) + val points = histogramPoints(histogram.collect()) + assertTrue( + points.size == 1 && + points.head.count == 1L && + points.head.sum == 5.0 + ) + }, + test("covers all AttributeValue types in Gauge") { + val gauge = Gauge("test", "Test gauge", "1") + val attrs = Attributes.builder + .put("str", "value") + .put("bool", true) + .put("long", 42L) + .put("double", 3.14) + .put(AttributeKey.stringSeq("str_seq"), Seq("a", "b")) + .put(AttributeKey.longSeq("long_seq"), Seq(1L, 2L)) + .put(AttributeKey.doubleSeq("double_seq"), Seq(1.1, 2.2)) + .put(AttributeKey.booleanSeq("bool_seq"), Seq(true, false)) + .build + gauge.record(42.0, attrs) + val points = gaugePoints(gauge.collect()) + assertTrue( + points.size == 1 && + points.head.value == 42.0 + ) + }, + test("covers all AttributeValue types in UpDownCounter") { + val counter = UpDownCounter("test", "Test counter", "1") + val attrs = Attributes.builder + .put("str", "value") + .put("bool", true) + .put("long", 42L) + .put("double", 3.14) + .put(AttributeKey.stringSeq("str_seq"), Seq("a", "b")) + .put(AttributeKey.longSeq("long_seq"), Seq(1L, 2L)) + .put(AttributeKey.doubleSeq("double_seq"), Seq(1.1, 2.2)) + .put(AttributeKey.booleanSeq("bool_seq"), Seq(true, false)) + .build + counter.add(-5L, attrs) + val points = sumPoints(counter.collect()) + assertTrue( + points.size == 1 && + points.head.value == -5L + ) + } + ) + ) +} diff --git a/otel/jvm/src/test/scala/zio/blocks/otel/OtlpJsonEncoderSpec.scala b/otel/jvm/src/test/scala/zio/blocks/otel/OtlpJsonEncoderSpec.scala new file mode 100644 index 0000000000..1f9457a43b --- /dev/null +++ b/otel/jvm/src/test/scala/zio/blocks/otel/OtlpJsonEncoderSpec.scala @@ -0,0 +1,578 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import zio.test._ + +object OtlpJsonEncoderSpec extends ZIOSpecDefault { + + private val testResource = Resource( + Attributes.builder + .put("service.name", "test-service") + .build + ) + + private val testScope = InstrumentationScope( + name = "test-lib", + version = Some("1.0.0") + ) + + private val testTraceId = TraceId(hi = 0x0123456789abcdefL, lo = 0xfedcba9876543210L) + private val testSpanId = SpanId(value = 0x0123456789abcdefL) + + private val parentSpanId = SpanId(value = 0xfedcba9876543210L) + + private val testSpanContext = SpanContext( + traceId = testTraceId, + spanId = testSpanId, + traceFlags = TraceFlags.sampled, + traceState = "", + isRemote = false + ) + + private val parentSpanContext = SpanContext( + traceId = testTraceId, + spanId = parentSpanId, + traceFlags = TraceFlags.sampled, + traceState = "", + isRemote = false + ) + + private def jsonString(bytes: Array[Byte]): String = new String(bytes, "UTF-8") + + def spec = suite("OtlpJsonEncoder")( + suite("encodeTraces")( + test("encodes single span with correct OTLP structure") { + val span = SpanData( + name = "test-op", + kind = SpanKind.Server, + spanContext = testSpanContext, + parentSpanContext = parentSpanContext, + startTimeNanos = 1000000000L, + endTimeNanos = 2000000000L, + attributes = Attributes.empty, + events = Nil, + links = Nil, + status = SpanStatus.Unset, + resource = testResource, + instrumentationScope = testScope + ) + + val json = jsonString(OtlpJsonEncoder.encodeTraces(Seq(span), testResource, testScope)) + + assertTrue( + json.contains("\"resourceSpans\""), + json.contains("\"scopeSpans\""), + json.contains("\"spans\""), + json.contains("\"name\":\"test-op\""), + json.contains("\"kind\":2"), + json.contains("\"traceId\":\"0123456789abcdeffedcba9876543210\""), + json.contains("\"spanId\":\"0123456789abcdef\""), + json.contains("\"parentSpanId\":\"fedcba9876543210\""), + json.contains("\"startTimeUnixNano\":\"1000000000\""), + json.contains("\"endTimeUnixNano\":\"2000000000\""), + json.contains("\"status\":{\"code\":0}") + ) + }, + test("encodes span with attributes") { + val attrs = Attributes.builder + .put("http.method", "GET") + .put("http.status_code", 200L) + .build + + val span = SpanData( + name = "http-request", + kind = SpanKind.Client, + spanContext = testSpanContext, + parentSpanContext = SpanContext.invalid, + startTimeNanos = 100L, + endTimeNanos = 200L, + attributes = attrs, + events = Nil, + links = Nil, + status = SpanStatus.Ok, + resource = testResource, + instrumentationScope = testScope + ) + + val json = jsonString(OtlpJsonEncoder.encodeTraces(Seq(span), testResource, testScope)) + + assertTrue( + json.contains("\"key\":\"http.method\""), + json.contains("\"stringValue\":\"GET\""), + json.contains("\"key\":\"http.status_code\""), + json.contains("\"intValue\":\"200\""), + json.contains("\"status\":{\"code\":1}") + ) + }, + test("encodes span with error status and description") { + val span = SpanData( + name = "failing-op", + kind = SpanKind.Internal, + spanContext = testSpanContext, + parentSpanContext = SpanContext.invalid, + startTimeNanos = 100L, + endTimeNanos = 200L, + attributes = Attributes.empty, + events = Nil, + links = Nil, + status = SpanStatus.Error("something went wrong"), + resource = testResource, + instrumentationScope = testScope + ) + + val json = jsonString(OtlpJsonEncoder.encodeTraces(Seq(span), testResource, testScope)) + + assertTrue( + json.contains("\"status\":{\"code\":2,\"message\":\"something went wrong\"}") + ) + }, + test("encodes span with events") { + val event = SpanEvent( + name = "exception", + timestampNanos = 150L, + attributes = Attributes.builder.put("exception.message", "boom").build + ) + + val span = SpanData( + name = "with-events", + kind = SpanKind.Internal, + spanContext = testSpanContext, + parentSpanContext = SpanContext.invalid, + startTimeNanos = 100L, + endTimeNanos = 200L, + attributes = Attributes.empty, + events = List(event), + links = Nil, + status = SpanStatus.Unset, + resource = testResource, + instrumentationScope = testScope + ) + + val json = jsonString(OtlpJsonEncoder.encodeTraces(Seq(span), testResource, testScope)) + + assertTrue( + json.contains("\"events\":[{"), + json.contains("\"name\":\"exception\""), + json.contains("\"timeUnixNano\":\"150\""), + json.contains("\"key\":\"exception.message\"") + ) + }, + test("encodes span with links") { + val linkedContext = SpanContext( + traceId = TraceId(hi = 0xaabbccddeeff0011L, lo = 0x2233445566778899L), + spanId = SpanId(value = 0xaabbccddeeff0011L), + traceFlags = TraceFlags.sampled, + traceState = "", + isRemote = true + ) + val link = SpanLink( + spanContext = linkedContext, + attributes = Attributes.empty + ) + + val span = SpanData( + name = "with-links", + kind = SpanKind.Internal, + spanContext = testSpanContext, + parentSpanContext = SpanContext.invalid, + startTimeNanos = 100L, + endTimeNanos = 200L, + attributes = Attributes.empty, + events = Nil, + links = List(link), + status = SpanStatus.Unset, + resource = testResource, + instrumentationScope = testScope + ) + + val json = jsonString(OtlpJsonEncoder.encodeTraces(Seq(span), testResource, testScope)) + + assertTrue( + json.contains("\"links\":[{"), + json.contains("\"traceId\":\"aabbccddeeff00112233445566778899\""), + json.contains("\"spanId\":\"aabbccddeeff0011\"") + ) + }, + test("encodes empty spans list") { + val json = jsonString(OtlpJsonEncoder.encodeTraces(Seq.empty, testResource, testScope)) + + assertTrue( + json.contains("\"resourceSpans\":[{"), + json.contains("\"spans\":[]") + ) + }, + test("all SpanKind values map to correct OTLP integers") { + def makeSpan(kind: SpanKind): SpanData = SpanData( + name = "kind-test", + kind = kind, + spanContext = testSpanContext, + parentSpanContext = SpanContext.invalid, + startTimeNanos = 0L, + endTimeNanos = 0L, + attributes = Attributes.empty, + events = Nil, + links = Nil, + status = SpanStatus.Unset, + resource = testResource, + instrumentationScope = testScope + ) + + val internalJson = + jsonString(OtlpJsonEncoder.encodeTraces(Seq(makeSpan(SpanKind.Internal)), testResource, testScope)) + val serverJson = + jsonString(OtlpJsonEncoder.encodeTraces(Seq(makeSpan(SpanKind.Server)), testResource, testScope)) + val clientJson = + jsonString(OtlpJsonEncoder.encodeTraces(Seq(makeSpan(SpanKind.Client)), testResource, testScope)) + val producerJson = + jsonString(OtlpJsonEncoder.encodeTraces(Seq(makeSpan(SpanKind.Producer)), testResource, testScope)) + val consumerJson = + jsonString(OtlpJsonEncoder.encodeTraces(Seq(makeSpan(SpanKind.Consumer)), testResource, testScope)) + + assertTrue( + internalJson.contains("\"kind\":1"), + serverJson.contains("\"kind\":2"), + clientJson.contains("\"kind\":3"), + producerJson.contains("\"kind\":4"), + consumerJson.contains("\"kind\":5") + ) + } + ), + suite("encodeMetrics")( + test("encodes sum metric (counter) with correct structure") { + val point = SumDataPoint(Attributes.empty, 0L, 1000000000L, 42L) + val metric = MetricData.SumData(List(point)) + + val json = jsonString( + OtlpJsonEncoder.encodeMetrics( + Seq(OtlpJsonEncoder.NamedMetric("request.count", "", "1", metric)), + testResource, + testScope + ) + ) + + assertTrue( + json.contains("\"resourceMetrics\""), + json.contains("\"scopeMetrics\""), + json.contains("\"metrics\""), + json.contains("\"name\":\"request.count\""), + json.contains("\"sum\":{"), + json.contains("\"asInt\":\"42\""), + json.contains("\"timeUnixNano\":\"1000000000\""), + json.contains("\"isMonotonic\":true") + ) + }, + test("encodes histogram metric") { + val point = HistogramDataPoint( + attributes = Attributes.empty, + startTimeNanos = 0L, + timeNanos = 1000000000L, + count = 10L, + sum = 55.5, + min = 1.0, + max = 10.0, + bucketCounts = Array(2L, 3L, 5L), + boundaries = Array(5.0, 10.0) + ) + val metric = MetricData.HistogramData(List(point)) + + val json = jsonString( + OtlpJsonEncoder.encodeMetrics( + Seq(OtlpJsonEncoder.NamedMetric("latency", "request latency", "ms", metric)), + testResource, + testScope + ) + ) + + assertTrue( + json.contains("\"name\":\"latency\""), + json.contains("\"histogram\":{"), + json.contains("\"count\":\"10\""), + json.contains("\"sum\":55.5"), + json.contains("\"min\":1.0"), + json.contains("\"max\":10.0"), + json.contains("\"bucketCounts\":[\"2\",\"3\",\"5\"]"), + json.contains("\"explicitBounds\":[5.0,10.0]") + ) + }, + test("encodes gauge metric") { + val point = GaugeDataPoint(Attributes.empty, 1000000000L, 73.5) + val metric = MetricData.GaugeData(List(point)) + + val json = jsonString( + OtlpJsonEncoder.encodeMetrics( + Seq(OtlpJsonEncoder.NamedMetric("temperature", "current temp", "celsius", metric)), + testResource, + testScope + ) + ) + + assertTrue( + json.contains("\"name\":\"temperature\""), + json.contains("\"gauge\":{"), + json.contains("\"asDouble\":73.5") + ) + }, + test("encodes metric data points with attributes") { + val attrs = Attributes.builder.put("region", "us-east-1").build + val point = SumDataPoint(attrs, 0L, 1000000000L, 100L) + val metric = MetricData.SumData(List(point)) + + val json = jsonString( + OtlpJsonEncoder.encodeMetrics( + Seq(OtlpJsonEncoder.NamedMetric("req", "", "", metric)), + testResource, + testScope + ) + ) + + assertTrue( + json.contains("\"key\":\"region\""), + json.contains("\"stringValue\":\"us-east-1\"") + ) + } + ), + suite("encodeLogs")( + test("encodes log record with correct OTLP structure") { + val log = LogRecord( + timestampNanos = 1000000000L, + observedTimestampNanos = 1000000001L, + severity = Severity.Info, + severityText = "INFO", + body = "User logged in", + attributes = Attributes.empty, + traceId = Some(testTraceId), + spanId = Some(testSpanId), + traceFlags = Some(TraceFlags.sampled), + resource = testResource, + instrumentationScope = testScope + ) + + val json = jsonString(OtlpJsonEncoder.encodeLogs(Seq(log), testResource, testScope)) + + assertTrue( + json.contains("\"resourceLogs\""), + json.contains("\"scopeLogs\""), + json.contains("\"logRecords\""), + json.contains("\"timeUnixNano\":\"1000000000\""), + json.contains("\"severityNumber\":9"), + json.contains("\"severityText\":\"INFO\""), + json.contains("\"body\":{\"stringValue\":\"User logged in\"}"), + json.contains("\"traceId\":\"0123456789abcdeffedcba9876543210\""), + json.contains("\"spanId\":\"0123456789abcdef\"") + ) + }, + test("encodes log record without trace correlation") { + val log = LogRecord( + timestampNanos = 5000L, + observedTimestampNanos = 5001L, + severity = Severity.Error, + severityText = "ERROR", + body = "disk full", + attributes = Attributes.empty, + traceId = None, + spanId = None, + traceFlags = None, + resource = testResource, + instrumentationScope = testScope + ) + + val json = jsonString(OtlpJsonEncoder.encodeLogs(Seq(log), testResource, testScope)) + + assertTrue( + json.contains("\"severityNumber\":17"), + json.contains("\"severityText\":\"ERROR\""), + json.contains("\"traceId\":\"\""), + json.contains("\"spanId\":\"\"") + ) + }, + test("encodes log record with attributes") { + val attrs = Attributes.builder + .put("user.id", "u123") + .put("request.latency", 42.5) + .build + + val log = LogRecord( + timestampNanos = 1000L, + observedTimestampNanos = 1001L, + severity = Severity.Warn, + severityText = "WARN", + body = "slow request", + attributes = attrs, + traceId = None, + spanId = None, + traceFlags = None, + resource = testResource, + instrumentationScope = testScope + ) + + val json = jsonString(OtlpJsonEncoder.encodeLogs(Seq(log), testResource, testScope)) + + assertTrue( + json.contains("\"key\":\"user.id\""), + json.contains("\"stringValue\":\"u123\""), + json.contains("\"key\":\"request.latency\""), + json.contains("\"doubleValue\":42.5") + ) + } + ), + suite("attribute encoding")( + test("encodes string attribute") { + val attrs = Attributes.builder.put("k", "hello").build + val span = makeSimpleSpan(attributes = attrs) + val json = jsonString(OtlpJsonEncoder.encodeTraces(Seq(span), testResource, testScope)) + + assertTrue(json.contains("\"stringValue\":\"hello\"")) + }, + test("encodes long attribute as quoted string") { + val attrs = Attributes.builder.put("k", 42L).build + val span = makeSimpleSpan(attributes = attrs) + val json = jsonString(OtlpJsonEncoder.encodeTraces(Seq(span), testResource, testScope)) + + assertTrue(json.contains("\"intValue\":\"42\"")) + }, + test("encodes double attribute") { + val attrs = Attributes.builder.put("k", 3.14).build + val span = makeSimpleSpan(attributes = attrs) + val json = jsonString(OtlpJsonEncoder.encodeTraces(Seq(span), testResource, testScope)) + + assertTrue(json.contains("\"doubleValue\":3.14")) + }, + test("encodes boolean attribute") { + val attrs = Attributes.builder.put("k", true).build + val span = makeSimpleSpan(attributes = attrs) + val json = jsonString(OtlpJsonEncoder.encodeTraces(Seq(span), testResource, testScope)) + + assertTrue(json.contains("\"boolValue\":true")) + }, + test("encodes string seq attribute as arrayValue") { + val attrs = Attributes.of(AttributeKey.stringSeq("tags"), Seq("a", "b")) + val span = makeSimpleSpan(attributes = attrs) + val json = jsonString(OtlpJsonEncoder.encodeTraces(Seq(span), testResource, testScope)) + + assertTrue( + json.contains("\"arrayValue\":{\"values\":["), + json.contains("\"stringValue\":\"a\""), + json.contains("\"stringValue\":\"b\"") + ) + } + ), + suite("JSON string escaping")( + test("escapes double quotes") { + val attrs = Attributes.builder.put("k", "say \"hello\"").build + val span = makeSimpleSpan(attributes = attrs) + val json = jsonString(OtlpJsonEncoder.encodeTraces(Seq(span), testResource, testScope)) + + assertTrue(json.contains("say \\\"hello\\\"")) + }, + test("escapes backslash") { + val attrs = Attributes.builder.put("k", "path\\to\\file").build + val span = makeSimpleSpan(attributes = attrs) + val json = jsonString(OtlpJsonEncoder.encodeTraces(Seq(span), testResource, testScope)) + + assertTrue(json.contains("path\\\\to\\\\file")) + }, + test("escapes newline and tab") { + val attrs = Attributes.builder.put("k", "line1\nline2\ttab").build + val span = makeSimpleSpan(attributes = attrs) + val json = jsonString(OtlpJsonEncoder.encodeTraces(Seq(span), testResource, testScope)) + + assertTrue( + json.contains("line1\\nline2\\ttab") + ) + }, + test("escapes control characters") { + val attrs = Attributes.builder.put("k", "null\u0000char").build + val span = makeSimpleSpan(attributes = attrs) + val json = jsonString(OtlpJsonEncoder.encodeTraces(Seq(span), testResource, testScope)) + + assertTrue(json.contains("\\u0000")) + } + ), + suite("empty collections")( + test("empty attributes produce empty array") { + val span = makeSimpleSpan(attributes = Attributes.empty) + val json = jsonString(OtlpJsonEncoder.encodeTraces(Seq(span), testResource, testScope)) + + assertTrue(json.contains("\"attributes\":[]")) + }, + test("empty events produce empty array") { + val span = makeSimpleSpan() + val json = jsonString(OtlpJsonEncoder.encodeTraces(Seq(span), testResource, testScope)) + + assertTrue(json.contains("\"events\":[]")) + }, + test("empty links produce empty array") { + val span = makeSimpleSpan() + val json = jsonString(OtlpJsonEncoder.encodeTraces(Seq(span), testResource, testScope)) + + assertTrue(json.contains("\"links\":[]")) + } + ), + suite("resource and scope encoding")( + test("resource attributes are encoded") { + val span = makeSimpleSpan() + val json = jsonString(OtlpJsonEncoder.encodeTraces(Seq(span), testResource, testScope)) + + assertTrue( + json.contains("\"resource\":{\"attributes\":["), + json.contains("\"key\":\"service.name\""), + json.contains("\"stringValue\":\"test-service\"") + ) + }, + test("scope name and version are encoded") { + val span = makeSimpleSpan() + val json = jsonString(OtlpJsonEncoder.encodeTraces(Seq(span), testResource, testScope)) + + assertTrue( + json.contains("\"scope\":{\"name\":\"test-lib\",\"version\":\"1.0.0\""), + json.contains("\"name\":\"test-lib\""), + json.contains("\"version\":\"1.0.0\"") + ) + }, + test("scope without version omits version field") { + val scopeNoVersion = InstrumentationScope(name = "no-version-lib") + val span = makeSimpleSpan() + val json = jsonString(OtlpJsonEncoder.encodeTraces(Seq(span), testResource, scopeNoVersion)) + + assertTrue( + json.contains("\"scope\":{\"name\":\"no-version-lib\"}"), + !json.contains("\"version\"") + ) + } + ) + ) + + private def makeSimpleSpan( + attributes: Attributes = Attributes.empty, + events: List[SpanEvent] = Nil, + links: List[SpanLink] = Nil + ): SpanData = SpanData( + name = "simple", + kind = SpanKind.Internal, + spanContext = testSpanContext, + parentSpanContext = SpanContext.invalid, + startTimeNanos = 100L, + endTimeNanos = 200L, + attributes = attributes, + events = events, + links = links, + status = SpanStatus.Unset, + resource = testResource, + instrumentationScope = testScope + ) +} diff --git a/otel/jvm/src/test/scala/zio/blocks/otel/OtlpJsonExporterSpec.scala b/otel/jvm/src/test/scala/zio/blocks/otel/OtlpJsonExporterSpec.scala new file mode 100644 index 0000000000..60e2ece3f6 --- /dev/null +++ b/otel/jvm/src/test/scala/zio/blocks/otel/OtlpJsonExporterSpec.scala @@ -0,0 +1,481 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import zio.test._ + +import java.util.concurrent.atomic.AtomicReference + +object OtlpJsonExporterSpec extends ZIOSpecDefault { + + private val testResource = Resource.create( + Attributes.of(AttributeKey.string("service.name"), "test-service") + ) + private val testScope = InstrumentationScope("test-lib", Some("1.0.0")) + + private def mockHttpSender(): (HttpSender, AtomicReference[List[(String, Map[String, String], Array[Byte])]]) = { + val captured = new AtomicReference[List[(String, Map[String, String], Array[Byte])]](Nil) + val sender = new HttpSender { + def send(url: String, headers: Map[String, String], body: Array[Byte]): HttpResponse = { + var done = false + while (!done) { + val current = captured.get() + done = captured.compareAndSet(current, current :+ ((url, headers, body))) + } + HttpResponse(200, Array.empty[Byte], Map.empty) + } + def shutdown(): Unit = () + } + (sender, captured) + } + + private def mockHttpSenderWithStatus(statusCode: Int): HttpSender = new HttpSender { + def send(url: String, headers: Map[String, String], body: Array[Byte]): HttpResponse = + HttpResponse(statusCode, Array.empty[Byte], Map.empty) + def shutdown(): Unit = () + } + + private def sampleSpanData(): SpanData = SpanData( + name = "test-span", + kind = SpanKind.Server, + spanContext = SpanContext( + traceId = TraceId.fromHex("0af7651916cd43dd8448eb211c80319c").get, + spanId = SpanId.fromHex("b7ad6b7169203331").get, + traceFlags = TraceFlags.sampled, + traceState = "", + isRemote = false + ), + parentSpanContext = SpanContext.invalid, + startTimeNanos = 1000000L, + endTimeNanos = 2000000L, + attributes = Attributes.empty, + events = Nil, + links = Nil, + status = SpanStatus.Ok, + resource = testResource, + instrumentationScope = testScope + ) + + private def sampleLogRecord(): LogRecord = LogRecord( + timestampNanos = 1000000L, + observedTimestampNanos = 1000000L, + severity = Severity.Info, + severityText = "INFO", + body = "test log message", + attributes = Attributes.empty, + traceId = None, + spanId = None, + traceFlags = None, + resource = testResource, + instrumentationScope = testScope + ) + + private def sampleNamedMetric(): NamedMetric = NamedMetric( + name = "test.counter", + description = "a test counter", + unit = "1", + data = MetricData.SumData( + List(SumDataPoint(Attributes.empty, 0L, 1000000L, 42L)) + ) + ) + + def spec: Spec[Any, Nothing] = suite("OtlpJsonExporter")( + suite("ExporterConfig")( + test("has correct default values") { + val config = ExporterConfig() + assertTrue( + config.endpoint == "http://localhost:4318" && + config.headers == Map.empty[String, String] && + config.timeout == java.time.Duration.ofSeconds(30) && + config.maxQueueSize == 2048 && + config.maxBatchSize == 512 && + config.flushIntervalMillis == 5000L + ) + }, + test("allows custom values") { + val config = ExporterConfig( + endpoint = "http://otel-collector:4318", + headers = Map("Authorization" -> "Bearer token"), + timeout = java.time.Duration.ofSeconds(60), + maxQueueSize = 4096, + maxBatchSize = 1024, + flushIntervalMillis = 10000L + ) + assertTrue( + config.endpoint == "http://otel-collector:4318" && + config.headers.get("Authorization").contains("Bearer token") && + config.timeout == java.time.Duration.ofSeconds(60) && + config.maxQueueSize == 4096 && + config.maxBatchSize == 1024 && + config.flushIntervalMillis == 10000L + ) + } + ), + suite("OtlpJsonTraceExporter")( + test("onStart is a no-op") { + val (sender, _) = mockHttpSender() + val exporter = new OtlpJsonTraceExporter( + ExporterConfig(flushIntervalMillis = 600000L), + testResource, + testScope, + sender, + PlatformExecutor.create() + ) + try { + // onStart should not throw or do anything + // We can't easily construct a Span trait instance so we test it doesn't error + assertTrue(true) + } finally exporter.shutdown() + }, + test("onEnd enqueues span and forceFlush sends via HttpSender") { + val (sender, captured) = mockHttpSender() + val exporter = new OtlpJsonTraceExporter( + ExporterConfig(flushIntervalMillis = 600000L), + testResource, + testScope, + sender, + PlatformExecutor.create() + ) + try { + exporter.onEnd(sampleSpanData()) + exporter.forceFlush() + val calls = captured.get() + assertTrue( + calls.length == 1 && + calls.head._1.endsWith("/v1/traces") && + calls.head._3.nonEmpty + ) + } finally exporter.shutdown() + }, + test("sends to correct endpoint URL") { + val (sender, captured) = mockHttpSender() + val exporter = new OtlpJsonTraceExporter( + ExporterConfig(endpoint = "http://my-collector:4318", flushIntervalMillis = 600000L), + testResource, + testScope, + sender, + PlatformExecutor.create() + ) + try { + exporter.onEnd(sampleSpanData()) + exporter.forceFlush() + val calls = captured.get() + assertTrue(calls.head._1 == "http://my-collector:4318/v1/traces") + } finally exporter.shutdown() + }, + test("sends correct Content-Type header") { + val (sender, captured) = mockHttpSender() + val exporter = new OtlpJsonTraceExporter( + ExporterConfig(flushIntervalMillis = 600000L), + testResource, + testScope, + sender, + PlatformExecutor.create() + ) + try { + exporter.onEnd(sampleSpanData()) + exporter.forceFlush() + val calls = captured.get() + assertTrue(calls.head._2.get("Content-Type").contains("application/json")) + } finally exporter.shutdown() + }, + test("includes custom headers from config") { + val (sender, captured) = mockHttpSender() + val exporter = new OtlpJsonTraceExporter( + ExporterConfig( + headers = Map("Authorization" -> "Bearer secret"), + flushIntervalMillis = 600000L + ), + testResource, + testScope, + sender, + PlatformExecutor.create() + ) + try { + exporter.onEnd(sampleSpanData()) + exporter.forceFlush() + val calls = captured.get() + assertTrue(calls.head._2.get("Authorization").contains("Bearer secret")) + } finally exporter.shutdown() + }, + test("encodes span data as valid OTLP JSON") { + val (sender, captured) = mockHttpSender() + val exporter = new OtlpJsonTraceExporter( + ExporterConfig(flushIntervalMillis = 600000L), + testResource, + testScope, + sender, + PlatformExecutor.create() + ) + try { + exporter.onEnd(sampleSpanData()) + exporter.forceFlush() + val body = new String(captured.get().head._3, "UTF-8") + assertTrue( + body.contains("resourceSpans") && + body.contains("test-span") && + body.contains("0af7651916cd43dd8448eb211c80319c") + ) + } finally exporter.shutdown() + }, + test("batches multiple spans together") { + val (sender, captured) = mockHttpSender() + val exporter = new OtlpJsonTraceExporter( + ExporterConfig(flushIntervalMillis = 600000L), + testResource, + testScope, + sender, + PlatformExecutor.create() + ) + try { + exporter.onEnd(sampleSpanData()) + exporter.onEnd(sampleSpanData().copy(name = "span-2")) + exporter.forceFlush() + val calls = captured.get() + val body = new String(calls.head._3, "UTF-8") + assertTrue( + calls.nonEmpty && + body.contains("test-span") && + body.contains("span-2") + ) + } finally exporter.shutdown() + } + ), + suite("OtlpJsonLogExporter")( + test("onEmit enqueues log and forceFlush sends via HttpSender") { + val (sender, captured) = mockHttpSender() + val exporter = new OtlpJsonLogExporter( + ExporterConfig(flushIntervalMillis = 600000L), + testResource, + testScope, + sender, + PlatformExecutor.create() + ) + try { + exporter.onEmit(sampleLogRecord()) + exporter.forceFlush() + val calls = captured.get() + assertTrue( + calls.length == 1 && + calls.head._1.endsWith("/v1/logs") && + calls.head._3.nonEmpty + ) + } finally exporter.shutdown() + }, + test("sends to correct endpoint URL") { + val (sender, captured) = mockHttpSender() + val exporter = new OtlpJsonLogExporter( + ExporterConfig(endpoint = "http://my-collector:4318", flushIntervalMillis = 600000L), + testResource, + testScope, + sender, + PlatformExecutor.create() + ) + try { + exporter.onEmit(sampleLogRecord()) + exporter.forceFlush() + val calls = captured.get() + assertTrue(calls.head._1 == "http://my-collector:4318/v1/logs") + } finally exporter.shutdown() + }, + test("encodes log data as valid OTLP JSON") { + val (sender, captured) = mockHttpSender() + val exporter = new OtlpJsonLogExporter( + ExporterConfig(flushIntervalMillis = 600000L), + testResource, + testScope, + sender, + PlatformExecutor.create() + ) + try { + exporter.onEmit(sampleLogRecord()) + exporter.forceFlush() + val body = new String(captured.get().head._3, "UTF-8") + assertTrue( + body.contains("resourceLogs") && + body.contains("test log message") && + body.contains("INFO") + ) + } finally exporter.shutdown() + }, + test("batches multiple logs together") { + val (sender, captured) = mockHttpSender() + val exporter = new OtlpJsonLogExporter( + ExporterConfig(flushIntervalMillis = 600000L), + testResource, + testScope, + sender, + PlatformExecutor.create() + ) + try { + exporter.onEmit(sampleLogRecord()) + exporter.onEmit(sampleLogRecord().copy(body = "second log")) + exporter.forceFlush() + val calls = captured.get() + val body = new String(calls.head._3, "UTF-8") + assertTrue( + calls.nonEmpty && + body.contains("test log message") && + body.contains("second log") + ) + } finally exporter.shutdown() + } + ), + suite("OtlpJsonMetricExporter")( + test("export collects from collector and sends via HttpSender") { + val (sender, captured) = mockHttpSender() + val metrics = List(sampleNamedMetric()) + val exporter = new OtlpJsonMetricExporter( + ExporterConfig(), + testResource, + testScope, + sender, + () => metrics + ) + try { + exporter.exportMetrics() + val calls = captured.get() + assertTrue( + calls.length == 1 && + calls.head._1.endsWith("/v1/metrics") && + calls.head._3.nonEmpty + ) + } finally exporter.shutdown() + }, + test("sends to correct endpoint URL") { + val (sender, captured) = mockHttpSender() + val exporter = new OtlpJsonMetricExporter( + ExporterConfig(endpoint = "http://my-collector:4318"), + testResource, + testScope, + sender, + () => List(sampleNamedMetric()) + ) + try { + exporter.exportMetrics() + val calls = captured.get() + assertTrue(calls.head._1 == "http://my-collector:4318/v1/metrics") + } finally exporter.shutdown() + }, + test("encodes metric data as valid OTLP JSON") { + val (sender, captured) = mockHttpSender() + val exporter = new OtlpJsonMetricExporter( + ExporterConfig(), + testResource, + testScope, + sender, + () => List(sampleNamedMetric()) + ) + try { + exporter.exportMetrics() + val body = new String(captured.get().head._3, "UTF-8") + assertTrue( + body.contains("resourceMetrics") && + body.contains("test.counter") && + body.contains("a test counter") + ) + } finally exporter.shutdown() + }, + test("does not send when no metrics collected") { + val (sender, captured) = mockHttpSender() + val exporter = new OtlpJsonMetricExporter( + ExporterConfig(), + testResource, + testScope, + sender, + () => Nil + ) + try { + exporter.exportMetrics() + val calls = captured.get() + assertTrue(calls.isEmpty) + } finally exporter.shutdown() + } + ), + suite("Response mapping")( + test("200 maps to Success") { + val result = OtlpJsonExporter.mapResponse(HttpResponse(200, Array.empty, Map.empty)) + assertTrue(result == ExportResult.Success) + }, + test("429 maps to retryable Failure") { + val result = OtlpJsonExporter.mapResponse(HttpResponse(429, Array.empty, Map.empty)) + result match { + case ExportResult.Failure(retryable, _) => assertTrue(retryable) + case _ => assertTrue(false) + } + }, + test("502 maps to retryable Failure") { + val result = OtlpJsonExporter.mapResponse(HttpResponse(502, Array.empty, Map.empty)) + result match { + case ExportResult.Failure(retryable, _) => assertTrue(retryable) + case _ => assertTrue(false) + } + }, + test("503 maps to retryable Failure") { + val result = OtlpJsonExporter.mapResponse(HttpResponse(503, Array.empty, Map.empty)) + result match { + case ExportResult.Failure(retryable, _) => assertTrue(retryable) + case _ => assertTrue(false) + } + }, + test("504 maps to retryable Failure") { + val result = OtlpJsonExporter.mapResponse(HttpResponse(504, Array.empty, Map.empty)) + result match { + case ExportResult.Failure(retryable, _) => assertTrue(retryable) + case _ => assertTrue(false) + } + }, + test("400 maps to non-retryable Failure") { + val result = OtlpJsonExporter.mapResponse(HttpResponse(400, Array.empty, Map.empty)) + result match { + case ExportResult.Failure(retryable, _) => assertTrue(!retryable) + case _ => assertTrue(false) + } + }, + test("500 maps to non-retryable Failure") { + val result = OtlpJsonExporter.mapResponse(HttpResponse(500, Array.empty, Map.empty)) + result match { + case ExportResult.Failure(retryable, _) => assertTrue(!retryable) + case _ => assertTrue(false) + } + }, + test("201 maps to Success") { + val result = OtlpJsonExporter.mapResponse(HttpResponse(201, Array.empty, Map.empty)) + assertTrue(result == ExportResult.Success) + } + ), + suite("Trace exporter with response mapping integration")( + test("retryable status code triggers retry in batch processor") { + val sender = mockHttpSenderWithStatus(429) + val exporter = new OtlpJsonTraceExporter( + ExporterConfig(flushIntervalMillis = 600000L), + testResource, + testScope, + sender, + PlatformExecutor.create() + ) + try { + // This will enqueue and flush — the 429 will cause retries in BatchProcessor + // but eventually drop after maxRetries. We just verify it doesn't hang. + exporter.onEnd(sampleSpanData()) + exporter.forceFlush() + assertTrue(true) + } finally exporter.shutdown() + } + ) + ) @@ TestAspect.sequential +} diff --git a/otel/jvm/src/test/scala/zio/blocks/otel/PlatformExecutorSpec.scala b/otel/jvm/src/test/scala/zio/blocks/otel/PlatformExecutorSpec.scala new file mode 100644 index 0000000000..4c9bf3a003 --- /dev/null +++ b/otel/jvm/src/test/scala/zio/blocks/otel/PlatformExecutorSpec.scala @@ -0,0 +1,83 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import zio.test._ + +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +object PlatformExecutorSpec extends ZIOSpecDefault { + + def spec: Spec[Any, Nothing] = suite("PlatformExecutor")( + test("create() returns a PlatformExecutor with a usable executor") { + val pe = PlatformExecutor.create() + val latch = new CountDownLatch(1) + val future = pe.schedule(0L, 100L, TimeUnit.MILLISECONDS)(new Runnable { + def run(): Unit = latch.countDown() + }) + try { + val ran = latch.await(2, TimeUnit.SECONDS) + assertTrue(pe.executor != null && ran) + } finally { + future.cancel(false) + pe.shutdown() + } + }, + test("schedule executes task and runs periodically") { + val pe = PlatformExecutor.create() + val counter = new AtomicInteger(0) + val latch = new CountDownLatch(3) + val future = pe.schedule(0L, 50L, TimeUnit.MILLISECONDS)(new Runnable { + def run(): Unit = { + counter.incrementAndGet() + latch.countDown() + } + }) + try { + val awaited = latch.await(2, TimeUnit.SECONDS) + assertTrue(awaited && counter.get() >= 3) + } finally { + future.cancel(false) + pe.shutdown() + } + }, + test("cancelled future stops further executions") { + val pe = PlatformExecutor.create() + val counter = new AtomicInteger(0) + val started = new CountDownLatch(1) + val future = pe.schedule(0L, 50L, TimeUnit.MILLISECONDS)(new Runnable { + def run(): Unit = { + counter.incrementAndGet() + started.countDown() + } + }) + try { + val ok = started.await(2, TimeUnit.SECONDS) + future.cancel(false) + val countAtCancel = counter.get() + Thread.sleep(200) + val countAfterWait = counter.get() + assertTrue(ok && countAfterWait <= countAtCancel + 1) + } finally pe.shutdown() + }, + test("hasLoom is consistent with ContextStorage.hasLoom") { + assertTrue(PlatformExecutor.hasLoom == ContextStorage.hasLoom) + } + ) @@ TestAspect.sequential +} diff --git a/otel/jvm/src/test/scala/zio/blocks/otel/SpanSpec.scala b/otel/jvm/src/test/scala/zio/blocks/otel/SpanSpec.scala new file mode 100644 index 0000000000..0d4f658162 --- /dev/null +++ b/otel/jvm/src/test/scala/zio/blocks/otel/SpanSpec.scala @@ -0,0 +1,304 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import zio.test._ + +object SpanSpec extends ZIOSpecDefault { + + def spec = suite("Span")( + suite("SpanEvent")( + test("stores name, timestamp, and attributes") { + val attrs = Attributes.of(AttributeKey.string("key"), "value") + val event = SpanEvent("test-event", 12345L, attrs) + assertTrue( + event.name == "test-event" && + event.timestampNanos == 12345L && + event.attributes.get(AttributeKey.string("key")).contains("value") + ) + }, + test("supports empty attributes") { + val event = SpanEvent("empty-event", 0L, Attributes.empty) + assertTrue(event.attributes.isEmpty) + } + ), + suite("SpanLink")( + test("stores span context and attributes") { + val ctx = SpanContext.create(TraceId.random, SpanId.random, TraceFlags.sampled, "", false) + val link = SpanLink(ctx, Attributes.empty) + assertTrue(link.spanContext.isValid && link.attributes.isEmpty) + } + ), + suite("SpanData")( + test("is an immutable snapshot") { + val ctx = SpanContext.create(TraceId.random, SpanId.random, TraceFlags.sampled, "", false) + val spanData = SpanData( + name = "test-span", + kind = SpanKind.Internal, + spanContext = ctx, + parentSpanContext = SpanContext.invalid, + startTimeNanos = 100L, + endTimeNanos = 200L, + attributes = Attributes.empty, + events = List.empty, + links = List.empty, + status = SpanStatus.Unset, + resource = Resource.empty, + instrumentationScope = InstrumentationScope("test") + ) + assertTrue( + spanData.name == "test-span" && + spanData.kind == SpanKind.Internal && + spanData.startTimeNanos == 100L && + spanData.endTimeNanos == 200L && + spanData.parentSpanContext == SpanContext.invalid + ) + } + ), + suite("RecordingSpan lifecycle")( + test("start → setAttribute → addEvent → setStatus → end") { + val span = SpanBuilder("lifecycle-span") + .setKind(SpanKind.Server) + .startSpan() + + val recording = span.isRecording + val spanName = span.name + val spanKind = span.kind + + span.setAttribute(AttributeKey.string("http.method"), "GET") + span.addEvent("processing-started") + span.setStatus(SpanStatus.Ok) + span.end() + + val data = span.toSpanData + assertTrue( + recording && + spanName == "lifecycle-span" && + spanKind == SpanKind.Server && + !span.isRecording && + data.attributes.get(AttributeKey.string("http.method")).contains("GET") && + data.events.size == 1 && + data.events.head.name == "processing-started" && + data.status == SpanStatus.Ok && + data.endTimeNanos > 0L + ) + }, + test("setAttribute convenience methods work") { + val span = SpanBuilder("convenience-span").startSpan() + + span.setAttribute("str-key", "str-val") + span.setAttribute("long-key", 42L) + span.setAttribute("double-key", 3.14) + span.setAttribute("bool-key", true) + span.end() + + val data = span.toSpanData + assertTrue( + data.attributes.get(AttributeKey.string("str-key")).contains("str-val") && + data.attributes.get(AttributeKey.long("long-key")).contains(42L) && + data.attributes.get(AttributeKey.double("double-key")).contains(3.14) && + data.attributes.get(AttributeKey.boolean("bool-key")).contains(true) + ) + }, + test("addEvent with attributes") { + val span = SpanBuilder("event-span").startSpan() + val attrs = Attributes.of(AttributeKey.string("detail"), "info") + span.addEvent("my-event", attrs) + span.end() + val data = span.toSpanData + assertTrue( + data.events.size == 1 && + data.events.head.name == "my-event" && + data.events.head.attributes.get(AttributeKey.string("detail")).contains("info") + ) + }, + test("addEvent with explicit timestamp") { + val span = SpanBuilder("ts-event-span").startSpan() + span.addEvent("timed-event", 99999L, Attributes.empty) + span.end() + val data = span.toSpanData + assertTrue( + data.events.head.timestampNanos == 99999L + ) + }, + test("end with explicit timestamp") { + val span = SpanBuilder("explicit-end").startSpan() + span.end(555555L) + val data = span.toSpanData + assertTrue(data.endTimeNanos == 555555L) + }, + test("end is idempotent — second end is ignored") { + val span = SpanBuilder("idempotent-end").startSpan() + span.end(100L) + span.end(200L) + val data = span.toSpanData + assertTrue(data.endTimeNanos == 100L) + }, + test("setAttribute after end is ignored") { + val span = SpanBuilder("post-end").startSpan() + span.setAttribute("before", "yes") + span.end() + span.setAttribute("after", "no") + val data = span.toSpanData + assertTrue( + data.attributes.get(AttributeKey.string("before")).contains("yes") && + data.attributes.get(AttributeKey.string("after")).isEmpty + ) + }, + test("spanContext is valid and has correct trace ID from parent") { + val parentCtx = SpanContext.create(TraceId.random, SpanId.random, TraceFlags.sampled, "", false) + val span = SpanBuilder("child-span") + .setParent(parentCtx) + .startSpan() + span.end() + assertTrue( + span.spanContext.isValid && + span.spanContext.traceId == parentCtx.traceId && + span.spanContext.spanId != parentCtx.spanId + ) + }, + test("spanContext gets new trace ID when no parent") { + val span = SpanBuilder("root-span").startSpan() + span.end() + assertTrue(span.spanContext.isValid && span.spanContext.traceId.isValid) + }, + test("toSpanData captures parent span context") { + val parentCtx = SpanContext.create(TraceId.random, SpanId.random, TraceFlags.sampled, "", false) + val span = SpanBuilder("child").setParent(parentCtx).startSpan() + span.end() + val data = span.toSpanData + assertTrue(data.parentSpanContext == parentCtx) + }, + test("toSpanData uses actual resource and instrumentationScope") { + val customResource = Resource.create( + Attributes.of(AttributeKey.string("service.name"), "test-svc") + ) + val customScope = InstrumentationScope("my-lib", Some("1.0")) + val span = SpanBuilder("resource-scope-span") + .setResource(customResource) + .setInstrumentationScope(customScope) + .startSpan() + span.end() + val data = span.toSpanData + assertTrue( + data.resource == customResource && + data.instrumentationScope == customScope + ) + } + ), + suite("Span.NoOp")( + test("spanContext returns invalid") { + assertTrue(Span.NoOp.spanContext == SpanContext.invalid) + }, + test("isRecording returns false") { + assertTrue(!Span.NoOp.isRecording) + }, + test("all mutating methods are no-ops") { + Span.NoOp.setAttribute(AttributeKey.string("k"), "v") + Span.NoOp.setAttribute("k", "v") + Span.NoOp.setAttribute("k", 1L) + Span.NoOp.setAttribute("k", 1.0) + Span.NoOp.setAttribute("k", true) + Span.NoOp.addEvent("e") + Span.NoOp.addEvent("e", Attributes.empty) + Span.NoOp.addEvent("e", 0L, Attributes.empty) + Span.NoOp.setStatus(SpanStatus.Ok) + Span.NoOp.end() + Span.NoOp.end(0L) + assertTrue(true) + }, + test("toSpanData returns empty snapshot") { + val data = Span.NoOp.toSpanData + assertTrue( + data.name == "" && + data.spanContext == SpanContext.invalid && + data.attributes.isEmpty && + data.events.isEmpty && + data.links.isEmpty + ) + }, + test("name returns empty string") { + assertTrue(Span.NoOp.name == "") + }, + test("kind returns Internal") { + val k: SpanKind = Span.NoOp.kind + assertTrue(k == SpanKind.Internal) + } + ), + suite("SpanBuilder")( + test("fluent API builds a span") { + val parentCtx = SpanContext.create(TraceId.random, SpanId.random, TraceFlags.sampled, "", false) + val link = SpanLink( + SpanContext.create(TraceId.random, SpanId.random, TraceFlags.sampled, "", false), + Attributes.empty + ) + val span = SpanBuilder("built-span") + .setKind(SpanKind.Client) + .setParent(parentCtx) + .setAttribute(AttributeKey.string("builder-attr"), "val") + .addLink(link) + .startSpan() + + span.end() + val data = span.toSpanData + assertTrue( + data.name == "built-span" && + data.kind == SpanKind.Client && + data.parentSpanContext == parentCtx && + data.spanContext.traceId == parentCtx.traceId && + data.attributes.get(AttributeKey.string("builder-attr")).contains("val") && + data.links.size == 1 + ) + }, + test("setStartTimestamp overrides default start time") { + val span = SpanBuilder("ts-span") + .setStartTimestamp(42L) + .startSpan() + span.end() + val data = span.toSpanData + assertTrue(data.startTimeNanos == 42L) + } + ), + suite("thread safety")( + test("concurrent setAttribute does not lose updates") { + import java.util.concurrent.{CountDownLatch, Executors} + val span = SpanBuilder("concurrent-span").startSpan() + val nThreads = 10 + val perThread = 10 + val executor = Executors.newFixedThreadPool(nThreads) + val latch = new CountDownLatch(nThreads) + + (0 until nThreads).foreach { t => + executor.submit(new Runnable { + def run(): Unit = { + (0 until perThread).foreach { i => + span.setAttribute(s"t$t-i$i", s"v$t-$i") + } + latch.countDown() + } + }) + } + latch.await() + executor.shutdown() + span.end() + + val data = span.toSpanData + assertTrue(data.attributes.size == nThreads * perThread) + } + ) + ) +} diff --git a/otel/shared/src/main/scala-2/zio/blocks/otel/LogMacros.scala b/otel/shared/src/main/scala-2/zio/blocks/otel/LogMacros.scala new file mode 100644 index 0000000000..81e76daf8a --- /dev/null +++ b/otel/shared/src/main/scala-2/zio/blocks/otel/LogMacros.scala @@ -0,0 +1,128 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import scala.reflect.macros.blackbox + +private[otel] object LogMacros { + + def traceImpl(c: blackbox.Context)(message: c.Expr[String], enrichments: c.Expr[Any]*): c.Expr[Unit] = + logImpl(c)(message, enrichments, c.universe.reify(Severity.Trace)) + + def debugImpl(c: blackbox.Context)(message: c.Expr[String], enrichments: c.Expr[Any]*): c.Expr[Unit] = + logImpl(c)(message, enrichments, c.universe.reify(Severity.Debug)) + + def infoImpl(c: blackbox.Context)(message: c.Expr[String], enrichments: c.Expr[Any]*): c.Expr[Unit] = + logImpl(c)(message, enrichments, c.universe.reify(Severity.Info)) + + def warnImpl(c: blackbox.Context)(message: c.Expr[String], enrichments: c.Expr[Any]*): c.Expr[Unit] = + logImpl(c)(message, enrichments, c.universe.reify(Severity.Warn)) + + def errorImpl(c: blackbox.Context)(message: c.Expr[String], enrichments: c.Expr[Any]*): c.Expr[Unit] = + logImpl(c)(message, enrichments, c.universe.reify(Severity.Error)) + + def fatalImpl(c: blackbox.Context)(message: c.Expr[String], enrichments: c.Expr[Any]*): c.Expr[Unit] = + logImpl(c)(message, enrichments, c.universe.reify(Severity.Fatal)) + + private def logImpl(c: blackbox.Context)( + message: c.Expr[String], + enrichments: Seq[c.Expr[Any]], + severity: c.Expr[Severity] + ): c.Expr[Unit] = { + import c.universe._ + + val pos = c.enclosingPosition + val filePath = Literal(Constant(pos.source.path)) + val lineNo = Literal(Constant(pos.line)) + val methodName = Literal(Constant(findEnclosingMethod(c))) + val namespace = Literal(Constant(findEnclosingClass(c))) + val self = c.prefix.tree + + if (enrichments.isEmpty) { + c.Expr[Unit](q""" + { + val state = _root_.zio.blocks.otel.GlobalLogState.get() + if (state != null && $severity.number >= state.effectiveLevel($namespace)) { + $self.emit( + $severity, + $message, + _root_.zio.blocks.otel.SourceLocation($filePath, $namespace, $methodName, $lineNo) + ) + } + } + """) + } else { + val logEnrichmentTpe = typeOf[LogEnrichment[_]].typeConstructor + + var recordExpr: Tree = q""" + $self.baseRecord( + $severity, + $message, + _root_.zio.blocks.otel.SourceLocation($filePath, $namespace, $methodName, $lineNo) + ) + """ + + for (enrichmentExpr <- enrichments) { + val argType = enrichmentExpr.actualType.dealias + val enrichmentType = appliedType(logEnrichmentTpe, List(argType)) + val implicitInstance = c.inferImplicitValue(enrichmentType) + + if (implicitInstance == EmptyTree) { + c.abort( + enrichmentExpr.tree.pos, + s"No LogEnrichment instance found for type ${argType}. " + + s"Provide an implicit LogEnrichment[${argType}] instance." + ) + } + + recordExpr = q"$implicitInstance.enrich($recordExpr, ${enrichmentExpr.tree})" + } + + c.Expr[Unit](q""" + { + val state = _root_.zio.blocks.otel.GlobalLogState.get() + if (state != null && $severity.number >= state.effectiveLevel($namespace)) { + val record = $recordExpr + state.logger.emit(record) + } + } + """) + } + } + + private def findEnclosingMethod(c: blackbox.Context): String = { + var owner = c.internal.enclosingOwner + while (owner != c.universe.NoSymbol) { + if (owner.isMethod && !owner.isConstructor) { + return owner.name.decodedName.toString + } + owner = owner.owner + } + "" + } + + private def findEnclosingClass(c: blackbox.Context): String = { + var owner = c.internal.enclosingOwner + while (owner != c.universe.NoSymbol) { + if (owner.isClass) { + return owner.fullName + } + owner = owner.owner + } + "" + } +} diff --git a/otel/shared/src/main/scala-2/zio/blocks/otel/LogVersionSpecific.scala b/otel/shared/src/main/scala-2/zio/blocks/otel/LogVersionSpecific.scala new file mode 100644 index 0000000000..6ac262329c --- /dev/null +++ b/otel/shared/src/main/scala-2/zio/blocks/otel/LogVersionSpecific.scala @@ -0,0 +1,34 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import scala.language.experimental.macros + +private[otel] trait LogVersionSpecific { self: log.type => + + def trace(message: String, enrichments: Any*): Unit = macro LogMacros.traceImpl + + def debug(message: String, enrichments: Any*): Unit = macro LogMacros.debugImpl + + def info(message: String, enrichments: Any*): Unit = macro LogMacros.infoImpl + + def warn(message: String, enrichments: Any*): Unit = macro LogMacros.warnImpl + + def error(message: String, enrichments: Any*): Unit = macro LogMacros.errorImpl + + def fatal(message: String, enrichments: Any*): Unit = macro LogMacros.fatalImpl +} diff --git a/otel/shared/src/main/scala-3/zio/blocks/otel/LogMacros.scala b/otel/shared/src/main/scala-3/zio/blocks/otel/LogMacros.scala new file mode 100644 index 0000000000..ebfb6ef122 --- /dev/null +++ b/otel/shared/src/main/scala-3/zio/blocks/otel/LogMacros.scala @@ -0,0 +1,116 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import scala.quoted.* + +private[otel] object LogMacros { + + def logImpl( + self: Expr[log.type], + message: Expr[String], + enrichments: Expr[Seq[Any]], + severity: Expr[Severity] + )(using Quotes): Expr[Unit] = { + import quotes.reflect.* + + val pos = Position.ofMacroExpansion + + val filePath = Expr(pos.sourceFile.path) + val lineNumber = Expr(pos.startLine + 1) + + val methodName = Expr(findEnclosingMethod(Symbol.spliceOwner)) + val namespace = Expr(findEnclosingClass(Symbol.spliceOwner)) + + enrichments match { + case Varargs(enrichmentExprs) => + if (enrichmentExprs.isEmpty) { + // No enrichments — use the original emit path for zero overhead + '{ + val state = GlobalLogState.get() + if (state != null && $severity.number >= state.effectiveLevel($namespace)) { + $self.emit( + $severity, + $message, + SourceLocation($filePath, $namespace, $methodName, $lineNumber) + ) + } + } + } else { + // Build unrolled enrichment chain — no List/Seq allocation + val enrichmentChain: Expr[LogRecord] => Expr[LogRecord] = + enrichmentExprs.foldLeft(identity[Expr[LogRecord]]) { (chain, enrichmentExpr) => + val tpe = enrichmentExpr.asTerm.tpe.widen + tpe.asType match { + case '[t] => + Expr.summon[LogEnrichment[t]] match { + case Some(inst) => + (prev: Expr[LogRecord]) => '{ $inst.enrich(${ chain(prev) }, ${ enrichmentExpr.asExprOf[t] }) } + case None => + report.errorAndAbort( + s"No LogEnrichment instance found for type ${Type.show[t]}. " + + s"Provide an implicit LogEnrichment[${Type.show[t]}] instance.", + enrichmentExpr.asTerm.pos + ) + } + } + } + + '{ + val state = GlobalLogState.get() + if (state != null && $severity.number >= state.effectiveLevel($namespace)) { + val record = $self.baseRecord( + $severity, + $message, + SourceLocation($filePath, $namespace, $methodName, $lineNumber) + ) + state.logger.emit(${ enrichmentChain('record) }) + } + } + } + + case _ => + report.errorAndAbort( + "log methods require explicit arguments, not `args: _*` syntax" + ) + } + } + + private def findEnclosingMethod(using Quotes)(sym: quotes.reflect.Symbol): String = { + import quotes.reflect.* + var current = sym + while (current != Symbol.noSymbol) { + if (current.isDefDef && !current.name.startsWith("$") && current.name != "") { + return current.name + } + current = current.owner + } + "" + } + + private def findEnclosingClass(using Quotes)(sym: quotes.reflect.Symbol): String = { + import quotes.reflect.* + var current = sym + while (current != Symbol.noSymbol) { + if (current.isClassDef) { + return current.fullName + } + current = current.owner + } + "" + } +} diff --git a/otel/shared/src/main/scala-3/zio/blocks/otel/LogVersionSpecific.scala b/otel/shared/src/main/scala-3/zio/blocks/otel/LogVersionSpecific.scala new file mode 100644 index 0000000000..bcb252da8e --- /dev/null +++ b/otel/shared/src/main/scala-3/zio/blocks/otel/LogVersionSpecific.scala @@ -0,0 +1,38 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +private[otel] trait LogVersionSpecific { self: log.type => + + inline def trace(inline message: String, inline enrichments: Any*): Unit = + ${ LogMacros.logImpl('self, 'message, 'enrichments, '{ Severity.Trace }) } + + inline def debug(inline message: String, inline enrichments: Any*): Unit = + ${ LogMacros.logImpl('self, 'message, 'enrichments, '{ Severity.Debug }) } + + inline def info(inline message: String, inline enrichments: Any*): Unit = + ${ LogMacros.logImpl('self, 'message, 'enrichments, '{ Severity.Info }) } + + inline def warn(inline message: String, inline enrichments: Any*): Unit = + ${ LogMacros.logImpl('self, 'message, 'enrichments, '{ Severity.Warn }) } + + inline def error(inline message: String, inline enrichments: Any*): Unit = + ${ LogMacros.logImpl('self, 'message, 'enrichments, '{ Severity.Error }) } + + inline def fatal(inline message: String, inline enrichments: Any*): Unit = + ${ LogMacros.logImpl('self, 'message, 'enrichments, '{ Severity.Fatal }) } +} diff --git a/otel/shared/src/main/scala/zio/blocks/otel/AttributeKey.scala b/otel/shared/src/main/scala/zio/blocks/otel/AttributeKey.scala new file mode 100644 index 0000000000..4583ec4bdc --- /dev/null +++ b/otel/shared/src/main/scala/zio/blocks/otel/AttributeKey.scala @@ -0,0 +1,115 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +/** + * Type-safe key for attribute storage. Each key is bound to a specific value + * type A. + * + * AttributeKey instances are created via factory methods on the companion + * object, which enforce type consistency between key and value. + * + * @tparam A + * The type of values this key maps to + */ +sealed trait AttributeKey[A] { + def name: String + def `type`: AttributeType +} + +object AttributeKey { + + /** + * Creates a typed key for String values. + */ + def string(name: String): AttributeKey[String] = + StringKey(name) + + /** + * Creates a typed key for Boolean values. + */ + def boolean(name: String): AttributeKey[Boolean] = + BooleanKey(name) + + /** + * Creates a typed key for Long values. + */ + def long(name: String): AttributeKey[Long] = + LongKey(name) + + /** + * Creates a typed key for Double values. + */ + def double(name: String): AttributeKey[Double] = + DoubleKey(name) + + /** + * Creates a typed key for Seq[String] values. + */ + def stringSeq(name: String): AttributeKey[Seq[String]] = + StringSeqKey(name) + + /** + * Creates a typed key for Seq[Long] values. + */ + def longSeq(name: String): AttributeKey[Seq[Long]] = + LongSeqKey(name) + + /** + * Creates a typed key for Seq[Double] values. + */ + def doubleSeq(name: String): AttributeKey[Seq[Double]] = + DoubleSeqKey(name) + + /** + * Creates a typed key for Seq[Boolean] values. + */ + def booleanSeq(name: String): AttributeKey[Seq[Boolean]] = + BooleanSeqKey(name) + + private final case class StringKey(name: String) extends AttributeKey[String] { + def `type`: AttributeType = AttributeType.StringType + } + + private final case class BooleanKey(name: String) extends AttributeKey[Boolean] { + def `type`: AttributeType = AttributeType.BooleanType + } + + private final case class LongKey(name: String) extends AttributeKey[Long] { + def `type`: AttributeType = AttributeType.LongType + } + + private final case class DoubleKey(name: String) extends AttributeKey[Double] { + def `type`: AttributeType = AttributeType.DoubleType + } + + private final case class StringSeqKey(name: String) extends AttributeKey[Seq[String]] { + def `type`: AttributeType = AttributeType.StringSeqType + } + + private final case class LongSeqKey(name: String) extends AttributeKey[Seq[Long]] { + def `type`: AttributeType = AttributeType.LongSeqType + } + + private final case class DoubleSeqKey(name: String) extends AttributeKey[Seq[Double]] { + def `type`: AttributeType = AttributeType.DoubleSeqType + } + + private final case class BooleanSeqKey(name: String) extends AttributeKey[Seq[Boolean]] { + def `type`: AttributeType = AttributeType.BooleanSeqType + } +} diff --git a/otel/shared/src/main/scala/zio/blocks/otel/AttributeType.scala b/otel/shared/src/main/scala/zio/blocks/otel/AttributeType.scala new file mode 100644 index 0000000000..043a7bd95d --- /dev/null +++ b/otel/shared/src/main/scala/zio/blocks/otel/AttributeType.scala @@ -0,0 +1,34 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +/** + * Discriminator for AttributeKey type safety. Determines the expected value + * type for an attribute. + */ +sealed trait AttributeType + +object AttributeType { + case object StringType extends AttributeType + case object BooleanType extends AttributeType + case object LongType extends AttributeType + case object DoubleType extends AttributeType + case object StringSeqType extends AttributeType + case object LongSeqType extends AttributeType + case object DoubleSeqType extends AttributeType + case object BooleanSeqType extends AttributeType +} diff --git a/otel/shared/src/main/scala/zio/blocks/otel/AttributeValue.scala b/otel/shared/src/main/scala/zio/blocks/otel/AttributeValue.scala new file mode 100644 index 0000000000..66e9acb94d --- /dev/null +++ b/otel/shared/src/main/scala/zio/blocks/otel/AttributeValue.scala @@ -0,0 +1,38 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +/** + * Represents the value of an attribute. Each variant corresponds to an + * AttributeType discriminator. + * + * This sealed ADT mirrors OpenTelemetry's AnyValue protobuf message, which + * supports strings, booleans, 64-bit integers, doubles, and arrays of these + * types. + */ +sealed trait AttributeValue + +object AttributeValue { + final case class StringValue(value: String) extends AttributeValue + final case class BooleanValue(value: Boolean) extends AttributeValue + final case class LongValue(value: Long) extends AttributeValue + final case class DoubleValue(value: Double) extends AttributeValue + final case class StringSeqValue(value: Seq[String]) extends AttributeValue + final case class LongSeqValue(value: Seq[Long]) extends AttributeValue + final case class DoubleSeqValue(value: Seq[Double]) extends AttributeValue + final case class BooleanSeqValue(value: Seq[Boolean]) extends AttributeValue +} diff --git a/otel/shared/src/main/scala/zio/blocks/otel/Attributes.scala b/otel/shared/src/main/scala/zio/blocks/otel/Attributes.scala new file mode 100644 index 0000000000..dcaf681a95 --- /dev/null +++ b/otel/shared/src/main/scala/zio/blocks/otel/Attributes.scala @@ -0,0 +1,291 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +/** + * An immutable collection of typed key-value attribute pairs. + * + * Backed by an array of (String, AttributeValue) tuples for efficient storage + * of small attribute sets (typical spans have <10 attributes). + * + * Attributes are accessed via typed keys (AttributeKey[A]) for compile-time + * type safety. + */ +final class Attributes private ( + private val entries: Array[(String, AttributeValue)] +) { + + /** + * Number of attributes in this collection. + */ + def size: Int = entries.length + + /** + * True if this collection contains no attributes. + */ + def isEmpty: Boolean = entries.length == 0 + + /** + * Retrieves a typed attribute by key, returning None if not found. + * + * @tparam A + * The expected type of the value + * @param key + * The typed key to look up + * @return + * Some(value) if found, None otherwise + */ + def get[A](key: AttributeKey[A]): Option[A] = { + var i = entries.length - 1 + while (i >= 0) { + val (k, v) = entries(i) + if (k == key.name) { + return Some(valueToType(v).asInstanceOf[A]) + } + i -= 1 + } + None + } + + /** + * Invokes a function for each attribute. + */ + def foreach(f: (String, AttributeValue) => Unit): Unit = { + var i = 0 + while (i < entries.length) { + val (k, v) = entries(i) + f(k, v) + i += 1 + } + } + + /** + * Merges this Attributes with another, with values from `other` taking + * precedence on conflicts. + */ + def ++(other: Attributes): Attributes = + if (other.isEmpty) { + this + } else if (this.isEmpty) { + other + } else { + var result = this + var i = 0 + while (i < other.entries.length) { + val (k, v) = other.entries(i) + result = result.updated(k, v) + i += 1 + } + result + } + + /** + * Converts this Attributes to a Map with string keys and AttributeValue + * values. + */ + def toMap: Map[String, AttributeValue] = { + var map = Map.empty[String, AttributeValue] + var i = 0 + while (i < entries.length) { + val (k, v) = entries(i) + map = map + ((k, v)) + i += 1 + } + map + } + + /** + * Internal: updates or adds an attribute by key name. + */ + private def updated(key: String, value: AttributeValue): Attributes = { + var found = false + var i = 0 + while (i < entries.length && !found) { + if (entries(i)._1 == key) found = true + i += 1 + } + if (found) { + val newEntries = new Array[(String, AttributeValue)](entries.length) + var j = 0 + var k = 0 + while (j < entries.length) { + if (entries(j)._1 != key) { + newEntries(k) = entries(j) + k += 1 + } + j += 1 + } + newEntries(k) = (key, value) + new Attributes(newEntries) + } else { + val newEntries = new Array[(String, AttributeValue)](entries.length + 1) + System.arraycopy(entries, 0, newEntries, 0, entries.length) + newEntries(entries.length) = (key, value) + new Attributes(newEntries) + } + } + + /** + * Internal: extracts the typed value from an AttributeValue. + */ + private def valueToType(v: AttributeValue): Any = v match { + case AttributeValue.StringValue(s) => s + case AttributeValue.BooleanValue(b) => b + case AttributeValue.LongValue(l) => l + case AttributeValue.DoubleValue(d) => d + case AttributeValue.StringSeqValue(seq) => seq + case AttributeValue.LongSeqValue(seq) => seq + case AttributeValue.DoubleSeqValue(seq) => seq + case AttributeValue.BooleanSeqValue(seq) => seq + } +} + +object Attributes { + + /** + * An empty Attributes collection. + */ + val empty: Attributes = new Attributes(Array.empty) + + /** + * Creates an Attributes collection with a single typed attribute. + */ + def of[A](key: AttributeKey[A], value: A): Attributes = { + val attrValue = typeToValue(value) + new Attributes(Array((key.name, attrValue))) + } + + /** + * Returns a mutable builder for constructing Attributes incrementally. + */ + def builder: AttributesBuilder = + new AttributesBuilder() + + /** + * Predefined attribute key for service name. + */ + val ServiceName: AttributeKey[String] = AttributeKey.string("service.name") + + /** + * Predefined attribute key for service version. + */ + val ServiceVersion: AttributeKey[String] = AttributeKey.string("service.version") + + /** + * Internal: converts a typed value to an AttributeValue. + */ + private def typeToValue[A](value: A): AttributeValue = + value match { + case s: String => AttributeValue.StringValue(s) + case b: Boolean => AttributeValue.BooleanValue(b) + case l: Long => AttributeValue.LongValue(l) + case d: Double => AttributeValue.DoubleValue(d) + case seq: Seq[_] => + seq.headOption match { + case Some(_: String) => AttributeValue.StringSeqValue(seq.asInstanceOf[Seq[String]]) + case Some(_: Long) => AttributeValue.LongSeqValue(seq.asInstanceOf[Seq[Long]]) + case Some(_: Double) => AttributeValue.DoubleSeqValue(seq.asInstanceOf[Seq[Double]]) + case Some(_: Boolean) => AttributeValue.BooleanSeqValue(seq.asInstanceOf[Seq[Boolean]]) + case _ => AttributeValue.StringSeqValue(Seq.empty) + } + case _ => AttributeValue.StringValue(value.toString) + } + + /** + * Mutable builder for Attributes. + */ + class AttributesBuilder private[Attributes] () { + private var entries: Array[(String, AttributeValue)] = Array.empty + + /** + * Adds or updates a typed attribute. + */ + def put[A](key: AttributeKey[A], value: A): AttributesBuilder = { + putInternal(key.name, typeToValue(value)) + this + } + + /** + * Adds or updates a string attribute. + */ + def put(key: String, value: String): AttributesBuilder = { + putInternal(key, AttributeValue.StringValue(value)) + this + } + + /** + * Adds or updates a long attribute. + */ + def put(key: String, value: Long): AttributesBuilder = { + putInternal(key, AttributeValue.LongValue(value)) + this + } + + /** + * Adds or updates a double attribute. + */ + def put(key: String, value: Double): AttributesBuilder = { + putInternal(key, AttributeValue.DoubleValue(value)) + this + } + + /** + * Adds or updates a boolean attribute. + */ + def put(key: String, value: Boolean): AttributesBuilder = { + putInternal(key, AttributeValue.BooleanValue(value)) + this + } + + /** + * Builds and returns the immutable Attributes. + */ + def build: Attributes = + new Attributes(entries) + + /** + * Internal: updates or adds an entry by key. + */ + private def putInternal(key: String, value: AttributeValue): Unit = { + var found = false + var i = 0 + while (i < entries.length && !found) { + if (entries(i)._1 == key) found = true + i += 1 + } + if (found) { + var j = 0 + var k = 0 + val newEntries = new Array[(String, AttributeValue)](entries.length) + while (j < entries.length) { + if (entries(j)._1 != key) { + newEntries(k) = entries(j) + k += 1 + } + j += 1 + } + newEntries(k) = (key, value) + entries = newEntries + } else { + val newEntries = new Array[(String, AttributeValue)](entries.length + 1) + System.arraycopy(entries, 0, newEntries, 0, entries.length) + newEntries(entries.length) = (key, value) + entries = newEntries + } + } + } +} diff --git a/otel/shared/src/main/scala/zio/blocks/otel/B3Propagator.scala b/otel/shared/src/main/scala/zio/blocks/otel/B3Propagator.scala new file mode 100644 index 0000000000..009840eb73 --- /dev/null +++ b/otel/shared/src/main/scala/zio/blocks/otel/B3Propagator.scala @@ -0,0 +1,126 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +/** + * B3 propagation format (Zipkin's trace context standard). + * + * Provides both single-header (`b3`) and multi-header (`X-B3-*`) variants. + * + * @see + * https://github.com/openzipkin/b3-propagation + */ +object B3Propagator { + + /** + * Returns a B3 single-header propagator. + * + * Single-header format: `{traceId}-{spanId}-{sampling}-{parentSpanId}` + */ + val single: Propagator = B3SinglePropagator + + /** + * Returns a B3 multi-header propagator. + * + * Uses `X-B3-TraceId`, `X-B3-SpanId`, `X-B3-Sampled`, `X-B3-ParentSpanId`, + * and `X-B3-Flags` headers. + */ + val multi: Propagator = B3MultiPropagator + + /** + * Normalizes a trace ID hex string. Accepts 16 or 32 hex characters. 16-char + * IDs are left-padded with zeros to 32 characters. + */ + private[otel] def normalizeTraceId(hex: String): Option[TraceId] = { + val lower = hex.toLowerCase + val padded = + if (lower.length == 16) "0000000000000000" + lower + else if (lower.length == 32) lower + else return None + TraceId.fromHex(padded) + } + + private object B3SinglePropagator extends Propagator { + private val B3Header = "b3" + + override val fields: Seq[String] = Seq(B3Header) + + override def extract[C](carrier: C, getter: (C, String) => Option[String]): Option[SpanContext] = + for { + raw <- getter(carrier, B3Header) + value = raw.trim + _ <- if (value.isEmpty || value == "0") None else Some(()) + parts = value.split('-') + _ <- if (parts.length >= 2) Some(()) else None + traceId <- normalizeTraceId(parts(0)) + _ <- if (traceId.isValid) Some(()) else None + spanId <- SpanId.fromHex(parts(1).toLowerCase) + _ <- if (spanId.isValid) Some(()) else None + } yield { + val flags = if (parts.length >= 3) { + parts(2) match { + case "1" | "d" => TraceFlags.sampled + case "0" => TraceFlags.none + case _ => TraceFlags.none + } + } else TraceFlags.none + SpanContext.create(traceId, spanId, flags, traceState = "", isRemote = true) + } + + override def inject[C](spanContext: SpanContext, carrier: C, setter: (C, String, String) => C): C = + if (!spanContext.isValid) carrier + else { + val sampling = if (spanContext.traceFlags.isSampled) "1" else "0" + val value = s"${spanContext.traceId.toHex}-${spanContext.spanId.toHex}-$sampling" + setter(carrier, B3Header, value) + } + } + + private object B3MultiPropagator extends Propagator { + private val TraceIdHeader = "X-B3-TraceId" + private val SpanIdHeader = "X-B3-SpanId" + private val SampledHeader = "X-B3-Sampled" + private val ParentSpanIdHeader = "X-B3-ParentSpanId" + private val FlagsHeader = "X-B3-Flags" + + override val fields: Seq[String] = Seq(TraceIdHeader, SpanIdHeader, SampledHeader, ParentSpanIdHeader, FlagsHeader) + + override def extract[C](carrier: C, getter: (C, String) => Option[String]): Option[SpanContext] = + for { + traceIdRaw <- getter(carrier, TraceIdHeader) + traceId <- normalizeTraceId(traceIdRaw.trim) + _ <- if (traceId.isValid) Some(()) else None + spanIdRaw <- getter(carrier, SpanIdHeader) + spanId <- SpanId.fromHex(spanIdRaw.trim.toLowerCase) + _ <- if (spanId.isValid) Some(()) else None + } yield { + val debug = getter(carrier, FlagsHeader).exists(_.trim == "1") + val sampled = debug || getter(carrier, SampledHeader).exists(_.trim == "1") + val flags = if (sampled) TraceFlags.sampled else TraceFlags.none + SpanContext.create(traceId, spanId, flags, traceState = "", isRemote = true) + } + + override def inject[C](spanContext: SpanContext, carrier: C, setter: (C, String, String) => C): C = + if (!spanContext.isValid) carrier + else { + val sampling = if (spanContext.traceFlags.isSampled) "1" else "0" + val c1 = setter(carrier, TraceIdHeader, spanContext.traceId.toHex) + val c2 = setter(c1, SpanIdHeader, spanContext.spanId.toHex) + setter(c2, SampledHeader, sampling) + } + } +} diff --git a/otel/shared/src/main/scala/zio/blocks/otel/GlobalLogState.scala b/otel/shared/src/main/scala/zio/blocks/otel/GlobalLogState.scala new file mode 100644 index 0000000000..6f74ab7b73 --- /dev/null +++ b/otel/shared/src/main/scala/zio/blocks/otel/GlobalLogState.scala @@ -0,0 +1,81 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import java.util.concurrent.atomic.AtomicReference + +final class LogState( + val logger: Logger, + val minSeverity: Int, + val levelOverrides: Map[String, Int] +) { + def effectiveLevel(className: String): Int = { + if (levelOverrides.isEmpty) return minSeverity + var bestLen = -1 + var bestLevel = minSeverity + val iter = levelOverrides.iterator + while (iter.hasNext) { + val (prefix, level) = iter.next() + if (className.startsWith(prefix) && prefix.length > bestLen) { + bestLen = prefix.length + bestLevel = level + } + } + bestLevel + } +} + +object GlobalLogState { + private val ref: AtomicReference[LogState] = new AtomicReference[LogState](null) + + def get(): LogState = ref.get() + + def set(state: LogState): Unit = ref.set(state) + + def install(logger: Logger, minSeverity: Severity = Severity.Trace): Unit = + ref.set(new LogState(logger, minSeverity.number, Map.empty)) + + def setLevel(prefix: String, severity: Severity): Unit = { + var current = ref.get() + while (current != null) { + val updated = + new LogState(current.logger, current.minSeverity, current.levelOverrides + (prefix -> severity.number)) + if (ref.compareAndSet(current, updated)) return + current = ref.get() + } + } + + def clearLevel(prefix: String): Unit = { + var current = ref.get() + while (current != null) { + val updated = new LogState(current.logger, current.minSeverity, current.levelOverrides - prefix) + if (ref.compareAndSet(current, updated)) return + current = ref.get() + } + } + + def clearAllLevels(): Unit = { + var current = ref.get() + while (current != null) { + val updated = new LogState(current.logger, current.minSeverity, Map.empty) + if (ref.compareAndSet(current, updated)) return + current = ref.get() + } + } + + def uninstall(): Unit = ref.set(null) +} diff --git a/otel/shared/src/main/scala/zio/blocks/otel/InstrumentationScope.scala b/otel/shared/src/main/scala/zio/blocks/otel/InstrumentationScope.scala new file mode 100644 index 0000000000..ebc448d4e6 --- /dev/null +++ b/otel/shared/src/main/scala/zio/blocks/otel/InstrumentationScope.scala @@ -0,0 +1,30 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +/** + * Identifies the library/component that produced telemetry. + * + * An InstrumentationScope represents a logical grouping of telemetry signals + * from a specific library or instrumentation. It typically includes the library + * name and optional version information. + */ +final case class InstrumentationScope( + name: String, + version: Option[String] = None, + attributes: Attributes = Attributes.empty +) diff --git a/otel/shared/src/main/scala/zio/blocks/otel/LogEnrichment.scala b/otel/shared/src/main/scala/zio/blocks/otel/LogEnrichment.scala new file mode 100644 index 0000000000..3832d5e01d --- /dev/null +++ b/otel/shared/src/main/scala/zio/blocks/otel/LogEnrichment.scala @@ -0,0 +1,81 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +trait LogEnrichment[A] { + def enrich(record: LogRecord, value: A): LogRecord +} + +object LogEnrichment { + + implicit val stringEnrichment: LogEnrichment[String] = new LogEnrichment[String] { + def enrich(record: LogRecord, value: String): LogRecord = + record.copy(body = value) + } + + implicit val throwableEnrichment: LogEnrichment[Throwable] = new LogEnrichment[Throwable] { + def enrich(record: LogRecord, value: Throwable): LogRecord = { + val builder = Attributes.builder + builder.put("exception.type", value.getClass.getName) + builder.put("exception.message", if (value.getMessage != null) value.getMessage else "") + val sw = new java.io.StringWriter() + value.printStackTrace(new java.io.PrintWriter(sw)) + builder.put("exception.stacktrace", sw.toString) + record.copy(attributes = record.attributes ++ builder.build) + } + } + + implicit val stringStringEnrichment: LogEnrichment[(String, String)] = + new LogEnrichment[(String, String)] { + def enrich(record: LogRecord, value: (String, String)): LogRecord = + record.copy(attributes = record.attributes ++ Attributes.of(AttributeKey.string(value._1), value._2)) + } + + implicit val stringLongEnrichment: LogEnrichment[(String, Long)] = + new LogEnrichment[(String, Long)] { + def enrich(record: LogRecord, value: (String, Long)): LogRecord = + record.copy(attributes = record.attributes ++ Attributes.of(AttributeKey.long(value._1), value._2)) + } + + implicit val stringDoubleEnrichment: LogEnrichment[(String, Double)] = + new LogEnrichment[(String, Double)] { + def enrich(record: LogRecord, value: (String, Double)): LogRecord = + record.copy(attributes = record.attributes ++ Attributes.of(AttributeKey.double(value._1), value._2)) + } + + implicit val stringBooleanEnrichment: LogEnrichment[(String, Boolean)] = + new LogEnrichment[(String, Boolean)] { + def enrich(record: LogRecord, value: (String, Boolean)): LogRecord = + record.copy(attributes = record.attributes ++ Attributes.of(AttributeKey.boolean(value._1), value._2)) + } + + implicit val stringIntEnrichment: LogEnrichment[(String, Int)] = + new LogEnrichment[(String, Int)] { + def enrich(record: LogRecord, value: (String, Int)): LogRecord = + record.copy(attributes = record.attributes ++ Attributes.of(AttributeKey.long(value._1), value._2.toLong)) + } + + implicit val attributesEnrichment: LogEnrichment[Attributes] = new LogEnrichment[Attributes] { + def enrich(record: LogRecord, value: Attributes): LogRecord = + record.copy(attributes = record.attributes ++ value) + } + + implicit val severityEnrichment: LogEnrichment[Severity] = new LogEnrichment[Severity] { + def enrich(record: LogRecord, value: Severity): LogRecord = + record.copy(severity = value, severityText = value.text) + } +} diff --git a/otel/shared/src/main/scala/zio/blocks/otel/LogRecord.scala b/otel/shared/src/main/scala/zio/blocks/otel/LogRecord.scala new file mode 100644 index 0000000000..bbdfbf7b9f --- /dev/null +++ b/otel/shared/src/main/scala/zio/blocks/otel/LogRecord.scala @@ -0,0 +1,236 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +/** + * Represents an immutable log record with trace correlation support. + * + * A LogRecord contains a log message, its severity level, timing information, + * and optional trace context (traceId, spanId, traceFlags) for distributed + * tracing correlation. + * + * @param timestampNanos + * The time when the event occurred, in nanoseconds since Unix epoch + * @param observedTimestampNanos + * The time when the event was observed/recorded, in nanoseconds since Unix + * epoch + * @param severity + * The severity level of the log record + * @param severityText + * The text representation of the severity level + * @param body + * The log message body + * @param attributes + * Additional attributes/metadata associated with the log record + * @param traceId + * Optional trace ID for correlation with distributed traces + * @param spanId + * Optional span ID for correlation with distributed traces + * @param traceFlags + * Optional trace flags (e.g., sampled flag) + * @param resource + * The resource that generated this log record + * @param instrumentationScope + * The instrumentation scope (library/tool) that generated this log record + */ +final case class LogRecord( + timestampNanos: Long, + observedTimestampNanos: Long, + severity: Severity, + severityText: String, + body: String, + attributes: Attributes, + traceId: Option[TraceId], + spanId: Option[SpanId], + traceFlags: Option[TraceFlags], + resource: Resource, + instrumentationScope: InstrumentationScope +) + +object LogRecord { + + /** + * Creates a new LogRecordBuilder for constructing LogRecord instances. + */ + def builder: LogRecordBuilder = LogRecordBuilder() +} + +/** + * Mutable builder for constructing LogRecord instances with fluent API. + * + * The builder provides sensible defaults for all fields: + * - timestamps default to current system time in nanoseconds + * - severity defaults to Info + * - severityText defaults to "INFO" + * - body defaults to empty string + * - attributes defaults to empty Attributes + * - trace fields (traceId, spanId, traceFlags) default to None + * - resource defaults to Resource.empty + * - instrumentationScope defaults to InstrumentationScope with name "unknown" + */ +final case class LogRecordBuilder( + timestampNanos: Option[Long] = None, + observedTimestampNanos: Option[Long] = None, + severity: Severity = Severity.Info, + body: String = "", + attributes: Attributes = Attributes.empty, + traceId: Option[TraceId] = None, + spanId: Option[SpanId] = None, + traceFlags: Option[TraceFlags] = None, + resource: Resource = Resource.empty, + instrumentationScope: InstrumentationScope = InstrumentationScope(name = "unknown") +) { + + /** + * Sets the timestamp (time when the event occurred). + * + * @param nanos + * The timestamp in nanoseconds since Unix epoch + * @return + * This builder for method chaining + */ + def setTimestamp(nanos: Long): LogRecordBuilder = + copy(timestampNanos = Some(nanos)) + + /** + * Sets the observed timestamp (time when the event was recorded). + * + * @param nanos + * The observed timestamp in nanoseconds since Unix epoch + * @return + * This builder for method chaining + */ + def setObservedTimestamp(nanos: Long): LogRecordBuilder = + copy(observedTimestampNanos = Some(nanos)) + + /** + * Sets the severity level of the log record. + * + * Also updates the severityText to match the severity's text representation. + * + * @param sev + * The severity level + * @return + * This builder for method chaining + */ + def setSeverity(sev: Severity): LogRecordBuilder = + copy(severity = sev) + + /** + * Sets the log message body. + * + * @param msg + * The log message + * @return + * This builder for method chaining + */ + def setBody(msg: String): LogRecordBuilder = + copy(body = msg) + + /** + * Adds an attribute to the log record. + * + * @param key + * The attribute key + * @param value + * The attribute value + * @return + * This builder for method chaining + */ + def setAttribute[A](key: AttributeKey[A], value: A): LogRecordBuilder = + copy(attributes = attributes ++ Attributes.of(key, value)) + + /** + * Sets the trace ID for correlation with distributed traces. + * + * @param id + * The trace ID + * @return + * This builder for method chaining + */ + def setTraceId(id: TraceId): LogRecordBuilder = + copy(traceId = Some(id)) + + /** + * Sets the span ID for correlation with distributed traces. + * + * @param id + * The span ID + * @return + * This builder for method chaining + */ + def setSpanId(id: SpanId): LogRecordBuilder = + copy(spanId = Some(id)) + + /** + * Sets the trace flags (e.g., sampled flag). + * + * @param flags + * The trace flags + * @return + * This builder for method chaining + */ + def setTraceFlags(flags: TraceFlags): LogRecordBuilder = + copy(traceFlags = Some(flags)) + + /** + * Sets the resource that generated this log record. + * + * @param res + * The resource + * @return + * This builder for method chaining + */ + def setResource(res: Resource): LogRecordBuilder = + copy(resource = res) + + /** + * Sets the instrumentation scope. + * + * @param scope + * The instrumentation scope + * @return + * This builder for method chaining + */ + def setInstrumentationScope(scope: InstrumentationScope): LogRecordBuilder = + copy(instrumentationScope = scope) + + /** + * Builds the final LogRecord. + * + * Uses current system time for any timestamps not explicitly set. + * + * @return + * The constructed LogRecord + */ + def build: LogRecord = { + val now = System.nanoTime() + LogRecord( + timestampNanos = timestampNanos.getOrElse(now), + observedTimestampNanos = observedTimestampNanos.getOrElse(now), + severity = severity, + severityText = severity.text, + body = body, + attributes = attributes, + traceId = traceId, + spanId = spanId, + traceFlags = traceFlags, + resource = resource, + instrumentationScope = instrumentationScope + ) + } +} diff --git a/otel/shared/src/main/scala/zio/blocks/otel/LogRecordProcessor.scala b/otel/shared/src/main/scala/zio/blocks/otel/LogRecordProcessor.scala new file mode 100644 index 0000000000..63c20f52ee --- /dev/null +++ b/otel/shared/src/main/scala/zio/blocks/otel/LogRecordProcessor.scala @@ -0,0 +1,32 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +trait LogRecordProcessor { + def onEmit(logRecord: LogRecord): Unit + def shutdown(): Unit + def forceFlush(): Unit +} + +object LogRecordProcessor { + + val noop: LogRecordProcessor = new LogRecordProcessor { + def onEmit(logRecord: LogRecord): Unit = () + def shutdown(): Unit = () + def forceFlush(): Unit = () + } +} diff --git a/otel/shared/src/main/scala/zio/blocks/otel/Logger.scala b/otel/shared/src/main/scala/zio/blocks/otel/Logger.scala new file mode 100644 index 0000000000..922215fcad --- /dev/null +++ b/otel/shared/src/main/scala/zio/blocks/otel/Logger.scala @@ -0,0 +1,85 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +final class Logger( + instrumentationScope: InstrumentationScope, + resource: Resource, + processors: Seq[LogRecordProcessor], + contextStorage: ContextStorage[Option[SpanContext]] +) { + + def emit(logRecord: LogRecord): Unit = + processors.foreach(_.onEmit(logRecord)) + + def trace(body: String, attrs: (String, AttributeValue)*): Unit = + log(Severity.Trace, body, attrs) + + def debug(body: String, attrs: (String, AttributeValue)*): Unit = + log(Severity.Debug, body, attrs) + + def info(body: String, attrs: (String, AttributeValue)*): Unit = + log(Severity.Info, body, attrs) + + def warn(body: String, attrs: (String, AttributeValue)*): Unit = + log(Severity.Warn, body, attrs) + + def error(body: String, attrs: (String, AttributeValue)*): Unit = + log(Severity.Error, body, attrs) + + def fatal(body: String, attrs: (String, AttributeValue)*): Unit = + log(Severity.Fatal, body, attrs) + + private def log(severity: Severity, body: String, attrs: Seq[(String, AttributeValue)]): Unit = { + val now = System.nanoTime() + val spanCtxOpt = contextStorage.get() + + val attrBuilder = Attributes.builder + attrs.foreach { case (k, v) => + v match { + case AttributeValue.StringValue(s) => attrBuilder.put(k, s) + case AttributeValue.BooleanValue(b) => attrBuilder.put(k, b) + case AttributeValue.LongValue(l) => attrBuilder.put(k, l) + case AttributeValue.DoubleValue(d) => attrBuilder.put(k, d) + case _ => attrBuilder.put(k, v.toString) + } + } + + val (traceId, spanId, traceFlags) = spanCtxOpt match { + case Some(ctx) if ctx.isValid => + (Some(ctx.traceId), Some(ctx.spanId), Some(ctx.traceFlags)) + case _ => + (None, None, None) + } + + val record = LogRecord( + timestampNanos = now, + observedTimestampNanos = now, + severity = severity, + severityText = severity.text, + body = body, + attributes = attrBuilder.build, + traceId = traceId, + spanId = spanId, + traceFlags = traceFlags, + resource = resource, + instrumentationScope = instrumentationScope + ) + + emit(record) + } +} diff --git a/otel/shared/src/main/scala/zio/blocks/otel/LoggerProvider.scala b/otel/shared/src/main/scala/zio/blocks/otel/LoggerProvider.scala new file mode 100644 index 0000000000..3af5a19a0c --- /dev/null +++ b/otel/shared/src/main/scala/zio/blocks/otel/LoggerProvider.scala @@ -0,0 +1,71 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +final class LoggerProvider( + resource: Resource, + processors: Seq[LogRecordProcessor], + contextStorage: ContextStorage[Option[SpanContext]] +) { + + def get(name: String, version: String = ""): Logger = { + val scope = InstrumentationScope( + name = name, + version = if (version.isEmpty) None else Some(version) + ) + new Logger(scope, resource, processors, contextStorage) + } + + def shutdown(): Unit = + processors.foreach(_.shutdown()) +} + +object LoggerProvider { + + def builder: LoggerProviderBuilder = new LoggerProviderBuilder( + resource = Resource.default, + processors = Seq.empty, + contextStorage = None + ) +} + +final class LoggerProviderBuilder private[otel] ( + private var resource: Resource, + private var processors: Seq[LogRecordProcessor], + private var contextStorage: Option[ContextStorage[Option[SpanContext]]] = None +) { + + def setResource(resource: Resource): LoggerProviderBuilder = { + this.resource = resource + this + } + + def addLogRecordProcessor(processor: LogRecordProcessor): LoggerProviderBuilder = { + this.processors = this.processors :+ processor + this + } + + def setContextStorage(contextStorage: ContextStorage[Option[SpanContext]]): LoggerProviderBuilder = { + this.contextStorage = Some(contextStorage) + this + } + + def build(): LoggerProvider = { + val cs = contextStorage.getOrElse(ContextStorage.create[Option[SpanContext]](None)) + new LoggerProvider(resource, processors, cs) + } +} diff --git a/otel/shared/src/main/scala/zio/blocks/otel/Meter.scala b/otel/shared/src/main/scala/zio/blocks/otel/Meter.scala new file mode 100644 index 0000000000..029b4d75c2 --- /dev/null +++ b/otel/shared/src/main/scala/zio/blocks/otel/Meter.scala @@ -0,0 +1,253 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import java.util.concurrent.CopyOnWriteArrayList + +/** + * Internal thread-safe registry that tracks all instruments across all meters. + */ +private[otel] final class MeterRegistry { + private val meters = new CopyOnWriteArrayList[Meter]() + + def register(meter: Meter): Unit = { + meters.add(meter) + () + } + + def collectAll(): Seq[MetricData] = { + val result = new java.util.ArrayList[MetricData]() + val it = meters.iterator() + while (it.hasNext) { + val meter = it.next() + meter.collectInstruments(result) + } + SyncInstrumentsHelper.listFromJava(result) + } +} + +/** + * A Meter creates and manages metric instruments for a given + * InstrumentationScope. All instruments created through a Meter are registered + * internally so the MetricReader can collect from them. + * + * @param instrumentationScope + * the scope identifying the instrumentation library + */ +final class Meter private[otel] ( + val instrumentationScope: InstrumentationScope +) { + private sealed trait Collectible + private final case class SyncCounter(c: Counter) extends Collectible + private final case class SyncUpDownCounter(c: UpDownCounter) extends Collectible + private final case class SyncHistogram(h: Histogram) extends Collectible + private final case class SyncGauge(g: Gauge) extends Collectible + private final case class ObsCounter(c: ObservableCounter) extends Collectible + private final case class ObsUpDownCounter(c: ObservableUpDownCounter) extends Collectible + private final case class ObsGauge(g: ObservableGauge) extends Collectible + private val instruments = new CopyOnWriteArrayList[Collectible]() + + def counterBuilder(name: String): CounterBuilder = + new CounterBuilder(name, this) + + def upDownCounterBuilder(name: String): UpDownCounterBuilder = + new UpDownCounterBuilder(name, this) + + def histogramBuilder(name: String): HistogramBuilder = + new HistogramBuilder(name, this) + + def gaugeBuilder(name: String): GaugeBuilder = + new GaugeBuilder(name, this) + + private[otel] def registerCounter(c: Counter): Unit = { + instruments.add(SyncCounter(c)) + () + } + + private[otel] def registerUpDownCounter(c: UpDownCounter): Unit = { + instruments.add(SyncUpDownCounter(c)) + () + } + + private[otel] def registerHistogram(h: Histogram): Unit = { + instruments.add(SyncHistogram(h)) + () + } + + private[otel] def registerGauge(g: Gauge): Unit = { + instruments.add(SyncGauge(g)) + () + } + + private[otel] def registerObservableCounter(c: ObservableCounter): Unit = { + instruments.add(ObsCounter(c)) + () + } + + private[otel] def registerObservableUpDownCounter(c: ObservableUpDownCounter): Unit = { + instruments.add(ObsUpDownCounter(c)) + () + } + + private[otel] def registerObservableGauge(g: ObservableGauge): Unit = { + instruments.add(ObsGauge(g)) + () + } + + private[otel] def collectInstruments(out: java.util.ArrayList[MetricData]): Unit = { + val it = instruments.iterator() + while (it.hasNext) { + val data = (it.next(): @unchecked) match { + case SyncCounter(c) => c.collect() + case SyncUpDownCounter(c) => c.collect() + case SyncHistogram(h) => h.collect() + case SyncGauge(g) => g.collect() + case ObsCounter(c) => c.collect() + case ObsUpDownCounter(c) => c.collect() + case ObsGauge(g) => g.collect() + } + out.add(data) + () + } + } +} + +/** + * Builder for Counter instruments. + */ +final class CounterBuilder private[otel] ( + private val name: String, + private val meter: Meter, + private var description: String = "", + private var unit: String = "" +) { + + def setDescription(desc: String): CounterBuilder = { + this.description = desc + this + } + + def setUnit(u: String): CounterBuilder = { + this.unit = u + this + } + + def build(): Counter = { + val counter = Counter(name, description, unit) + meter.registerCounter(counter) + counter + } + + def buildWithCallback(callback: ObservableCallback => Unit): ObservableCounter = { + val obs = ObservableCounter(name, description, unit)(callback) + meter.registerObservableCounter(obs) + obs + } +} + +/** + * Builder for UpDownCounter instruments. + */ +final class UpDownCounterBuilder private[otel] ( + private val name: String, + private val meter: Meter, + private var description: String = "", + private var unit: String = "" +) { + + def setDescription(desc: String): UpDownCounterBuilder = { + this.description = desc + this + } + + def setUnit(u: String): UpDownCounterBuilder = { + this.unit = u + this + } + + def build(): UpDownCounter = { + val counter = UpDownCounter(name, description, unit) + meter.registerUpDownCounter(counter) + counter + } + + def buildWithCallback(callback: ObservableCallback => Unit): ObservableUpDownCounter = { + val obs = ObservableUpDownCounter(name, description, unit)(callback) + meter.registerObservableUpDownCounter(obs) + obs + } +} + +/** + * Builder for Histogram instruments. + */ +final class HistogramBuilder private[otel] ( + private val name: String, + private val meter: Meter, + private var description: String = "", + private var unit: String = "" +) { + + def setDescription(desc: String): HistogramBuilder = { + this.description = desc + this + } + + def setUnit(u: String): HistogramBuilder = { + this.unit = u + this + } + + def build(): Histogram = { + val histogram = Histogram(name, description, unit) + meter.registerHistogram(histogram) + histogram + } +} + +/** + * Builder for Gauge instruments. + */ +final class GaugeBuilder private[otel] ( + private val name: String, + private val meter: Meter, + private var description: String = "", + private var unit: String = "" +) { + + def setDescription(desc: String): GaugeBuilder = { + this.description = desc + this + } + + def setUnit(u: String): GaugeBuilder = { + this.unit = u + this + } + + def build(): Gauge = { + val gauge = Gauge(name, description, unit) + meter.registerGauge(gauge) + gauge + } + + def buildWithCallback(callback: ObservableCallback => Unit): ObservableGauge = { + val obs = ObservableGauge(name, description, unit)(callback) + meter.registerObservableGauge(obs) + obs + } +} diff --git a/otel/shared/src/main/scala/zio/blocks/otel/MeterProvider.scala b/otel/shared/src/main/scala/zio/blocks/otel/MeterProvider.scala new file mode 100644 index 0000000000..1dcef2e481 --- /dev/null +++ b/otel/shared/src/main/scala/zio/blocks/otel/MeterProvider.scala @@ -0,0 +1,79 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import java.util.concurrent.ConcurrentHashMap + +/** + * Provides Meter instances keyed by instrumentation scope. A MeterProvider + * creates a shared MeterRegistry and MetricReader that collects from all + * meters. + * + * @param resource + * the resource describing the entity producing telemetry + */ +final class MeterProvider private[otel] ( + val resource: Resource, + private val meterRegistry: MeterRegistry +) { + private val meters = new ConcurrentHashMap[InstrumentationScope, Meter]() + + val reader: MetricReader = new MetricReaderImpl(meterRegistry) + + def get(name: String, version: String = ""): Meter = { + val scope = InstrumentationScope( + name = name, + version = if (version.isEmpty) None else Some(version) + ) + meters.computeIfAbsent( + scope, + { s => + val meter = new Meter(s) + meterRegistry.register(meter) + meter + } + ) + } + + def shutdown(): Unit = + reader.shutdown() +} + +object MeterProvider { + + def builder: MeterProviderBuilder = new MeterProviderBuilder( + resource = Resource.default + ) +} + +/** + * Builder for MeterProvider. + */ +final class MeterProviderBuilder private[otel] ( + private var resource: Resource +) { + + def setResource(resource: Resource): MeterProviderBuilder = { + this.resource = resource + this + } + + def build(): MeterProvider = { + val registry = new MeterRegistry() + new MeterProvider(resource, registry) + } +} diff --git a/otel/shared/src/main/scala/zio/blocks/otel/MetricData.scala b/otel/shared/src/main/scala/zio/blocks/otel/MetricData.scala new file mode 100644 index 0000000000..4ce6a0e2b5 --- /dev/null +++ b/otel/shared/src/main/scala/zio/blocks/otel/MetricData.scala @@ -0,0 +1,120 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +/** + * A single measurement observation with a value and associated attributes. + * + * @param value + * the measured value + * @param attributes + * attributes describing the measurement context + */ +final case class Measurement(value: Double, attributes: Attributes) + +/** + * A data point for sum-based metrics (Counter, UpDownCounter). + * + * @param attributes + * attributes identifying this time series + * @param startTimeNanos + * epoch nanoseconds when the aggregation period started + * @param timeNanos + * epoch nanoseconds when this snapshot was taken + * @param value + * the aggregated sum value + */ +final case class SumDataPoint( + attributes: Attributes, + startTimeNanos: Long, + timeNanos: Long, + value: Long +) + +/** + * A data point for histogram metrics. + * + * @param attributes + * attributes identifying this time series + * @param startTimeNanos + * epoch nanoseconds when the aggregation period started + * @param timeNanos + * epoch nanoseconds when this snapshot was taken + * @param count + * total number of recorded values + * @param sum + * sum of all recorded values + * @param min + * minimum recorded value + * @param max + * maximum recorded value + * @param bucketCounts + * counts for each bucket (length = boundaries.length + 1) + * @param boundaries + * upper exclusive boundaries for each bucket + */ +final case class HistogramDataPoint( + attributes: Attributes, + startTimeNanos: Long, + timeNanos: Long, + count: Long, + sum: Double, + min: Double, + max: Double, + bucketCounts: Array[Long], + boundaries: Array[Double] +) + +/** + * A data point for gauge metrics. + * + * @param attributes + * attributes identifying this time series + * @param timeNanos + * epoch nanoseconds when this value was observed + * @param value + * the current gauge value + */ +final case class GaugeDataPoint( + attributes: Attributes, + timeNanos: Long, + value: Double +) + +/** + * Aggregated metric data collected from an instrument. Each variant holds a + * list of data points, one per unique attribute set observed. + */ +sealed trait MetricData + +object MetricData { + + /** + * Aggregated sum data from a Counter or UpDownCounter. + */ + final case class SumData(points: List[SumDataPoint]) extends MetricData + + /** + * Aggregated histogram data from a Histogram instrument. + */ + final case class HistogramData(points: List[HistogramDataPoint]) extends MetricData + + /** + * Aggregated gauge data from a Gauge instrument. + */ + final case class GaugeData(points: List[GaugeDataPoint]) extends MetricData +} diff --git a/otel/shared/src/main/scala/zio/blocks/otel/MetricReader.scala b/otel/shared/src/main/scala/zio/blocks/otel/MetricReader.scala new file mode 100644 index 0000000000..9d40d4d9d3 --- /dev/null +++ b/otel/shared/src/main/scala/zio/blocks/otel/MetricReader.scala @@ -0,0 +1,54 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +/** + * Reads and collects metric data from all registered instruments. + */ +trait MetricReader { + + /** + * Collects metric data from all registered instruments across all meters. + */ + def collectAllMetrics(): Seq[MetricData] + + /** + * Shuts down the reader, releasing any resources. + */ + def shutdown(): Unit + + /** + * Forces a flush of any buffered metric data. + */ + def forceFlush(): Unit +} + +/** + * Default MetricReader implementation backed by a MeterProvider's meter + * registry. + */ +final class MetricReaderImpl private[otel] ( + private val meterRegistry: MeterRegistry +) extends MetricReader { + + override def collectAllMetrics(): Seq[MetricData] = + meterRegistry.collectAll() + + override def shutdown(): Unit = () + + override def forceFlush(): Unit = () +} diff --git a/otel/shared/src/main/scala/zio/blocks/otel/ObservableInstruments.scala b/otel/shared/src/main/scala/zio/blocks/otel/ObservableInstruments.scala new file mode 100644 index 0000000000..be9e8bd8b6 --- /dev/null +++ b/otel/shared/src/main/scala/zio/blocks/otel/ObservableInstruments.scala @@ -0,0 +1,161 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +/** + * Callback interface for observable instruments to report measurements during + * collection. + */ +trait ObservableCallback { + + /** + * Records a measurement value with associated attributes. + */ + def record(value: Double, attributes: Attributes): Unit +} + +/** + * An observable counter that invokes a callback on each collection to report + * cumulative monotonic sum values. + * + * @param name + * instrument name + * @param description + * human-readable description + * @param unit + * unit of measurement + * @param callback + * function invoked on each collect to report current measurements + */ +final class ObservableCounter( + val name: String, + val description: String, + val unit: String, + private val callback: ObservableCallback => Unit +) { + + /** + * Collects current measurements by invoking the registered callback. + */ + def collect(): MetricData = { + val measurements = new java.util.ArrayList[SumDataPoint]() + val now = System.nanoTime() + val cb = new ObservableCallback { + def record(value: Double, attributes: Attributes): Unit = { + measurements.add(SumDataPoint(attributes, 0L, now, value.toLong)) + () + } + } + callback(cb) + MetricData.SumData(SyncInstrumentsHelper.listFromJava(measurements)) + } +} + +object ObservableCounter { + def apply(name: String, description: String, unit: String)( + callback: ObservableCallback => Unit + ): ObservableCounter = + new ObservableCounter(name, description, unit, callback) +} + +/** + * An observable up-down counter that invokes a callback on each collection to + * report cumulative non-monotonic sum values. + * + * @param name + * instrument name + * @param description + * human-readable description + * @param unit + * unit of measurement + * @param callback + * function invoked on each collect to report current measurements + */ +final class ObservableUpDownCounter( + val name: String, + val description: String, + val unit: String, + private val callback: ObservableCallback => Unit +) { + + /** + * Collects current measurements by invoking the registered callback. + */ + def collect(): MetricData = { + val measurements = new java.util.ArrayList[SumDataPoint]() + val now = System.nanoTime() + val cb = new ObservableCallback { + def record(value: Double, attributes: Attributes): Unit = { + measurements.add(SumDataPoint(attributes, 0L, now, value.toLong)) + () + } + } + callback(cb) + MetricData.SumData(SyncInstrumentsHelper.listFromJava(measurements)) + } +} + +object ObservableUpDownCounter { + def apply(name: String, description: String, unit: String)( + callback: ObservableCallback => Unit + ): ObservableUpDownCounter = + new ObservableUpDownCounter(name, description, unit, callback) +} + +/** + * An observable gauge that invokes a callback on each collection to report + * current point-in-time values. + * + * @param name + * instrument name + * @param description + * human-readable description + * @param unit + * unit of measurement + * @param callback + * function invoked on each collect to report current measurements + */ +final class ObservableGauge( + val name: String, + val description: String, + val unit: String, + private val callback: ObservableCallback => Unit +) { + + /** + * Collects current measurements by invoking the registered callback. + */ + def collect(): MetricData = { + val measurements = new java.util.ArrayList[GaugeDataPoint]() + val now = System.nanoTime() + val cb = new ObservableCallback { + def record(value: Double, attributes: Attributes): Unit = { + measurements.add(GaugeDataPoint(attributes, now, value)) + () + } + } + callback(cb) + MetricData.GaugeData(SyncInstrumentsHelper.listFromJava(measurements)) + } +} + +object ObservableGauge { + def apply(name: String, description: String, unit: String)( + callback: ObservableCallback => Unit + ): ObservableGauge = + new ObservableGauge(name, description, unit, callback) +} diff --git a/otel/shared/src/main/scala/zio/blocks/otel/OtelContext.scala b/otel/shared/src/main/scala/zio/blocks/otel/OtelContext.scala new file mode 100644 index 0000000000..ded51760f7 --- /dev/null +++ b/otel/shared/src/main/scala/zio/blocks/otel/OtelContext.scala @@ -0,0 +1,49 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import zio.blocks.typeid.IsNominalType + +/** + * Bridges the otel module's ContextStorage with zio-blocks Context[R]. + * + * OtelContext captures the current span context from a ContextStorage, enabling + * type-safe context threading via `Context[R & OtelContext]`. + * + * @param spanContext + * the active span context, if any + */ +final case class OtelContext(spanContext: Option[SpanContext]) + +object OtelContext { + + implicit val isNominalType: IsNominalType[OtelContext] = + IsNominalType.derived[OtelContext] + + /** + * Snapshots the current span context from the given storage. + */ + def current(storage: ContextStorage[Option[SpanContext]]): OtelContext = + OtelContext(storage.get()) + + /** + * Executes `f` with the given span's context set as current in storage. + * Restores the previous context afterward (even if `f` throws). + */ + def withSpan[A](span: Span, storage: ContextStorage[Option[SpanContext]])(f: => A): A = + storage.scoped(Some(span.spanContext))(f) +} diff --git a/otel/shared/src/main/scala/zio/blocks/otel/OtlpJsonEncoder.scala b/otel/shared/src/main/scala/zio/blocks/otel/OtlpJsonEncoder.scala new file mode 100644 index 0000000000..5b81e0a4e0 --- /dev/null +++ b/otel/shared/src/main/scala/zio/blocks/otel/OtlpJsonEncoder.scala @@ -0,0 +1,515 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +/** + * A named metric descriptor pairing metric metadata with its data. + * + * @param name + * the metric name + * @param description + * human-readable description + * @param unit + * the unit of measurement + * @param data + * the aggregated metric data + */ +final case class NamedMetric( + name: String, + description: String, + unit: String, + data: MetricData +) + +/** + * Minimal OTLP JSON encoder for traces, metrics, and logs. + * + * Encodes telemetry data into OTLP JSON format (protobuf JSON mapping) without + * external JSON library dependencies. Uses StringBuilder internally. + * + * Encoding rules follow the protobuf JSON mapping: + * - bytes (traceId, spanId) → lowercase hex strings + * - int64/uint64 → quoted string numbers + * - enums → integer numbers + * - field names → camelCase + */ +object OtlpJsonEncoder { + + // Re-export for test convenience + type NamedMetric = zio.blocks.otel.NamedMetric + val NamedMetric: zio.blocks.otel.NamedMetric.type = zio.blocks.otel.NamedMetric + + def encodeTraces(spans: Seq[SpanData], resource: Resource, scope: InstrumentationScope): Array[Byte] = { + val sb = new StringBuilder(256) + sb.append("{\"resourceSpans\":[{") + writeResource(sb, resource) + sb.append(",\"scopeSpans\":[{") + writeScope(sb, scope) + sb.append(",\"spans\":[") + writeSeq(sb, spans)(writeSpan) + sb.append("]}]}]}") + sb.toString.getBytes("UTF-8") + } + + def encodeMetrics( + metrics: Seq[NamedMetric], + resource: Resource, + scope: InstrumentationScope + ): Array[Byte] = { + val sb = new StringBuilder(256) + sb.append("{\"resourceMetrics\":[{") + writeResource(sb, resource) + sb.append(",\"scopeMetrics\":[{") + writeScope(sb, scope) + sb.append(",\"metrics\":[") + writeSeq(sb, metrics)(writeNamedMetric) + sb.append("]}]}]}") + sb.toString.getBytes("UTF-8") + } + + def encodeLogs(logs: Seq[LogRecord], resource: Resource, scope: InstrumentationScope): Array[Byte] = { + val sb = new StringBuilder(256) + sb.append("{\"resourceLogs\":[{") + writeResource(sb, resource) + sb.append(",\"scopeLogs\":[{") + writeScope(sb, scope) + sb.append(",\"logRecords\":[") + writeSeq(sb, logs)(writeLogRecord) + sb.append("]}]}]}") + sb.toString.getBytes("UTF-8") + } + + // --- JSON primitives --- + + private def writeJsonString(sb: StringBuilder, s: String): Unit = { + sb.append('"') + var i = 0 + while (i < s.length) { + val c = s.charAt(i) + c match { + case '"' => sb.append("\\\"") + case '\\' => sb.append("\\\\") + case '\n' => sb.append("\\n") + case '\r' => sb.append("\\r") + case '\t' => sb.append("\\t") + case '\b' => sb.append("\\b") + case '\f' => sb.append("\\f") + case _ => + if (c < 0x20 || (c >= 0xd800 && c <= 0xdfff)) { + sb.append("\\u") + sb.append(String.format("%04x", c.toInt)) + } else { + sb.append(c) + } + } + i += 1 + } + sb.append('"') + } + + private def writeKey(sb: StringBuilder, key: String): Unit = { + sb.append('"') + sb.append(key) // keys are always safe ASCII + sb.append("\":") + } + + private def writeKeyString(sb: StringBuilder, key: String, value: String): Unit = { + writeKey(sb, key) + writeJsonString(sb, value) + } + + private def writeKeyLong(sb: StringBuilder, key: String, value: Long): Unit = { + writeKey(sb, key) + sb.append('"') + sb.append(value) + sb.append('"') + } + + private def writeKeyInt(sb: StringBuilder, key: String, value: Int): Unit = { + writeKey(sb, key) + sb.append(value) + } + + private def writeKeyDouble(sb: StringBuilder, key: String, value: Double): Unit = { + writeKey(sb, key) + sb.append(value) + } + + private def writeKeyBool(sb: StringBuilder, key: String, value: Boolean): Unit = { + writeKey(sb, key) + sb.append(value) + } + + private def writeSeq[A](sb: StringBuilder, items: Seq[A])(write: (StringBuilder, A) => Unit): Unit = { + var first = true + items.foreach { item => + if (!first) sb.append(',') + write(sb, item) + first = false + } + } + + private def writeList[A](sb: StringBuilder, items: List[A])(write: (StringBuilder, A) => Unit): Unit = { + var first = true + items.foreach { item => + if (!first) sb.append(',') + write(sb, item) + first = false + } + } + + // --- Resource & Scope --- + + private def writeResource(sb: StringBuilder, resource: Resource): Unit = { + sb.append("\"resource\":{\"attributes\":[") + writeAttributes(sb, resource.attributes) + sb.append("]}") + } + + private def writeScope(sb: StringBuilder, scope: InstrumentationScope): Unit = { + sb.append("\"scope\":{") + writeKeyString(sb, "name", scope.name) + scope.version.foreach { v => + sb.append(',') + writeKeyString(sb, "version", v) + } + sb.append('}') + } + + // --- Attributes --- + + private def writeAttributes(sb: StringBuilder, attrs: Attributes): Unit = { + var first = true + attrs.foreach { (key, value) => + if (!first) sb.append(',') + sb.append("{\"key\":") + writeJsonString(sb, key) + sb.append(",\"value\":{") + writeAttributeValue(sb, value) + sb.append("}}") + first = false + } + } + + private def writeAttributeValue(sb: StringBuilder, v: AttributeValue): Unit = v match { + case AttributeValue.StringValue(s) => + writeKeyString(sb, "stringValue", s) + case AttributeValue.BooleanValue(b) => + writeKeyBool(sb, "boolValue", b) + case AttributeValue.LongValue(l) => + writeKeyLong(sb, "intValue", l) + case AttributeValue.DoubleValue(d) => + writeKeyDouble(sb, "doubleValue", d) + case AttributeValue.StringSeqValue(seq) => + sb.append("\"arrayValue\":{\"values\":[") + var first = true + seq.foreach { s => + if (!first) sb.append(',') + sb.append("{\"stringValue\":") + writeJsonString(sb, s) + sb.append('}') + first = false + } + sb.append("]}") + case AttributeValue.LongSeqValue(seq) => + sb.append("\"arrayValue\":{\"values\":[") + var first = true + seq.foreach { l => + if (!first) sb.append(',') + sb.append("{\"intValue\":\"") + sb.append(l) + sb.append("\"}") + first = false + } + sb.append("]}") + case AttributeValue.DoubleSeqValue(seq) => + sb.append("\"arrayValue\":{\"values\":[") + var first = true + seq.foreach { d => + if (!first) sb.append(',') + sb.append("{\"doubleValue\":") + sb.append(d) + sb.append('}') + first = false + } + sb.append("]}") + case AttributeValue.BooleanSeqValue(seq) => + sb.append("\"arrayValue\":{\"values\":[") + var first = true + seq.foreach { b => + if (!first) sb.append(',') + sb.append("{\"boolValue\":") + sb.append(b) + sb.append('}') + first = false + } + sb.append("]}") + } + + // --- Span --- + + private def spanKindToInt(kind: SpanKind): Int = kind match { + case SpanKind.Internal => 1 + case SpanKind.Server => 2 + case SpanKind.Client => 3 + case SpanKind.Producer => 4 + case SpanKind.Consumer => 5 + } + + private def statusCodeToInt(status: SpanStatus): Int = status match { + case SpanStatus.Unset => 0 + case SpanStatus.Ok => 1 + case SpanStatus.Error(_) => 2 + } + + private def writeSpan(sb: StringBuilder, span: SpanData): Unit = { + sb.append('{') + writeKeyString(sb, "traceId", span.spanContext.traceId.toHex) + sb.append(',') + writeKeyString(sb, "spanId", span.spanContext.spanId.toHex) + sb.append(',') + writeKeyString(sb, "parentSpanId", span.parentSpanContext.spanId.toHex) + sb.append(',') + writeKeyString(sb, "name", span.name) + sb.append(',') + writeKeyInt(sb, "kind", spanKindToInt(span.kind)) + sb.append(',') + writeKeyLong(sb, "startTimeUnixNano", span.startTimeNanos) + sb.append(',') + writeKeyLong(sb, "endTimeUnixNano", span.endTimeNanos) + sb.append(',') + + // attributes + writeKey(sb, "attributes") + sb.append('[') + writeAttributes(sb, span.attributes) + sb.append(']') + sb.append(',') + + // events + writeKey(sb, "events") + sb.append('[') + writeList(sb, span.events)(writeSpanEvent) + sb.append(']') + sb.append(',') + + // links + writeKey(sb, "links") + sb.append('[') + writeList(sb, span.links)(writeSpanLink) + sb.append(']') + sb.append(',') + + // status + writeKey(sb, "status") + sb.append('{') + writeKeyInt(sb, "code", statusCodeToInt(span.status)) + span.status match { + case SpanStatus.Error(desc) => + sb.append(',') + writeKeyString(sb, "message", desc) + case _ => () + } + sb.append('}') + + sb.append('}') + } + + private def writeSpanEvent(sb: StringBuilder, event: SpanEvent): Unit = { + sb.append('{') + writeKeyString(sb, "name", event.name) + sb.append(',') + writeKeyLong(sb, "timeUnixNano", event.timestampNanos) + sb.append(',') + writeKey(sb, "attributes") + sb.append('[') + writeAttributes(sb, event.attributes) + sb.append(']') + sb.append('}') + } + + private def writeSpanLink(sb: StringBuilder, link: SpanLink): Unit = { + sb.append('{') + writeKeyString(sb, "traceId", link.spanContext.traceId.toHex) + sb.append(',') + writeKeyString(sb, "spanId", link.spanContext.spanId.toHex) + sb.append(',') + writeKey(sb, "attributes") + sb.append('[') + writeAttributes(sb, link.attributes) + sb.append(']') + sb.append('}') + } + + // --- Metrics --- + + private def writeNamedMetric(sb: StringBuilder, nm: NamedMetric): Unit = { + sb.append('{') + writeKeyString(sb, "name", nm.name) + sb.append(',') + writeKeyString(sb, "description", nm.description) + sb.append(',') + writeKeyString(sb, "unit", nm.unit) + sb.append(',') + + nm.data match { + case MetricData.SumData(points) => + writeKey(sb, "sum") + sb.append('{') + writeKey(sb, "dataPoints") + sb.append('[') + writeList(sb, points)(writeSumDataPoint) + sb.append(']') + sb.append(',') + writeKeyBool(sb, "isMonotonic", true) + sb.append('}') + + case MetricData.HistogramData(points) => + writeKey(sb, "histogram") + sb.append('{') + writeKey(sb, "dataPoints") + sb.append('[') + writeList(sb, points)(writeHistogramDataPoint) + sb.append(']') + sb.append('}') + + case MetricData.GaugeData(points) => + writeKey(sb, "gauge") + sb.append('{') + writeKey(sb, "dataPoints") + sb.append('[') + writeList(sb, points)(writeGaugeDataPoint) + sb.append(']') + sb.append('}') + } + + sb.append('}') + } + + private def writeSumDataPoint(sb: StringBuilder, pt: SumDataPoint): Unit = { + sb.append('{') + writeKey(sb, "attributes") + sb.append('[') + writeAttributes(sb, pt.attributes) + sb.append(']') + sb.append(',') + writeKeyLong(sb, "startTimeUnixNano", pt.startTimeNanos) + sb.append(',') + writeKeyLong(sb, "timeUnixNano", pt.timeNanos) + sb.append(',') + writeKeyLong(sb, "asInt", pt.value) + sb.append('}') + } + + private def writeHistogramDataPoint(sb: StringBuilder, pt: HistogramDataPoint): Unit = { + sb.append('{') + writeKey(sb, "attributes") + sb.append('[') + writeAttributes(sb, pt.attributes) + sb.append(']') + sb.append(',') + writeKeyLong(sb, "startTimeUnixNano", pt.startTimeNanos) + sb.append(',') + writeKeyLong(sb, "timeUnixNano", pt.timeNanos) + sb.append(',') + writeKeyLong(sb, "count", pt.count) + sb.append(',') + writeKeyDouble(sb, "sum", pt.sum) + sb.append(',') + writeKeyDouble(sb, "min", pt.min) + sb.append(',') + writeKeyDouble(sb, "max", pt.max) + sb.append(',') + + // bucketCounts as quoted strings + writeKey(sb, "bucketCounts") + sb.append('[') + var i = 0 + while (i < pt.bucketCounts.length) { + if (i > 0) sb.append(',') + sb.append('"') + sb.append(pt.bucketCounts(i)) + sb.append('"') + i += 1 + } + sb.append(']') + sb.append(',') + + // explicitBounds as doubles + writeKey(sb, "explicitBounds") + sb.append('[') + i = 0 + while (i < pt.boundaries.length) { + if (i > 0) sb.append(',') + sb.append(pt.boundaries(i)) + i += 1 + } + sb.append(']') + + sb.append('}') + } + + private def writeGaugeDataPoint(sb: StringBuilder, pt: GaugeDataPoint): Unit = { + sb.append('{') + writeKey(sb, "attributes") + sb.append('[') + writeAttributes(sb, pt.attributes) + sb.append(']') + sb.append(',') + writeKeyLong(sb, "timeUnixNano", pt.timeNanos) + sb.append(',') + writeKeyDouble(sb, "asDouble", pt.value) + sb.append('}') + } + + // --- Log Records --- + + private def writeLogRecord(sb: StringBuilder, log: LogRecord): Unit = { + sb.append('{') + writeKeyLong(sb, "timeUnixNano", log.timestampNanos) + sb.append(',') + writeKeyLong(sb, "observedTimeUnixNano", log.observedTimestampNanos) + sb.append(',') + writeKeyInt(sb, "severityNumber", log.severity.number) + sb.append(',') + writeKeyString(sb, "severityText", log.severityText) + sb.append(',') + + // body as AnyValue + writeKey(sb, "body") + sb.append('{') + writeKeyString(sb, "stringValue", log.body) + sb.append('}') + sb.append(',') + + // attributes + writeKey(sb, "attributes") + sb.append('[') + writeAttributes(sb, log.attributes) + sb.append(']') + sb.append(',') + + // trace correlation + writeKeyString(sb, "traceId", log.traceId.map(_.toHex).getOrElse("")) + sb.append(',') + writeKeyString(sb, "spanId", log.spanId.map(_.toHex).getOrElse("")) + sb.append(',') + writeKeyInt(sb, "flags", log.traceFlags.map(f => f.toByte.toInt & 0xff).getOrElse(0)) + + sb.append('}') + } +} diff --git a/otel/shared/src/main/scala/zio/blocks/otel/Propagator.scala b/otel/shared/src/main/scala/zio/blocks/otel/Propagator.scala new file mode 100644 index 0000000000..54b9f43e2e --- /dev/null +++ b/otel/shared/src/main/scala/zio/blocks/otel/Propagator.scala @@ -0,0 +1,41 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +/** + * A propagator extracts and injects SpanContext from/into a carrier using + * getter/setter functions. This abstraction enables context propagation across + * process boundaries (e.g., HTTP headers, message queue metadata). + */ +trait Propagator { + + /** + * Extracts a SpanContext from the carrier. Returns None if no valid context + * is found or the input is malformed. + */ + def extract[C](carrier: C, getter: (C, String) => Option[String]): Option[SpanContext] + + /** + * Injects a SpanContext into the carrier, returning the modified carrier. + */ + def inject[C](spanContext: SpanContext, carrier: C, setter: (C, String, String) => C): C + + /** + * The header/field names this propagator reads and writes. + */ + def fields: Seq[String] +} diff --git a/otel/shared/src/main/scala/zio/blocks/otel/Resource.scala b/otel/shared/src/main/scala/zio/blocks/otel/Resource.scala new file mode 100644 index 0000000000..0b7b82bbb1 --- /dev/null +++ b/otel/shared/src/main/scala/zio/blocks/otel/Resource.scala @@ -0,0 +1,81 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +/** + * Describes the entity producing telemetry (service, container, host, etc.). + * + * Resources are immutable wrappers around Attributes that identify the + * application/service generating the telemetry data. A Resource typically + * includes semantic attributes like service.name, service.version, and + * deployment environment. + */ +final case class Resource(attributes: Attributes) + +object Resource { + + /** + * An empty Resource with no attributes. + */ + val empty: Resource = Resource(Attributes.empty) + + /** + * Creates a Resource from attributes. + * + * @param attrs + * The attributes to wrap + * @return + * A Resource containing the given attributes + */ + def create(attrs: Attributes): Resource = + Resource(attrs) + + /** + * The default Resource with auto-populated standard attributes. + * + * Includes: + * - service.name = "unknown_service" + * - telemetry.sdk.name = "zio-blocks" + * - telemetry.sdk.language = "scala" + * - telemetry.sdk.version = BuildInfo.version + */ + val default: Resource = { + val attrs = Attributes.builder + .put(Attributes.ServiceName, "unknown_service") + .put("telemetry.sdk.name", "zio-blocks") + .put("telemetry.sdk.language", "scala") + .put("telemetry.sdk.version", zio.blocks.otel.BuildInfo.version) + .build + Resource(attrs) + } + + /** + * Extension method to merge this Resource with another. + * + * Attributes from the other Resource take precedence on key conflicts, + * following the semantics of Attributes.++. + * + * @param other + * The other Resource to merge + * @return + * A new Resource with merged attributes + */ + implicit class ResourceOps(val self: Resource) { + def merge(other: Resource): Resource = + Resource(self.attributes ++ other.attributes) + } +} diff --git a/otel/shared/src/main/scala/zio/blocks/otel/Sampler.scala b/otel/shared/src/main/scala/zio/blocks/otel/Sampler.scala new file mode 100644 index 0000000000..173537d35e --- /dev/null +++ b/otel/shared/src/main/scala/zio/blocks/otel/Sampler.scala @@ -0,0 +1,174 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +/** + * Represents a sampling decision made by a `Sampler`. + */ +sealed trait SamplingDecision + +object SamplingDecision { + + /** + * The span will not be recorded and all events and attributes will be + * dropped. + */ + case object Drop extends SamplingDecision + + /** + * The span will be recorded but the sampled flag will not be set, so it will + * not be exported. + */ + case object RecordOnly extends SamplingDecision + + /** + * The span will be recorded and the sampled flag will be set, so it will be + * exported. + */ + case object RecordAndSample extends SamplingDecision +} + +/** + * The result of a sampling decision, including the decision itself, any + * additional attributes to add to the span, and the trace state. + * + * @param decision + * the sampling decision + * @param attributes + * additional attributes to add to the span + * @param traceState + * the trace state string to propagate + */ +final case class SamplingResult( + decision: SamplingDecision, + attributes: Attributes, + traceState: String +) + +/** + * A sampler decides whether a span should be recorded and/or sampled. + * + * Samplers are invoked during span creation to determine the sampling decision + * for the new span. + */ +trait Sampler { + + /** + * Determines whether a span should be sampled. + * + * @param parentContext + * the parent span context, if any + * @param traceId + * the trace ID of the span being created + * @param name + * the name of the span being created + * @param kind + * the kind of the span being created + * @param attributes + * the initial attributes of the span being created + * @param links + * the links associated with the span being created + * @return + * the sampling result + */ + def shouldSample( + parentContext: Option[SpanContext], + traceId: TraceId, + name: String, + kind: SpanKind, + attributes: Attributes, + links: Seq[SpanLink] + ): SamplingResult + + /** + * Returns a human-readable description of this sampler. + */ + def description: String +} + +/** + * A sampler that always records and samples every span. + */ +object AlwaysOnSampler extends Sampler { + + private val result: SamplingResult = + SamplingResult(SamplingDecision.RecordAndSample, Attributes.empty, "") + + def shouldSample( + parentContext: Option[SpanContext], + traceId: TraceId, + name: String, + kind: SpanKind, + attributes: Attributes, + links: Seq[SpanLink] + ): SamplingResult = result + + val description: String = "AlwaysOnSampler" +} + +/** + * A sampler that always drops every span. + */ +object AlwaysOffSampler extends Sampler { + + private val result: SamplingResult = + SamplingResult(SamplingDecision.Drop, Attributes.empty, "") + + def shouldSample( + parentContext: Option[SpanContext], + traceId: TraceId, + name: String, + kind: SpanKind, + attributes: Attributes, + links: Seq[SpanLink] + ): SamplingResult = result + + val description: String = "AlwaysOffSampler" +} + +/** + * A sampler that delegates to the parent span's sampling decision. + * + * If no parent exists, delegates to the provided root sampler. If a parent + * exists, the decision follows the parent's sampled flag. + * + * @param root + * the sampler to use when there is no parent span + */ +final case class ParentBasedSampler(root: Sampler) extends Sampler { + + private val sampledResult: SamplingResult = + SamplingResult(SamplingDecision.RecordAndSample, Attributes.empty, "") + + private val droppedResult: SamplingResult = + SamplingResult(SamplingDecision.Drop, Attributes.empty, "") + + def shouldSample( + parentContext: Option[SpanContext], + traceId: TraceId, + name: String, + kind: SpanKind, + attributes: Attributes, + links: Seq[SpanLink] + ): SamplingResult = + parentContext match { + case None => root.shouldSample(parentContext, traceId, name, kind, attributes, links) + case Some(parent) => if (parent.isSampled) sampledResult else droppedResult + } + + val description: String = s"ParentBasedSampler(root=${root.description})" +} diff --git a/otel/shared/src/main/scala/zio/blocks/otel/Severity.scala b/otel/shared/src/main/scala/zio/blocks/otel/Severity.scala new file mode 100644 index 0000000000..54e2480e36 --- /dev/null +++ b/otel/shared/src/main/scala/zio/blocks/otel/Severity.scala @@ -0,0 +1,208 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +/** + * Represents the severity/log level of a log record with 24 levels grouped in 6 + * categories: Trace (1-4), Debug (5-8), Info (9-12), Warn (13-16), Error + * (17-20), Fatal (21-24). + * + * Each severity level has a numeric value and a text representation. + */ +sealed trait Severity { + def number: Int + def text: String +} + +object Severity { + + // Trace levels (1-4) + case object Trace extends Severity { + def number: Int = 1 + def text: String = "TRACE" + } + + case object Trace2 extends Severity { + def number: Int = 2 + def text: String = "TRACE" + } + + case object Trace3 extends Severity { + def number: Int = 3 + def text: String = "TRACE" + } + + case object Trace4 extends Severity { + def number: Int = 4 + def text: String = "TRACE" + } + + // Debug levels (5-8) + case object Debug extends Severity { + def number: Int = 5 + def text: String = "DEBUG" + } + + case object Debug2 extends Severity { + def number: Int = 6 + def text: String = "DEBUG" + } + + case object Debug3 extends Severity { + def number: Int = 7 + def text: String = "DEBUG" + } + + case object Debug4 extends Severity { + def number: Int = 8 + def text: String = "DEBUG" + } + + // Info levels (9-12) + case object Info extends Severity { + def number: Int = 9 + def text: String = "INFO" + } + + case object Info2 extends Severity { + def number: Int = 10 + def text: String = "INFO" + } + + case object Info3 extends Severity { + def number: Int = 11 + def text: String = "INFO" + } + + case object Info4 extends Severity { + def number: Int = 12 + def text: String = "INFO" + } + + // Warn levels (13-16) + case object Warn extends Severity { + def number: Int = 13 + def text: String = "WARN" + } + + case object Warn2 extends Severity { + def number: Int = 14 + def text: String = "WARN" + } + + case object Warn3 extends Severity { + def number: Int = 15 + def text: String = "WARN" + } + + case object Warn4 extends Severity { + def number: Int = 16 + def text: String = "WARN" + } + + // Error levels (17-20) + case object Error extends Severity { + def number: Int = 17 + def text: String = "ERROR" + } + + case object Error2 extends Severity { + def number: Int = 18 + def text: String = "ERROR" + } + + case object Error3 extends Severity { + def number: Int = 19 + def text: String = "ERROR" + } + + case object Error4 extends Severity { + def number: Int = 20 + def text: String = "ERROR" + } + + // Fatal levels (21-24) + case object Fatal extends Severity { + def number: Int = 21 + def text: String = "FATAL" + } + + case object Fatal2 extends Severity { + def number: Int = 22 + def text: String = "FATAL" + } + + case object Fatal3 extends Severity { + def number: Int = 23 + def text: String = "FATAL" + } + + case object Fatal4 extends Severity { + def number: Int = 24 + def text: String = "FATAL" + } + + /** + * Returns the severity level for the given numeric value (1-24). + * + * Returns None if the number is not in the valid range. + */ + def fromNumber(n: Int): Option[Severity] = n match { + case 1 => Some(Trace) + case 2 => Some(Trace2) + case 3 => Some(Trace3) + case 4 => Some(Trace4) + case 5 => Some(Debug) + case 6 => Some(Debug2) + case 7 => Some(Debug3) + case 8 => Some(Debug4) + case 9 => Some(Info) + case 10 => Some(Info2) + case 11 => Some(Info3) + case 12 => Some(Info4) + case 13 => Some(Warn) + case 14 => Some(Warn2) + case 15 => Some(Warn3) + case 16 => Some(Warn4) + case 17 => Some(Error) + case 18 => Some(Error2) + case 19 => Some(Error3) + case 20 => Some(Error4) + case 21 => Some(Fatal) + case 22 => Some(Fatal2) + case 23 => Some(Fatal3) + case 24 => Some(Fatal4) + case _ => None + } + + /** + * Returns the severity level for the given text (case-insensitive). + * + * Valid texts are: TRACE, DEBUG, INFO, WARN, ERROR, FATAL. Returns None if + * the text does not match any severity level. + */ + def fromText(s: String): Option[Severity] = + s.toUpperCase match { + case "TRACE" => Some(Trace) + case "DEBUG" => Some(Debug) + case "INFO" => Some(Info) + case "WARN" => Some(Warn) + case "ERROR" => Some(Error) + case "FATAL" => Some(Fatal) + case _ => None + } +} diff --git a/otel/shared/src/main/scala/zio/blocks/otel/SourceLocation.scala b/otel/shared/src/main/scala/zio/blocks/otel/SourceLocation.scala new file mode 100644 index 0000000000..a4bf539433 --- /dev/null +++ b/otel/shared/src/main/scala/zio/blocks/otel/SourceLocation.scala @@ -0,0 +1,24 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +final case class SourceLocation( + filePath: String, + namespace: String, + methodName: String, + lineNumber: Int +) diff --git a/otel/shared/src/main/scala/zio/blocks/otel/Span.scala b/otel/shared/src/main/scala/zio/blocks/otel/Span.scala new file mode 100644 index 0000000000..a67ac3179d --- /dev/null +++ b/otel/shared/src/main/scala/zio/blocks/otel/Span.scala @@ -0,0 +1,220 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicBoolean + +/** + * A mutable, thread-safe span representing a unit of work in a trace. + * + * Spans are created via `SpanBuilder` and track attributes, events, and status + * throughout their lifetime. Once `end()` is called, the span stops recording + * and all mutating methods become no-ops. + */ +trait Span { + def spanContext: SpanContext + def name: String + def kind: SpanKind + def setAttribute[A](key: AttributeKey[A], value: A): Unit + def setAttribute(key: String, value: String): Unit + def setAttribute(key: String, value: Long): Unit + def setAttribute(key: String, value: Double): Unit + def setAttribute(key: String, value: Boolean): Unit + def addEvent(name: String): Unit + def addEvent(name: String, attributes: Attributes): Unit + def addEvent(name: String, timestamp: Long, attributes: Attributes): Unit + def setStatus(status: SpanStatus): Unit + def end(): Unit + def end(endTimeNanos: Long): Unit + def isRecording: Boolean + def toSpanData: SpanData +} + +object Span { + + /** + * A no-op span that performs zero allocations. All methods are no-ops. + */ + object NoOp extends Span { + val spanContext: SpanContext = SpanContext.invalid + val name: String = "" + val kind: SpanKind = SpanKind.Internal + + def setAttribute[A](key: AttributeKey[A], value: A): Unit = () + def setAttribute(key: String, value: String): Unit = () + def setAttribute(key: String, value: Long): Unit = () + def setAttribute(key: String, value: Double): Unit = () + def setAttribute(key: String, value: Boolean): Unit = () + def addEvent(name: String): Unit = () + def addEvent(name: String, attributes: Attributes): Unit = () + def addEvent(name: String, timestamp: Long, attributes: Attributes): Unit = () + def setStatus(status: SpanStatus): Unit = () + def end(): Unit = () + def end(endTimeNanos: Long): Unit = () + val isRecording: Boolean = false + + private val emptySpanData: SpanData = SpanData( + name = "", + kind = SpanKind.Internal, + spanContext = SpanContext.invalid, + parentSpanContext = SpanContext.invalid, + startTimeNanos = 0L, + endTimeNanos = 0L, + attributes = Attributes.empty, + events = List.empty, + links = List.empty, + status = SpanStatus.Unset, + resource = Resource.empty, + instrumentationScope = InstrumentationScope("noop") + ) + + def toSpanData: SpanData = emptySpanData + } +} + +private[otel] final class RecordingSpan( + val spanContext: SpanContext, + val name: String, + val kind: SpanKind, + val parentSpanContext: SpanContext, + val startTimeNanos: Long, + initialAttributes: Attributes, + initialLinks: List[SpanLink], + val resource: Resource, + val instrumentationScope: InstrumentationScope +) extends Span { + + private val ended: AtomicBoolean = new AtomicBoolean(false) + + @volatile private var endTime: Long = 0L + + @volatile private var currentStatus: SpanStatus = SpanStatus.Unset + + private val attributeEntries: CopyOnWriteArrayList[(String, AttributeValue)] = { + val list = new CopyOnWriteArrayList[(String, AttributeValue)]() + initialAttributes.foreach { (k, v) => + list.add((k, v)) + } + list + } + + private val eventEntries: CopyOnWriteArrayList[SpanEvent] = + new CopyOnWriteArrayList[SpanEvent]() + + def setAttribute[A](key: AttributeKey[A], value: A): Unit = + if (!ended.get()) { + val av = toAttributeValue(key, value) + removeAndAdd(key.name, av) + } + + def setAttribute(key: String, value: String): Unit = + if (!ended.get()) removeAndAdd(key, AttributeValue.StringValue(value)) + + def setAttribute(key: String, value: Long): Unit = + if (!ended.get()) removeAndAdd(key, AttributeValue.LongValue(value)) + + def setAttribute(key: String, value: Double): Unit = + if (!ended.get()) removeAndAdd(key, AttributeValue.DoubleValue(value)) + + def setAttribute(key: String, value: Boolean): Unit = + if (!ended.get()) removeAndAdd(key, AttributeValue.BooleanValue(value)) + + def addEvent(name: String): Unit = + if (!ended.get()) eventEntries.add(SpanEvent(name, System.nanoTime(), Attributes.empty)) + + def addEvent(name: String, attributes: Attributes): Unit = + if (!ended.get()) eventEntries.add(SpanEvent(name, System.nanoTime(), attributes)) + + def addEvent(name: String, timestamp: Long, attributes: Attributes): Unit = + if (!ended.get()) eventEntries.add(SpanEvent(name, timestamp, attributes)) + + def setStatus(status: SpanStatus): Unit = + if (!ended.get()) currentStatus = status + + def end(): Unit = + if (ended.compareAndSet(false, true)) { + endTime = System.nanoTime() + } + + def end(endTimeNanos: Long): Unit = + if (ended.compareAndSet(false, true)) { + endTime = endTimeNanos + } + + def isRecording: Boolean = !ended.get() + + def toSpanData: SpanData = { + val builder = Attributes.builder + val iter = attributeEntries.iterator() + while (iter.hasNext) { + val (k, v) = iter.next() + v match { + case AttributeValue.StringValue(s) => builder.put(k, s) + case AttributeValue.LongValue(l) => builder.put(k, l) + case AttributeValue.DoubleValue(d) => builder.put(k, d) + case AttributeValue.BooleanValue(b) => builder.put(k, b) + case _ => builder.put(AttributeKey.string(k), v.toString) + } + } + + val events = { + val buf = List.newBuilder[SpanEvent] + val evtIter = eventEntries.iterator() + while (evtIter.hasNext) buf += evtIter.next() + buf.result() + } + + SpanData( + name = name, + kind = kind, + spanContext = spanContext, + parentSpanContext = parentSpanContext, + startTimeNanos = startTimeNanos, + endTimeNanos = endTime, + attributes = builder.build, + events = events, + links = initialLinks, + status = currentStatus, + resource = resource, + instrumentationScope = instrumentationScope + ) + } + + private def removeAndAdd(key: String, value: AttributeValue): Unit = { + val iter = attributeEntries.iterator() + while (iter.hasNext) { + val entry = iter.next() + if (entry._1 == key) { + attributeEntries.remove(entry) + } + } + attributeEntries.add((key, value)) + } + + private def toAttributeValue[A](key: AttributeKey[A], value: A): AttributeValue = + key.`type` match { + case AttributeType.StringType => AttributeValue.StringValue(value.asInstanceOf[String]) + case AttributeType.BooleanType => AttributeValue.BooleanValue(value.asInstanceOf[Boolean]) + case AttributeType.LongType => AttributeValue.LongValue(value.asInstanceOf[Long]) + case AttributeType.DoubleType => AttributeValue.DoubleValue(value.asInstanceOf[Double]) + case AttributeType.StringSeqType => AttributeValue.StringSeqValue(value.asInstanceOf[Seq[String]]) + case AttributeType.LongSeqType => AttributeValue.LongSeqValue(value.asInstanceOf[Seq[Long]]) + case AttributeType.DoubleSeqType => AttributeValue.DoubleSeqValue(value.asInstanceOf[Seq[Double]]) + case AttributeType.BooleanSeqType => AttributeValue.BooleanSeqValue(value.asInstanceOf[Seq[Boolean]]) + } +} diff --git a/otel/shared/src/main/scala/zio/blocks/otel/SpanBuilder.scala b/otel/shared/src/main/scala/zio/blocks/otel/SpanBuilder.scala new file mode 100644 index 0000000000..485e107e15 --- /dev/null +++ b/otel/shared/src/main/scala/zio/blocks/otel/SpanBuilder.scala @@ -0,0 +1,122 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +/** + * Fluent builder for creating spans. + * + * Configured via chained method calls, then `startSpan()` creates the recording + * span. The builder creates a new SpanId and inherits the TraceId from the + * parent context (or generates a new one for root spans). + */ +final class SpanBuilder private ( + private val spanName: String, + private var spanKind: SpanKind, + private var parentContext: SpanContext, + private val attributesBuilder: Attributes.AttributesBuilder, + private var links: List[SpanLink], + private var startTimestamp: Long, + private var resource: Resource, + private var instrumentationScope: InstrumentationScope +) { + + def setKind(kind: SpanKind): SpanBuilder = { + spanKind = kind + this + } + + def setParent(parentContext: SpanContext): SpanBuilder = { + this.parentContext = parentContext + this + } + + def setAttribute[A](key: AttributeKey[A], value: A): SpanBuilder = { + attributesBuilder.put(key, value) + this + } + + def addLink(link: SpanLink): SpanBuilder = { + links = links :+ link + this + } + + def setStartTimestamp(nanos: Long): SpanBuilder = { + startTimestamp = nanos + this + } + + def setResource(resource: Resource): SpanBuilder = { + this.resource = resource + this + } + + def setInstrumentationScope(scope: InstrumentationScope): SpanBuilder = { + this.instrumentationScope = scope + this + } + + def startSpan(): Span = { + val traceId = + if (parentContext.isValid) parentContext.traceId + else TraceId.random + + val spanId = SpanId.random + + val traceFlags = + if (parentContext.isValid) parentContext.traceFlags + else TraceFlags.sampled + + val spanContext = SpanContext.create( + traceId = traceId, + spanId = spanId, + traceFlags = traceFlags, + traceState = if (parentContext.isValid) parentContext.traceState else "", + isRemote = false + ) + + val actualStart = + if (startTimestamp > 0L) startTimestamp + else System.nanoTime() + + new RecordingSpan( + spanContext = spanContext, + name = spanName, + kind = spanKind, + parentSpanContext = parentContext, + startTimeNanos = actualStart, + initialAttributes = attributesBuilder.build, + initialLinks = links, + resource = resource, + instrumentationScope = instrumentationScope + ) + } +} + +object SpanBuilder { + + def apply(name: String): SpanBuilder = + new SpanBuilder( + spanName = name, + spanKind = SpanKind.Internal, + parentContext = SpanContext.invalid, + attributesBuilder = Attributes.builder, + links = List.empty, + startTimestamp = -1L, + resource = Resource.empty, + instrumentationScope = InstrumentationScope("default") + ) +} diff --git a/otel/shared/src/main/scala/zio/blocks/otel/SpanContext.scala b/otel/shared/src/main/scala/zio/blocks/otel/SpanContext.scala new file mode 100644 index 0000000000..82e8f1a6f1 --- /dev/null +++ b/otel/shared/src/main/scala/zio/blocks/otel/SpanContext.scala @@ -0,0 +1,95 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +/** + * Represents an OpenTelemetry SpanContext - the propagatable part of a span. + * + * Composes TraceId, SpanId, TraceFlags, and trace state into a context that can + * be propagated across process boundaries. + * + * @param traceId + * the trace identifier + * @param spanId + * the span identifier + * @param traceFlags + * the trace flags (sampled flag, etc.) + * @param traceState + * the trace state string (vendor-specific tracing headers) + * @param isRemote + * whether this context originates from a remote parent + */ +final case class SpanContext( + traceId: TraceId, + spanId: SpanId, + traceFlags: TraceFlags, + traceState: String, + isRemote: Boolean +) { + + /** + * Checks if this span context is valid. + * + * A span context is valid if both its trace ID and span ID are valid. + */ + def isValid: Boolean = traceId.isValid && spanId.isValid + + /** + * Checks if this span context is sampled. + * + * Returns true if the sampled flag in traceFlags is set. + */ + def isSampled: Boolean = traceFlags.isSampled +} + +object SpanContext { + + /** + * The invalid/zero span context (represents "no span context"). + * + * All identifiers are zero, traceState is empty, and isRemote is false. + */ + val invalid: SpanContext = SpanContext( + traceId = TraceId.invalid, + spanId = SpanId.invalid, + traceFlags = TraceFlags.none, + traceState = "", + isRemote = false + ) + + /** + * Creates a new SpanContext with the provided values. + */ + def create( + traceId: TraceId, + spanId: SpanId, + traceFlags: TraceFlags, + traceState: String, + isRemote: Boolean + ): SpanContext = + SpanContext( + traceId = traceId, + spanId = spanId, + traceFlags = traceFlags, + traceState = traceState, + isRemote = isRemote + ) + + /** + * Unscoped instance - SpanContext is a safe data type that can escape scopes. + */ +} diff --git a/otel/shared/src/main/scala/zio/blocks/otel/SpanData.scala b/otel/shared/src/main/scala/zio/blocks/otel/SpanData.scala new file mode 100644 index 0000000000..8b38e964a6 --- /dev/null +++ b/otel/shared/src/main/scala/zio/blocks/otel/SpanData.scala @@ -0,0 +1,92 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +/** + * An event associated with a span, capturing a point-in-time occurrence. + * + * @param name + * human-readable name of the event + * @param timestampNanos + * epoch nanoseconds when the event occurred + * @param attributes + * attributes associated with this event + */ +final case class SpanEvent( + name: String, + timestampNanos: Long, + attributes: Attributes +) + +/** + * A link to another span, used to represent causal relationships across traces. + * + * @param spanContext + * the context of the linked span + * @param attributes + * attributes associated with this link + */ +final case class SpanLink( + spanContext: SpanContext, + attributes: Attributes +) + +/** + * Immutable snapshot of span data for export. + * + * Created by calling `toSpanData` on a `Span`. Contains all information + * collected during the span's lifetime. + * + * @param name + * the span name + * @param kind + * the span kind + * @param spanContext + * the span's own context + * @param parentSpanContext + * the parent span's context (invalid if root span) + * @param startTimeNanos + * epoch nanoseconds when the span started + * @param endTimeNanos + * epoch nanoseconds when the span ended + * @param attributes + * attributes set on the span + * @param events + * events recorded during the span + * @param links + * links to other spans + * @param status + * the span's completion status + * @param resource + * the resource producing this span + * @param instrumentationScope + * the instrumentation scope that created this span + */ +final case class SpanData( + name: String, + kind: SpanKind, + spanContext: SpanContext, + parentSpanContext: SpanContext, + startTimeNanos: Long, + endTimeNanos: Long, + attributes: Attributes, + events: List[SpanEvent], + links: List[SpanLink], + status: SpanStatus, + resource: Resource, + instrumentationScope: InstrumentationScope +) diff --git a/otel/shared/src/main/scala/zio/blocks/otel/SpanId.scala b/otel/shared/src/main/scala/zio/blocks/otel/SpanId.scala new file mode 100644 index 0000000000..5c7f315ba2 --- /dev/null +++ b/otel/shared/src/main/scala/zio/blocks/otel/SpanId.scala @@ -0,0 +1,96 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import scala.util.Random + +/** + * Represents an OpenTelemetry SpanId - a 64-bit identifier for a span. + * + * @param value + * the span ID as a 64-bit long + */ +final case class SpanId(value: Long) { + + /** + * Checks if this span ID is valid (not zero). + */ + def isValid: Boolean = value != 0L + + /** + * Converts this span ID to a 16-character lowercase hexadecimal string. + */ + def toHex: String = String.format("%016x", value) + + /** + * Converts this span ID to an 8-byte big-endian array. + */ + def toByteArray: Array[Byte] = { + val bytes = new Array[Byte](8) + bytes(0) = ((value >> 56) & 0xff).toByte + bytes(1) = ((value >> 48) & 0xff).toByte + bytes(2) = ((value >> 40) & 0xff).toByte + bytes(3) = ((value >> 32) & 0xff).toByte + bytes(4) = ((value >> 24) & 0xff).toByte + bytes(5) = ((value >> 16) & 0xff).toByte + bytes(6) = ((value >> 8) & 0xff).toByte + bytes(7) = (value & 0xff).toByte + bytes + } +} + +object SpanId { + + /** + * The invalid/zero span ID (represents "no span"). + */ + val invalid: SpanId = SpanId(value = 0L) + + /** + * Generates a random valid span ID. + * + * If the generated value is zero, regenerates until a valid one is obtained. + */ + def random: SpanId = { + var value = Random.nextLong() + while (value == 0L) { + value = Random.nextLong() + } + SpanId(value = value) + } + + /** + * Parses a 16-character hexadecimal string into a SpanId. + * + * Returns None if the string is not exactly 16 characters or contains non-hex + * characters. + */ + def fromHex(s: String): Option[SpanId] = + if (s.length != 16) None + else { + try { + val value = java.lang.Long.parseUnsignedLong(s, 16) + Some(SpanId(value = value)) + } catch { + case _: NumberFormatException => None + } + } + + /** + * Unscoped instance - SpanId is a safe data type that can escape scopes. + */ +} diff --git a/otel/shared/src/main/scala/zio/blocks/otel/SpanKind.scala b/otel/shared/src/main/scala/zio/blocks/otel/SpanKind.scala new file mode 100644 index 0000000000..6c62a92d06 --- /dev/null +++ b/otel/shared/src/main/scala/zio/blocks/otel/SpanKind.scala @@ -0,0 +1,71 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +/** + * Represents the kind of span - distinguishes the type of span based on its + * relationship in a trace. + * + * Follows OpenTelemetry span kind specification. + */ +sealed trait SpanKind + +object SpanKind { + + /** + * Internal span - represents work performed internally in the application. + * + * No remote parent or child, used for internal instrumentation. + */ + case object Internal extends SpanKind + + /** + * Server span - represents the server-side of a synchronous RPC + * communication. + * + * The span begins when the server starts processing the RPC and ends when the + * server sends the response. + */ + case object Server extends SpanKind + + /** + * Client span - represents the client-side of a synchronous RPC + * communication. + * + * The span begins when the client sends the RPC request and ends when it + * receives the response. + */ + case object Client extends SpanKind + + /** + * Producer span - represents the producer side of an asynchronous message + * transmission. + * + * The span begins when the producer sends the message and ends when the + * message is sent. + */ + case object Producer extends SpanKind + + /** + * Consumer span - represents the consumer side of an asynchronous message + * transmission. + * + * The span begins when the consumer receives the message and ends when the + * consumer finishes processing it. + */ + case object Consumer extends SpanKind +} diff --git a/otel/shared/src/main/scala/zio/blocks/otel/SpanProcessor.scala b/otel/shared/src/main/scala/zio/blocks/otel/SpanProcessor.scala new file mode 100644 index 0000000000..dc62204738 --- /dev/null +++ b/otel/shared/src/main/scala/zio/blocks/otel/SpanProcessor.scala @@ -0,0 +1,34 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +trait SpanProcessor { + def onStart(span: Span): Unit + def onEnd(spanData: SpanData): Unit + def shutdown(): Unit + def forceFlush(): Unit +} + +object SpanProcessor { + + val noop: SpanProcessor = new SpanProcessor { + def onStart(span: Span): Unit = () + def onEnd(spanData: SpanData): Unit = () + def shutdown(): Unit = () + def forceFlush(): Unit = () + } +} diff --git a/otel/shared/src/main/scala/zio/blocks/otel/SpanStatus.scala b/otel/shared/src/main/scala/zio/blocks/otel/SpanStatus.scala new file mode 100644 index 0000000000..b54a11979c --- /dev/null +++ b/otel/shared/src/main/scala/zio/blocks/otel/SpanStatus.scala @@ -0,0 +1,49 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +/** + * Represents the completion status of a span. + * + * Follows OpenTelemetry span status specification. + */ +sealed trait SpanStatus + +object SpanStatus { + + /** + * Unset status - the default status, meaning the span status was not set. + * + * The span status can be updated by the instrumentation. + */ + case object Unset extends SpanStatus + + /** + * Ok status - the span completed successfully. + * + * The operation associated with the span completed without unhandled errors. + */ + case object Ok extends SpanStatus + + /** + * Error status - the span ended due to an error. + * + * @param description + * human-readable error message describing what went wrong + */ + final case class Error(description: String) extends SpanStatus +} diff --git a/otel/shared/src/main/scala/zio/blocks/otel/SyncInstruments.scala b/otel/shared/src/main/scala/zio/blocks/otel/SyncInstruments.scala new file mode 100644 index 0000000000..c41acb96ce --- /dev/null +++ b/otel/shared/src/main/scala/zio/blocks/otel/SyncInstruments.scala @@ -0,0 +1,291 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.{AtomicReference, LongAdder} + +/** + * A monotonic sum instrument that only allows non-negative additions. + * + * Thread-safe: uses a LongAdder per attribute set for lock-free concurrent + * increments. + * + * @param name + * instrument name + * @param description + * human-readable description + * @param unit + * unit of measurement + */ +final class Counter( + val name: String, + val description: String, + val unit: String +) { + private val adders = new ConcurrentHashMap[Map[String, AttributeValue], LongAdder]() + + /** + * Adds a non-negative value to the counter for the given attributes. + */ + def add(value: Long, attributes: Attributes): Unit = + if (value >= 0) { + val key = attributes.toMap + val adder = adders.computeIfAbsent(key, _ => new LongAdder()) + adder.add(value) + } + + /** + * Collects a snapshot of current counter data as SumData. + */ + def collect(): MetricData = { + val now = System.nanoTime() + val points = new java.util.ArrayList[SumDataPoint]() + adders.forEach { (key, adder) => + val attrs = SyncInstrumentsHelper.mapToAttributes(key) + points.add(SumDataPoint(attrs, 0L, now, adder.sum())) + } + MetricData.SumData(SyncInstrumentsHelper.listFromJava(points)) + } +} + +object Counter { + def apply(name: String, description: String, unit: String): Counter = + new Counter(name, description, unit) +} + +/** + * A sum instrument that allows both positive and negative additions. + * + * Thread-safe: uses a LongAdder per attribute set for lock-free concurrent + * updates. + * + * @param name + * instrument name + * @param description + * human-readable description + * @param unit + * unit of measurement + */ +final class UpDownCounter( + val name: String, + val description: String, + val unit: String +) { + private val adders = new ConcurrentHashMap[Map[String, AttributeValue], LongAdder]() + + /** + * Adds a value (positive or negative) to the counter for the given + * attributes. + */ + def add(value: Long, attributes: Attributes): Unit = { + val key = attributes.toMap + val adder = adders.computeIfAbsent(key, _ => new LongAdder()) + adder.add(value) + } + + /** + * Collects a snapshot of current counter data as SumData. + */ + def collect(): MetricData = { + val now = System.nanoTime() + val points = new java.util.ArrayList[SumDataPoint]() + adders.forEach { (key, adder) => + val attrs = SyncInstrumentsHelper.mapToAttributes(key) + points.add(SumDataPoint(attrs, 0L, now, adder.sum())) + } + MetricData.SumData(SyncInstrumentsHelper.listFromJava(points)) + } +} + +object UpDownCounter { + def apply(name: String, description: String, unit: String): UpDownCounter = + new UpDownCounter(name, description, unit) +} + +/** + * A histogram instrument that records value distributions. + * + * Thread-safe: uses synchronized blocks per attribute set for consistent + * bucket, count, and statistics updates. + * + * @param name + * instrument name + * @param description + * human-readable description + * @param unit + * unit of measurement + * @param boundaries + * upper exclusive boundaries for histogram buckets + */ +final class Histogram( + val name: String, + val description: String, + val unit: String, + val boundaries: Array[Double] +) { + private val states = new ConcurrentHashMap[Map[String, AttributeValue], Histogram.State]() + + /** + * Records a value into the histogram for the given attributes. + */ + def record(value: Double, attributes: Attributes): Unit = { + val key = attributes.toMap + val state = states.computeIfAbsent(key, _ => new Histogram.State(boundaries.length + 1)) + state.synchronized { + state.count += 1 + state.sum += value + if (value < state.min) state.min = value + if (value > state.max) state.max = value + val idx = findBucketIndex(value) + state.bucketCounts(idx) += 1 + } + } + + /** + * Collects a snapshot of current histogram data. + */ + def collect(): MetricData = { + val now = System.nanoTime() + val points = new java.util.ArrayList[HistogramDataPoint]() + states.forEach { (key, state) => + state.synchronized { + val attrs = SyncInstrumentsHelper.mapToAttributes(key) + points.add( + HistogramDataPoint( + attrs, + 0L, + now, + state.count, + state.sum, + state.min, + state.max, + state.bucketCounts.clone(), + boundaries.clone() + ) + ) + } + } + MetricData.HistogramData(SyncInstrumentsHelper.listFromJava(points)) + } + + private def findBucketIndex(value: Double): Int = { + var i = 0 + while (i < boundaries.length) { + if (value <= boundaries(i)) return i + i += 1 + } + boundaries.length + } +} + +object Histogram { + private val DefaultBoundaries: Array[Double] = + Array(0.0, 5.0, 10.0, 25.0, 50.0, 75.0, 100.0, 250.0, 500.0, 750.0, 1000.0, 2500.0, 5000.0, 7500.0, 10000.0) + + private[otel] class State(bucketCount: Int) { + var count: Long = 0L + var sum: Double = 0.0 + var min: Double = Double.MaxValue + var max: Double = Double.MinValue + val bucketCounts: Array[Long] = new Array[Long](bucketCount) + } + + def apply(name: String, description: String, unit: String): Histogram = + new Histogram(name, description, unit, DefaultBoundaries) + + def apply(name: String, description: String, unit: String, boundaries: Array[Double]): Histogram = + new Histogram(name, description, unit, boundaries) +} + +/** + * A gauge instrument that records the latest observed value. + * + * Thread-safe: uses AtomicReference per attribute set for lock-free updates. + * + * @param name + * instrument name + * @param description + * human-readable description + * @param unit + * unit of measurement + */ +final class Gauge( + val name: String, + val description: String, + val unit: String +) { + private val values = + new ConcurrentHashMap[Map[String, AttributeValue], AtomicReference[java.lang.Double]]() + + /** + * Records the current value for the given attributes, replacing any previous + * value. + */ + def record(value: Double, attributes: Attributes): Unit = { + val key = attributes.toMap + val ref = values.computeIfAbsent(key, _ => new AtomicReference[java.lang.Double](0.0)) + ref.set(value) + } + + /** + * Collects a snapshot of current gauge data. + */ + def collect(): MetricData = { + val now = System.nanoTime() + val points = new java.util.ArrayList[GaugeDataPoint]() + values.forEach { (key, ref) => + val attrs = SyncInstrumentsHelper.mapToAttributes(key) + points.add(GaugeDataPoint(attrs, now, ref.get())) + } + MetricData.GaugeData(SyncInstrumentsHelper.listFromJava(points)) + } +} + +object Gauge { + def apply(name: String, description: String, unit: String): Gauge = + new Gauge(name, description, unit) +} + +private[otel] object SyncInstrumentsHelper { + def mapToAttributes(map: Map[String, AttributeValue]): Attributes = { + val builder = Attributes.builder + map.foreach { case (k, v) => + v match { + case AttributeValue.StringValue(s) => builder.put(k, s) + case AttributeValue.BooleanValue(b) => builder.put(k, b) + case AttributeValue.LongValue(l) => builder.put(k, l) + case AttributeValue.DoubleValue(d) => builder.put(k, d) + case AttributeValue.StringSeqValue(seq) => builder.put(AttributeKey.stringSeq(k), seq) + case AttributeValue.LongSeqValue(seq) => builder.put(AttributeKey.longSeq(k), seq) + case AttributeValue.DoubleSeqValue(seq) => builder.put(AttributeKey.doubleSeq(k), seq) + case AttributeValue.BooleanSeqValue(seq) => builder.put(AttributeKey.booleanSeq(k), seq) + } + } + builder.build + } + + def listFromJava[A](javaList: java.util.ArrayList[A]): List[A] = { + var result: List[A] = Nil + var i = javaList.size() - 1 + while (i >= 0) { + result = javaList.get(i) :: result + i -= 1 + } + result + } +} diff --git a/otel/shared/src/main/scala/zio/blocks/otel/TraceFlags.scala b/otel/shared/src/main/scala/zio/blocks/otel/TraceFlags.scala new file mode 100644 index 0000000000..5d42a3813d --- /dev/null +++ b/otel/shared/src/main/scala/zio/blocks/otel/TraceFlags.scala @@ -0,0 +1,86 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +/** + * Represents OpenTelemetry trace flags - a single byte where bit 0 represents + * the sampled flag. + * + * @param byte + * the underlying byte value + */ +final case class TraceFlags(byte: Byte) { + + /** + * Checks if the sampled flag (bit 0) is set. + */ + def isSampled: Boolean = (byte & 0x01) != 0 + + /** + * Creates a new TraceFlags with the sampled flag set or cleared. + */ + def withSampled(sampled: Boolean): TraceFlags = { + val newByte = + if (sampled) (byte | 0x01).toByte + else (byte & 0xfe).toByte + TraceFlags(byte = newByte) + } + + /** + * Converts this trace flags to a 2-character lowercase hexadecimal string. + */ + def toHex: String = String.format("%02x", byte & 0xff) + + /** + * Returns the underlying byte value. + */ + def toByte: Byte = byte +} + +object TraceFlags { + + /** + * TraceFlags with no flags set (0x00). + */ + val none: TraceFlags = TraceFlags(byte = 0x00.toByte) + + /** + * TraceFlags with the sampled flag set (0x01). + */ + val sampled: TraceFlags = TraceFlags(byte = 0x01.toByte) + + /** + * Parses a 2-character hexadecimal string into TraceFlags. + * + * Returns None if the string is not exactly 2 characters or contains non-hex + * characters. + */ + def fromHex(s: String): Option[TraceFlags] = + if (s.length != 2) None + else { + try { + val value = java.lang.Integer.parseInt(s, 16) & 0xff + Some(TraceFlags(byte = value.toByte)) + } catch { + case _: NumberFormatException => None + } + } + + /** + * Unscoped instance - TraceFlags is a safe data type that can escape scopes. + */ +} diff --git a/otel/shared/src/main/scala/zio/blocks/otel/TraceId.scala b/otel/shared/src/main/scala/zio/blocks/otel/TraceId.scala new file mode 100644 index 0000000000..4088a7c2bd --- /dev/null +++ b/otel/shared/src/main/scala/zio/blocks/otel/TraceId.scala @@ -0,0 +1,113 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import scala.util.Random + +/** + * Represents an OpenTelemetry TraceId - a 128-bit identifier for a trace. + * + * @param hi + * the high 64 bits of the trace ID + * @param lo + * the low 64 bits of the trace ID + */ +final case class TraceId(hi: Long, lo: Long) { + + /** + * Checks if this trace ID is valid (not all zeros). + */ + def isValid: Boolean = hi != 0L || lo != 0L + + /** + * Converts this trace ID to a 32-character lowercase hexadecimal string. + */ + def toHex: String = { + val hiHex = String.format("%016x", hi) + val loHex = String.format("%016x", lo) + hiHex + loHex + } + + /** + * Converts this trace ID to a 16-byte big-endian array. + */ + def toByteArray: Array[Byte] = { + val bytes = new Array[Byte](16) + bytes(0) = ((hi >> 56) & 0xff).toByte + bytes(1) = ((hi >> 48) & 0xff).toByte + bytes(2) = ((hi >> 40) & 0xff).toByte + bytes(3) = ((hi >> 32) & 0xff).toByte + bytes(4) = ((hi >> 24) & 0xff).toByte + bytes(5) = ((hi >> 16) & 0xff).toByte + bytes(6) = ((hi >> 8) & 0xff).toByte + bytes(7) = (hi & 0xff).toByte + bytes(8) = ((lo >> 56) & 0xff).toByte + bytes(9) = ((lo >> 48) & 0xff).toByte + bytes(10) = ((lo >> 40) & 0xff).toByte + bytes(11) = ((lo >> 32) & 0xff).toByte + bytes(12) = ((lo >> 24) & 0xff).toByte + bytes(13) = ((lo >> 16) & 0xff).toByte + bytes(14) = ((lo >> 8) & 0xff).toByte + bytes(15) = (lo & 0xff).toByte + bytes + } +} + +object TraceId { + + /** + * The invalid/zero trace ID (represents "no trace"). + */ + val invalid: TraceId = TraceId(hi = 0L, lo = 0L) + + /** + * Generates a random valid trace ID. + * + * If both hi and lo are zero, regenerates until a valid one is obtained. + */ + def random: TraceId = { + var hi = Random.nextLong() + var lo = Random.nextLong() + while (hi == 0L && lo == 0L) { + hi = Random.nextLong() + lo = Random.nextLong() + } + TraceId(hi = hi, lo = lo) + } + + /** + * Parses a 32-character hexadecimal string into a TraceId. + * + * Returns None if the string is not exactly 32 characters or contains non-hex + * characters. + */ + def fromHex(s: String): Option[TraceId] = + if (s.length != 32) None + else { + try { + val hi = java.lang.Long.parseUnsignedLong(s.substring(0, 16), 16) + val lo = java.lang.Long.parseUnsignedLong(s.substring(16, 32), 16) + Some(TraceId(hi = hi, lo = lo)) + } catch { + case _: NumberFormatException => None + } + } + + /** + * Unscoped instance - TraceId is a safe data type that can escape scopes. + */ +} diff --git a/otel/shared/src/main/scala/zio/blocks/otel/Tracer.scala b/otel/shared/src/main/scala/zio/blocks/otel/Tracer.scala new file mode 100644 index 0000000000..f7954984b3 --- /dev/null +++ b/otel/shared/src/main/scala/zio/blocks/otel/Tracer.scala @@ -0,0 +1,172 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +final class Tracer( + val instrumentationScope: InstrumentationScope, + val resource: Resource, + sampler: Sampler, + processors: Seq[SpanProcessor], + contextStorage: ContextStorage[Option[SpanContext]] +) { + + def spanBuilder(name: String): SpanBuilder = + SpanBuilder(name).setResource(resource).setInstrumentationScope(instrumentationScope) + + def currentSpan: Option[SpanContext] = + contextStorage.get() + + def span[A](name: String)(f: Span => A): A = + span(name, SpanKind.Internal, Attributes.empty)(f) + + def span[A](name: String, kind: SpanKind)(f: Span => A): A = + span(name, kind, Attributes.empty)(f) + + def span[A](name: String, kind: SpanKind, attributes: Attributes)(f: Span => A): A = { + val parentCtx = contextStorage.get() + val traceId = parentCtx match { + case Some(p) if p.isValid => p.traceId + case _ => TraceId.random + } + + val result = sampler.shouldSample( + parentCtx, + traceId, + name, + kind, + attributes, + Seq.empty + ) + + result.decision match { + case SamplingDecision.Drop => + f(Span.NoOp) + + case SamplingDecision.RecordOnly => + val traceFlags = TraceFlags.none + val builder = + SpanBuilder(name).setKind(kind).setResource(resource).setInstrumentationScope(instrumentationScope) + + parentCtx.foreach(p => builder.setParent(p)) + + result.attributes.foreach { (k, v) => + v match { + case AttributeValue.StringValue(s) => builder.setAttribute(AttributeKey.string(k), s) + case AttributeValue.LongValue(l) => builder.setAttribute(AttributeKey.long(k), l) + case AttributeValue.DoubleValue(d) => builder.setAttribute(AttributeKey.double(k), d) + case AttributeValue.BooleanValue(b) => builder.setAttribute(AttributeKey.boolean(k), b) + case _ => () + } + } + + attributes.foreach { (k, v) => + v match { + case AttributeValue.StringValue(s) => builder.setAttribute(AttributeKey.string(k), s) + case AttributeValue.LongValue(l) => builder.setAttribute(AttributeKey.long(k), l) + case AttributeValue.DoubleValue(d) => builder.setAttribute(AttributeKey.double(k), d) + case AttributeValue.BooleanValue(b) => builder.setAttribute(AttributeKey.boolean(k), b) + case _ => () + } + } + + val span = builder.startSpan() + // Override traceFlags to none (not sampled) and apply traceState from SamplingResult + val correctedCtx = span.spanContext.copy( + traceFlags = traceFlags, + traceState = if (result.traceState.nonEmpty) result.traceState else span.spanContext.traceState + ) + val recordOnlySpan = new RecordingSpan( + spanContext = correctedCtx, + name = span.name, + kind = span.kind, + parentSpanContext = span.toSpanData.parentSpanContext, + startTimeNanos = span.toSpanData.startTimeNanos, + initialAttributes = span.toSpanData.attributes, + initialLinks = span.toSpanData.links, + resource = resource, + instrumentationScope = instrumentationScope + ) + + processors.foreach(_.onStart(recordOnlySpan)) + + try + contextStorage.scoped(Some(recordOnlySpan.spanContext)) { + f(recordOnlySpan) + } + finally { + recordOnlySpan.end() + val spanData = recordOnlySpan.toSpanData + processors.foreach(_.onEnd(spanData)) + } + + case SamplingDecision.RecordAndSample => + val builder = + SpanBuilder(name).setKind(kind).setResource(resource).setInstrumentationScope(instrumentationScope) + + parentCtx.foreach(p => builder.setParent(p)) + + result.attributes.foreach { (k, v) => + v match { + case AttributeValue.StringValue(s) => builder.setAttribute(AttributeKey.string(k), s) + case AttributeValue.LongValue(l) => builder.setAttribute(AttributeKey.long(k), l) + case AttributeValue.DoubleValue(d) => builder.setAttribute(AttributeKey.double(k), d) + case AttributeValue.BooleanValue(b) => builder.setAttribute(AttributeKey.boolean(k), b) + case _ => () + } + } + + attributes.foreach { (k, v) => + v match { + case AttributeValue.StringValue(s) => builder.setAttribute(AttributeKey.string(k), s) + case AttributeValue.LongValue(l) => builder.setAttribute(AttributeKey.long(k), l) + case AttributeValue.DoubleValue(d) => builder.setAttribute(AttributeKey.double(k), d) + case AttributeValue.BooleanValue(b) => builder.setAttribute(AttributeKey.boolean(k), b) + case _ => () + } + } + + val span = builder.startSpan() + // Apply traceState from SamplingResult if present + val finalSpan = if (result.traceState.nonEmpty) { + val correctedCtx = span.spanContext.copy(traceState = result.traceState) + new RecordingSpan( + spanContext = correctedCtx, + name = span.name, + kind = span.kind, + parentSpanContext = span.toSpanData.parentSpanContext, + startTimeNanos = span.toSpanData.startTimeNanos, + initialAttributes = span.toSpanData.attributes, + initialLinks = span.toSpanData.links, + resource = resource, + instrumentationScope = instrumentationScope + ) + } else span + + processors.foreach(_.onStart(finalSpan)) + + try + contextStorage.scoped(Some(finalSpan.spanContext)) { + f(finalSpan) + } + finally { + finalSpan.end() + val spanData = finalSpan.toSpanData + processors.foreach(_.onEnd(spanData)) + } + } + } +} diff --git a/otel/shared/src/main/scala/zio/blocks/otel/TracerProvider.scala b/otel/shared/src/main/scala/zio/blocks/otel/TracerProvider.scala new file mode 100644 index 0000000000..3dbb038899 --- /dev/null +++ b/otel/shared/src/main/scala/zio/blocks/otel/TracerProvider.scala @@ -0,0 +1,82 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +final class TracerProvider( + resource: Resource, + sampler: Sampler, + processors: Seq[SpanProcessor], + private[otel] val contextStorage: ContextStorage[Option[SpanContext]] +) { + + def get(name: String, version: String = ""): Tracer = { + val scope = InstrumentationScope( + name = name, + version = if (version.isEmpty) None else Some(version) + ) + new Tracer(scope, resource, sampler, processors, contextStorage) + } + + def shutdown(): Unit = + processors.foreach(_.shutdown()) + + def forceFlush(): Unit = + processors.foreach(_.forceFlush()) +} + +object TracerProvider { + + def builder: TracerProviderBuilder = new TracerProviderBuilder( + resource = Resource.default, + sampler = AlwaysOnSampler, + processors = Seq.empty, + contextStorage = None + ) +} + +final class TracerProviderBuilder private[otel] ( + private var resource: Resource, + private var sampler: Sampler, + private var processors: Seq[SpanProcessor], + private var contextStorage: Option[ContextStorage[Option[SpanContext]]] = None +) { + + def setResource(resource: Resource): TracerProviderBuilder = { + this.resource = resource + this + } + + def setSampler(sampler: Sampler): TracerProviderBuilder = { + this.sampler = sampler + this + } + + def addSpanProcessor(processor: SpanProcessor): TracerProviderBuilder = { + this.processors = this.processors :+ processor + this + } + + def setContextStorage(contextStorage: ContextStorage[Option[SpanContext]]): TracerProviderBuilder = { + this.contextStorage = Some(contextStorage) + this + } + + def build(): TracerProvider = { + val cs = contextStorage.getOrElse(ContextStorage.create[Option[SpanContext]](None)) + new TracerProvider(resource, sampler, processors, cs) + } +} diff --git a/otel/shared/src/main/scala/zio/blocks/otel/W3CTraceContextPropagator.scala b/otel/shared/src/main/scala/zio/blocks/otel/W3CTraceContextPropagator.scala new file mode 100644 index 0000000000..548617c8d4 --- /dev/null +++ b/otel/shared/src/main/scala/zio/blocks/otel/W3CTraceContextPropagator.scala @@ -0,0 +1,69 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +/** + * W3C TraceContext propagator implementing the traceparent/tracestate headers. + * + * traceparent format: + * {version:2hex}-{trace-id:32hex}-{span-id:16hex}-{flags:2hex} Only version + * "00" is supported. + * + * @see + * https://www.w3.org/TR/trace-context/ + */ +object W3CTraceContextPropagator extends Propagator { + + private val TraceparentHeader = "traceparent" + private val TracestateHeader = "tracestate" + private val Version = "00" + private val TraceparentLength = 55 // 2 + 1 + 32 + 1 + 16 + 1 + 2 + + override val fields: Seq[String] = Seq(TraceparentHeader, TracestateHeader) + + override def extract[C](carrier: C, getter: (C, String) => Option[String]): Option[SpanContext] = + for { + raw <- getter(carrier, TraceparentHeader) + traceparent = raw.trim + _ <- if (traceparent.length == TraceparentLength) Some(()) else None + _ <- if (traceparent.charAt(2) == '-' && traceparent.charAt(35) == '-' && traceparent.charAt(52) == '-') Some(()) + else None + version = traceparent.substring(0, 2) + _ <- if (version == Version) Some(()) else None + traceIdHex = traceparent.substring(3, 35).toLowerCase + traceId <- TraceId.fromHex(traceIdHex) + _ <- if (traceId.isValid) Some(()) else None + spanIdHex = traceparent.substring(36, 52).toLowerCase + spanId <- SpanId.fromHex(spanIdHex) + _ <- if (spanId.isValid) Some(()) else None + flagsHex = traceparent.substring(53, 55).toLowerCase + flags <- TraceFlags.fromHex(flagsHex) + } yield { + val traceState = getter(carrier, TracestateHeader).map(_.trim).getOrElse("") + SpanContext.create(traceId, spanId, flags, traceState, isRemote = true) + } + + override def inject[C](spanContext: SpanContext, carrier: C, setter: (C, String, String) => C): C = + if (!spanContext.isValid) carrier + else { + val traceparent = + s"$Version-${spanContext.traceId.toHex}-${spanContext.spanId.toHex}-${spanContext.traceFlags.toHex}" + val withParent = setter(carrier, TraceparentHeader, traceparent) + if (spanContext.traceState.nonEmpty) setter(withParent, TracestateHeader, spanContext.traceState) + else withParent + } +} diff --git a/otel/shared/src/main/scala/zio/blocks/otel/log.scala b/otel/shared/src/main/scala/zio/blocks/otel/log.scala new file mode 100644 index 0000000000..0fd7166d56 --- /dev/null +++ b/otel/shared/src/main/scala/zio/blocks/otel/log.scala @@ -0,0 +1,94 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +object log extends LogVersionSpecific { + + /** + * Run a block with additional contextual annotations attached to all log + * records. + */ + def annotated[A](annotations: (String, String)*)(f: => A): A = + LogAnnotations.scoped(annotations.toMap)(f) + + private[otel] def emit(severity: Severity, message: String, location: SourceLocation): Unit = { + val state = GlobalLogState.get() + if (state != null && severity.number >= state.effectiveLevel(location.namespace)) { + val now = System.nanoTime() + val builder = Attributes.builder + .put("code.filepath", location.filePath) + .put("code.namespace", location.namespace) + .put("code.function", location.methodName) + .put("code.lineno", location.lineNumber.toLong) + + // Merge scoped annotations + val annotations = LogAnnotations.get() + if (annotations.nonEmpty) { + annotations.foreach { case (k, v) => builder.put(k, v) } + } + + val record = LogRecord( + timestampNanos = now, + observedTimestampNanos = now, + severity = severity, + severityText = severity.text, + body = message, + attributes = builder.build, + traceId = None, + spanId = None, + traceFlags = None, + resource = Resource.empty, + instrumentationScope = InstrumentationScope(name = "zio.blocks.otel.log") + ) + + state.logger.emit(record) + } + } + + private[otel] def baseRecord( + severity: Severity, + message: String, + location: SourceLocation + ): LogRecord = { + val now = System.nanoTime() + val builder = Attributes.builder + .put("code.filepath", location.filePath) + .put("code.namespace", location.namespace) + .put("code.function", location.methodName) + .put("code.lineno", location.lineNumber.toLong) + + // Merge scoped annotations + val annotations = LogAnnotations.get() + if (annotations.nonEmpty) { + annotations.foreach { case (k, v) => builder.put(k, v) } + } + + LogRecord( + timestampNanos = now, + observedTimestampNanos = now, + severity = severity, + severityText = severity.text, + body = message, + attributes = builder.build, + traceId = None, + spanId = None, + traceFlags = None, + resource = Resource.empty, + instrumentationScope = InstrumentationScope(name = "zio.blocks.otel.log") + ) + } +} diff --git a/otel/shared/src/test/scala/zio/blocks/otel/AttributesSpec.scala b/otel/shared/src/test/scala/zio/blocks/otel/AttributesSpec.scala new file mode 100644 index 0000000000..5a6bd4872d --- /dev/null +++ b/otel/shared/src/test/scala/zio/blocks/otel/AttributesSpec.scala @@ -0,0 +1,380 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import zio.test._ + +object AttributesSpec extends ZIOSpecDefault { + + def spec = suite("Attributes")( + suite("AttributeKey")( + test("string creates typed key with StringType") { + val key = AttributeKey.string("service.name") + assertTrue( + key.name == "service.name" && + key.`type` == AttributeType.StringType + ) + }, + test("boolean creates typed key with BooleanType") { + val key = AttributeKey.boolean("debug.enabled") + assertTrue( + key.name == "debug.enabled" && + key.`type` == AttributeType.BooleanType + ) + }, + test("long creates typed key with LongType") { + val key = AttributeKey.long("process.pid") + assertTrue( + key.name == "process.pid" && + key.`type` == AttributeType.LongType + ) + }, + test("double creates typed key with DoubleType") { + val key = AttributeKey.double("http.request.size") + assertTrue( + key.name == "http.request.size" && + key.`type` == AttributeType.DoubleType + ) + }, + test("stringSeq creates typed key with StringSeqType") { + val key = AttributeKey.stringSeq("http.request.header.names") + assertTrue( + key.name == "http.request.header.names" && + key.`type` == AttributeType.StringSeqType + ) + }, + test("longSeq creates typed key with LongSeqType") { + val key = AttributeKey.longSeq("memory.usage.history") + assertTrue( + key.name == "memory.usage.history" && + key.`type` == AttributeType.LongSeqType + ) + }, + test("doubleSeq creates typed key with DoubleSeqType") { + val key = AttributeKey.doubleSeq("latency.percentiles") + assertTrue( + key.name == "latency.percentiles" && + key.`type` == AttributeType.DoubleSeqType + ) + }, + test("booleanSeq creates typed key with BooleanSeqType") { + val key = AttributeKey.booleanSeq("flags.enabled") + assertTrue( + key.name == "flags.enabled" && + key.`type` == AttributeType.BooleanSeqType + ) + } + ), + suite("AttributeValue")( + test("StringValue stores string") { + val value = AttributeValue.StringValue("test") + assertTrue(value.asInstanceOf[AttributeValue.StringValue].value == "test") + }, + test("BooleanValue stores boolean") { + val value = AttributeValue.BooleanValue(true) + assertTrue(value.asInstanceOf[AttributeValue.BooleanValue].value == true) + }, + test("LongValue stores long") { + val value = AttributeValue.LongValue(42L) + assertTrue(value.asInstanceOf[AttributeValue.LongValue].value == 42L) + }, + test("DoubleValue stores double") { + val value = AttributeValue.DoubleValue(3.14) + assertTrue(value.asInstanceOf[AttributeValue.DoubleValue].value == 3.14) + }, + test("StringSeqValue stores seq of strings") { + val value = AttributeValue.StringSeqValue(Seq("a", "b", "c")) + assertTrue( + value.asInstanceOf[AttributeValue.StringSeqValue].value == Seq("a", "b", "c") + ) + }, + test("LongSeqValue stores seq of longs") { + val value = AttributeValue.LongSeqValue(Seq(1L, 2L, 3L)) + assertTrue( + value.asInstanceOf[AttributeValue.LongSeqValue].value == Seq(1L, 2L, 3L) + ) + }, + test("DoubleSeqValue stores seq of doubles") { + val value = AttributeValue.DoubleSeqValue(Seq(1.0, 2.5, 3.14)) + assertTrue( + value.asInstanceOf[AttributeValue.DoubleSeqValue].value == Seq(1.0, 2.5, 3.14) + ) + }, + test("BooleanSeqValue stores seq of booleans") { + val value = AttributeValue.BooleanSeqValue(Seq(true, false, true)) + assertTrue( + value.asInstanceOf[AttributeValue.BooleanSeqValue].value == Seq(true, false, true) + ) + } + ), + suite("Attributes.empty")( + test("is empty") { + assertTrue(Attributes.empty.isEmpty) + }, + test("has size 0") { + assertTrue(Attributes.empty.size == 0) + }, + test("get returns None") { + val key = AttributeKey.string("test") + assertTrue(Attributes.empty.get(key).isEmpty) + }, + test("toMap returns empty Map") { + assertTrue(Attributes.empty.toMap == (Map.empty[String, AttributeValue])) + } + ), + suite("Attributes.of")( + test("creates single-attribute Attributes") { + val key = AttributeKey.string("service.name") + val attrs = Attributes.of(key, "my-service") + assertTrue(attrs.size == 1) + }, + test("typed get retrieves value") { + val key = AttributeKey.string("service.name") + val attrs = Attributes.of(key, "my-service") + assertTrue(attrs.get(key).contains("my-service")) + }, + test("typed get returns None for different key") { + val key1 = AttributeKey.string("service.name") + val key2 = AttributeKey.string("service.version") + val attrs = Attributes.of(key1, "my-service") + assertTrue(attrs.get(key2).isEmpty) + }, + test("works with boolean values") { + val key = AttributeKey.boolean("debug.enabled") + val attrs = Attributes.of(key, true) + assertTrue(attrs.get(key).contains(true)) + }, + test("works with long values") { + val key = AttributeKey.long("process.pid") + val attrs = Attributes.of(key, 12345L) + assertTrue(attrs.get(key).contains(12345L)) + }, + test("works with double values") { + val key = AttributeKey.double("http.request.size") + val attrs = Attributes.of(key, 1024.5) + assertTrue(attrs.get(key).contains(1024.5)) + }, + test("works with string seq values") { + val key = AttributeKey.stringSeq("tags") + val attrs = Attributes.of(key, Seq("tag1", "tag2")) + assertTrue(attrs.get(key).contains(Seq("tag1", "tag2"))) + } + ), + suite("Attributes.builder")( + test("empty builder creates empty Attributes") { + val attrs = Attributes.builder.build + assertTrue(attrs.isEmpty) + }, + test("put string adds attribute") { + val attrs = Attributes.builder + .put("service.name", "my-service") + .build + assertTrue(attrs.size == 1) + }, + test("put long adds attribute") { + val attrs = Attributes.builder + .put("process.pid", 12345L) + .build + assertTrue(attrs.size == 1) + }, + test("put double adds attribute") { + val attrs = Attributes.builder + .put("latency", 123.45) + .build + assertTrue(attrs.size == 1) + }, + test("put boolean adds attribute") { + val attrs = Attributes.builder + .put("debug", true) + .build + assertTrue(attrs.size == 1) + }, + test("put typed adds attribute") { + val key = AttributeKey.string("service.name") + val attrs = Attributes.builder + .put(key, "my-service") + .build + assertTrue(attrs.size == 1 && attrs.get(key).contains("my-service")) + }, + test("multiple puts accumulate") { + val attrs = Attributes.builder + .put("service.name", "my-service") + .put("service.version", "1.0.0") + .put("debug", true) + .build + assertTrue(attrs.size == 3) + }, + test("later puts override earlier ones with same key") { + val attrs = Attributes.builder + .put("service.name", "old") + .put("service.name", "new") + .build + assertTrue(attrs.size == 1) + }, + test("typed and untyped puts can coexist") { + val key = AttributeKey.string("service.name") + val attrs = Attributes.builder + .put(key, "my-service") + .put("other", "value") + .build + assertTrue( + attrs.size == 2 && + attrs.get(key).contains("my-service") + ) + } + ), + suite("Attributes.get")( + test("retrieves typed value by key") { + val key = AttributeKey.string("service.name") + val attrs = Attributes.of(key, "my-service") + assertTrue(attrs.get(key).contains("my-service")) + }, + test("returns None for missing key") { + val key1 = AttributeKey.string("service.name") + val key2 = AttributeKey.string("service.version") + val attrs = Attributes.of(key1, "my-service") + assertTrue(attrs.get(key2).isEmpty) + }, + test("returns None for empty Attributes") { + val key = AttributeKey.string("service.name") + assertTrue(Attributes.empty.get(key).isEmpty) + }, + test("handles multiple attributes correctly") { + val key1 = AttributeKey.string("service.name") + val key2 = AttributeKey.long("process.pid") + val attrs = Attributes.builder + .put(key1, "my-service") + .put(key2, 12345L) + .build + assertTrue( + attrs.get(key1).contains("my-service") && + attrs.get(key2).contains(12345L) + ) + } + ), + suite("Attributes.size and isEmpty")( + test("empty Attributes has size 0") { + assertTrue(Attributes.empty.size == 0) + }, + test("empty Attributes is empty") { + assertTrue(Attributes.empty.isEmpty) + }, + test("single attribute has size 1") { + val attrs = Attributes.of(AttributeKey.string("key"), "value") + assertTrue(attrs.size == 1 && !attrs.isEmpty) + }, + test("three attributes have size 3") { + val attrs = Attributes.builder + .put("a", "1") + .put("b", "2") + .put("c", "3") + .build + assertTrue(attrs.size == 3 && !attrs.isEmpty) + } + ), + suite("Attributes.foreach")( + test("iterates over all attributes") { + val attrs = Attributes.builder + .put("key1", "value1") + .put("key2", "value2") + .build + var count = 0 + attrs.foreach { (_, _) => + count += 1 + } + assertTrue(count == 2) + }, + test("provides correct key-value pairs") { + val attrs = Attributes.of(AttributeKey.string("test"), "value") + var foundKey: String = null + var foundValue: String = null + attrs.foreach { (k, v) => + foundKey = k + foundValue = v.asInstanceOf[AttributeValue.StringValue].value + } + assertTrue(foundKey == "test" && foundValue == "value") + }, + test("iteration works for empty Attributes") { + var count = 0 + Attributes.empty.foreach { (_, _) => + count += 1 + } + assertTrue(count == 0) + } + ), + suite("Attributes.++")( + test("merges two Attributes") { + val attrs1 = Attributes.of(AttributeKey.string("key1"), "value1") + val attrs2 = Attributes.of(AttributeKey.string("key2"), "value2") + val merged = attrs1 ++ attrs2 + assertTrue(merged.size == 2) + }, + test("right side wins on key conflict") { + val attrs1 = Attributes.of(AttributeKey.string("key"), "old") + val attrs2 = Attributes.of(AttributeKey.string("key"), "new") + val merged = attrs1 ++ attrs2 + assertTrue(merged.size == 1) + val key = AttributeKey.string("key") + assertTrue(merged.get(key).contains("new")) + }, + test("merging with empty left preserves right") { + val attrs = Attributes.of(AttributeKey.string("key"), "value") + val merged = Attributes.empty ++ attrs + assertTrue(merged.size == 1) + }, + test("merging with empty right preserves left") { + val attrs = Attributes.of(AttributeKey.string("key"), "value") + val merged = attrs ++ Attributes.empty + assertTrue(merged.size == 1) + } + ), + suite("Attributes.toMap")( + test("converts to Map with string keys and AttributeValue values") { + val attrs = Attributes.of(AttributeKey.string("test"), "value") + val map = attrs.toMap + assertTrue(map.size == 1 && map.contains("test")) + }, + test("empty Attributes converts to empty Map") { + assertTrue(Attributes.empty.toMap == (Map.empty[String, AttributeValue])) + }, + test("preserves all key-value pairs") { + val attrs = Attributes.builder + .put("a", "1") + .put("b", "2") + .build + val map = attrs.toMap + assertTrue(map.size == 2 && map.contains("a") && map.contains("b")) + } + ), + suite("Attributes.predefined keys")( + test("ServiceName is string AttributeKey") { + val key = Attributes.ServiceName + assertTrue( + key.name == "service.name" && + key.`type` == AttributeType.StringType + ) + }, + test("ServiceVersion is string AttributeKey") { + val key = Attributes.ServiceVersion + assertTrue( + key.name == "service.version" && + key.`type` == AttributeType.StringType + ) + } + ) + ) +} diff --git a/otel/shared/src/test/scala/zio/blocks/otel/B3PropagatorSpec.scala b/otel/shared/src/test/scala/zio/blocks/otel/B3PropagatorSpec.scala new file mode 100644 index 0000000000..110ef6c16d --- /dev/null +++ b/otel/shared/src/test/scala/zio/blocks/otel/B3PropagatorSpec.scala @@ -0,0 +1,477 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import zio.test._ + +object B3PropagatorSpec extends ZIOSpecDefault { + + private type Headers = Map[String, String] + + private val getter: (Headers, String) => Option[String] = (carrier, key) => carrier.get(key) + + private val setter: (Headers, String, String) => Headers = (carrier, key, value) => carrier + (key -> value) + + private val traceIdHex = "4bf92f3577b34da6a3ce929d0e0e4736" + private val spanIdHex = "00f067aa0ba902b7" + + def spec = suite("B3Propagator")( + suite("B3SinglePropagator")( + singleFieldsSuite, + singleExtractSuite, + singleInjectSuite, + singleRoundtripSuite + ), + suite("B3MultiPropagator")( + multiFieldsSuite, + multiExtractSuite, + multiInjectSuite, + multiRoundtripSuite + ) + ) + + private val singleFieldsSuite = suite("fields")( + test("returns b3") { + assertTrue(B3Propagator.single.fields == Seq("b3")) + } + ) + + private val singleExtractSuite = suite("extract")( + test("parses valid full format with sampling and parentSpanId") { + val headers = Map("b3" -> s"$traceIdHex-$spanIdHex-1-0000000000000001") + val result = B3Propagator.single.extract(headers, getter) + assertTrue( + result.isDefined && + result.get.traceId.toHex == traceIdHex && + result.get.spanId.toHex == spanIdHex && + result.get.traceFlags.isSampled && + result.get.isRemote + ) + }, + test("parses valid format without sampling") { + val headers = Map("b3" -> s"$traceIdHex-$spanIdHex") + val result = B3Propagator.single.extract(headers, getter) + assertTrue( + result.isDefined && + result.get.traceId.toHex == traceIdHex && + result.get.spanId.toHex == spanIdHex && + !result.get.traceFlags.isSampled && + result.get.isRemote + ) + }, + test("parses valid format with sampling but without parentSpanId") { + val headers = Map("b3" -> s"$traceIdHex-$spanIdHex-1") + val result = B3Propagator.single.extract(headers, getter) + assertTrue( + result.isDefined && + result.get.traceFlags.isSampled + ) + }, + test("parses 16-char traceId by padding to 32 chars") { + val shortTraceId = "a3ce929d0e0e4736" + val headers = Map("b3" -> s"$shortTraceId-$spanIdHex-1") + val result = B3Propagator.single.extract(headers, getter) + assertTrue( + result.isDefined && + result.get.traceId.toHex == "0000000000000000a3ce929d0e0e4736" + ) + }, + test("parses deny/drop single value '0'") { + val headers = Map("b3" -> "0") + val result = B3Propagator.single.extract(headers, getter) + assertTrue(result.isEmpty) + }, + test("parses debug flag 'd' as sampled") { + val headers = Map("b3" -> s"$traceIdHex-$spanIdHex-d") + val result = B3Propagator.single.extract(headers, getter) + assertTrue( + result.isDefined && + result.get.traceFlags.isSampled + ) + }, + test("parses sampling '0' as not sampled") { + val headers = Map("b3" -> s"$traceIdHex-$spanIdHex-0") + val result = B3Propagator.single.extract(headers, getter) + assertTrue( + result.isDefined && + !result.get.traceFlags.isSampled + ) + }, + test("returns None for missing b3 header") { + val headers = Map.empty[String, String] + val result = B3Propagator.single.extract(headers, getter) + assertTrue(result.isEmpty) + }, + test("returns None for empty b3 header") { + val headers = Map("b3" -> "") + val result = B3Propagator.single.extract(headers, getter) + assertTrue(result.isEmpty) + }, + test("returns None for malformed traceId (non-hex)") { + val headers = Map("b3" -> s"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz-$spanIdHex-1") + val result = B3Propagator.single.extract(headers, getter) + assertTrue(result.isEmpty) + }, + test("returns None for malformed spanId (non-hex)") { + val headers = Map("b3" -> s"$traceIdHex-zzzzzzzzzzzzzzzz-1") + val result = B3Propagator.single.extract(headers, getter) + assertTrue(result.isEmpty) + }, + test("returns None for invalid traceId length") { + val headers = Map("b3" -> s"4bf92f35-$spanIdHex-1") + val result = B3Propagator.single.extract(headers, getter) + assertTrue(result.isEmpty) + }, + test("returns None for invalid spanId length") { + val headers = Map("b3" -> s"$traceIdHex-00f067aa-1") + val result = B3Propagator.single.extract(headers, getter) + assertTrue(result.isEmpty) + }, + test("returns None for all-zero traceId") { + val headers = Map("b3" -> s"00000000000000000000000000000000-$spanIdHex-1") + val result = B3Propagator.single.extract(headers, getter) + assertTrue(result.isEmpty) + }, + test("returns None for all-zero spanId") { + val headers = Map("b3" -> s"$traceIdHex-0000000000000000-1") + val result = B3Propagator.single.extract(headers, getter) + assertTrue(result.isEmpty) + }, + test("handles uppercase hex") { + val headers = Map("b3" -> s"${traceIdHex.toUpperCase}-${spanIdHex.toUpperCase}-1") + val result = B3Propagator.single.extract(headers, getter) + assertTrue( + result.isDefined && + result.get.traceId.toHex == traceIdHex && + result.get.spanId.toHex == spanIdHex + ) + }, + test("returns None for whitespace-only value") { + val headers = Map("b3" -> " ") + val result = B3Propagator.single.extract(headers, getter) + assertTrue(result.isEmpty) + } + ) + + private val singleInjectSuite = suite("inject")( + test("formats correctly with sampled flag") { + val traceId = TraceId.fromHex(traceIdHex).get + val spanId = SpanId.fromHex(spanIdHex).get + val ctx = SpanContext.create(traceId, spanId, TraceFlags.sampled, "", isRemote = false) + + val headers = B3Propagator.single.inject(ctx, Map.empty[String, String], setter) + assertTrue(headers("b3") == s"$traceIdHex-$spanIdHex-1") + }, + test("formats correctly with unsampled flag") { + val traceId = TraceId.fromHex(traceIdHex).get + val spanId = SpanId.fromHex(spanIdHex).get + val ctx = SpanContext.create(traceId, spanId, TraceFlags.none, "", isRemote = false) + + val headers = B3Propagator.single.inject(ctx, Map.empty[String, String], setter) + assertTrue(headers("b3") == s"$traceIdHex-$spanIdHex-0") + }, + test("does not inject invalid span context") { + val headers = B3Propagator.single.inject(SpanContext.invalid, Map.empty[String, String], setter) + assertTrue(headers.isEmpty) + }, + test("preserves existing carrier entries") { + val traceId = TraceId.fromHex(traceIdHex).get + val spanId = SpanId.fromHex(spanIdHex).get + val ctx = SpanContext.create(traceId, spanId, TraceFlags.sampled, "", isRemote = false) + val existingHeaders = Map("x-custom" -> "keep-me") + + val headers = B3Propagator.single.inject(ctx, existingHeaders, setter) + assertTrue( + headers("x-custom") == "keep-me" && + headers.contains("b3") + ) + } + ) + + private val singleRoundtripSuite = suite("roundtrip")( + test("inject then extract produces equivalent SpanContext") { + val traceId = TraceId.fromHex(traceIdHex).get + val spanId = SpanId.fromHex(spanIdHex).get + val original = SpanContext.create(traceId, spanId, TraceFlags.sampled, "", isRemote = false) + + val headers = B3Propagator.single.inject(original, Map.empty[String, String], setter) + val restored = B3Propagator.single.extract(headers, getter) + + assertTrue( + restored.isDefined && + restored.get.traceId == original.traceId && + restored.get.spanId == original.spanId && + restored.get.traceFlags == original.traceFlags && + restored.get.isRemote + ) + }, + test("roundtrip with unsampled") { + val traceId = TraceId.fromHex(traceIdHex).get + val spanId = SpanId.fromHex(spanIdHex).get + val original = SpanContext.create(traceId, spanId, TraceFlags.none, "", isRemote = false) + + val headers = B3Propagator.single.inject(original, Map.empty[String, String], setter) + val restored = B3Propagator.single.extract(headers, getter) + + assertTrue( + restored.isDefined && + restored.get.traceId == original.traceId && + restored.get.spanId == original.spanId && + !restored.get.traceFlags.isSampled + ) + } + ) + + private val multiFieldsSuite = suite("fields")( + test("returns all B3 multi-header field names") { + assertTrue( + B3Propagator.multi.fields == Seq( + "X-B3-TraceId", + "X-B3-SpanId", + "X-B3-Sampled", + "X-B3-ParentSpanId", + "X-B3-Flags" + ) + ) + } + ) + + private val multiExtractSuite = suite("extract")( + test("parses all headers present") { + val headers = Map( + "X-B3-TraceId" -> traceIdHex, + "X-B3-SpanId" -> spanIdHex, + "X-B3-Sampled" -> "1", + "X-B3-ParentSpanId" -> "0000000000000001" + ) + val result = B3Propagator.multi.extract(headers, getter) + assertTrue( + result.isDefined && + result.get.traceId.toHex == traceIdHex && + result.get.spanId.toHex == spanIdHex && + result.get.traceFlags.isSampled && + result.get.isRemote + ) + }, + test("parses with missing optional Sampled header (defaults to not sampled)") { + val headers = Map( + "X-B3-TraceId" -> traceIdHex, + "X-B3-SpanId" -> spanIdHex + ) + val result = B3Propagator.multi.extract(headers, getter) + assertTrue( + result.isDefined && + !result.get.traceFlags.isSampled + ) + }, + test("parses with Sampled=0 as not sampled") { + val headers = Map( + "X-B3-TraceId" -> traceIdHex, + "X-B3-SpanId" -> spanIdHex, + "X-B3-Sampled" -> "0" + ) + val result = B3Propagator.multi.extract(headers, getter) + assertTrue( + result.isDefined && + !result.get.traceFlags.isSampled + ) + }, + test("parses Flags=1 as sampled (debug implies sampled)") { + val headers = Map( + "X-B3-TraceId" -> traceIdHex, + "X-B3-SpanId" -> spanIdHex, + "X-B3-Flags" -> "1" + ) + val result = B3Propagator.multi.extract(headers, getter) + assertTrue( + result.isDefined && + result.get.traceFlags.isSampled + ) + }, + test("Flags=1 overrides Sampled=0") { + val headers = Map( + "X-B3-TraceId" -> traceIdHex, + "X-B3-SpanId" -> spanIdHex, + "X-B3-Sampled" -> "0", + "X-B3-Flags" -> "1" + ) + val result = B3Propagator.multi.extract(headers, getter) + assertTrue( + result.isDefined && + result.get.traceFlags.isSampled + ) + }, + test("returns None for missing TraceId") { + val headers = Map("X-B3-SpanId" -> spanIdHex, "X-B3-Sampled" -> "1") + val result = B3Propagator.multi.extract(headers, getter) + assertTrue(result.isEmpty) + }, + test("returns None for missing SpanId") { + val headers = Map("X-B3-TraceId" -> traceIdHex, "X-B3-Sampled" -> "1") + val result = B3Propagator.multi.extract(headers, getter) + assertTrue(result.isEmpty) + }, + test("returns None for malformed TraceId") { + val headers = Map( + "X-B3-TraceId" -> "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", + "X-B3-SpanId" -> spanIdHex, + "X-B3-Sampled" -> "1" + ) + val result = B3Propagator.multi.extract(headers, getter) + assertTrue(result.isEmpty) + }, + test("returns None for malformed SpanId") { + val headers = Map( + "X-B3-TraceId" -> traceIdHex, + "X-B3-SpanId" -> "zzzzzzzzzzzzzzzz", + "X-B3-Sampled" -> "1" + ) + val result = B3Propagator.multi.extract(headers, getter) + assertTrue(result.isEmpty) + }, + test("returns None for all-zero traceId") { + val headers = Map( + "X-B3-TraceId" -> "00000000000000000000000000000000", + "X-B3-SpanId" -> spanIdHex, + "X-B3-Sampled" -> "1" + ) + val result = B3Propagator.multi.extract(headers, getter) + assertTrue(result.isEmpty) + }, + test("returns None for all-zero spanId") { + val headers = Map( + "X-B3-TraceId" -> traceIdHex, + "X-B3-SpanId" -> "0000000000000000", + "X-B3-Sampled" -> "1" + ) + val result = B3Propagator.multi.extract(headers, getter) + assertTrue(result.isEmpty) + }, + test("parses 16-char traceId by padding to 32 chars") { + val shortTraceId = "a3ce929d0e0e4736" + val headers = Map( + "X-B3-TraceId" -> shortTraceId, + "X-B3-SpanId" -> spanIdHex, + "X-B3-Sampled" -> "1" + ) + val result = B3Propagator.multi.extract(headers, getter) + assertTrue( + result.isDefined && + result.get.traceId.toHex == "0000000000000000a3ce929d0e0e4736" + ) + }, + test("handles uppercase hex") { + val headers = Map( + "X-B3-TraceId" -> traceIdHex.toUpperCase, + "X-B3-SpanId" -> spanIdHex.toUpperCase, + "X-B3-Sampled" -> "1" + ) + val result = B3Propagator.multi.extract(headers, getter) + assertTrue( + result.isDefined && + result.get.traceId.toHex == traceIdHex && + result.get.spanId.toHex == spanIdHex + ) + }, + test("returns None for empty TraceId") { + val headers = Map( + "X-B3-TraceId" -> "", + "X-B3-SpanId" -> spanIdHex, + "X-B3-Sampled" -> "1" + ) + val result = B3Propagator.multi.extract(headers, getter) + assertTrue(result.isEmpty) + } + ) + + private val multiInjectSuite = suite("inject")( + test("sets correct headers with sampled") { + val traceId = TraceId.fromHex(traceIdHex).get + val spanId = SpanId.fromHex(spanIdHex).get + val ctx = SpanContext.create(traceId, spanId, TraceFlags.sampled, "", isRemote = false) + + val headers = B3Propagator.multi.inject(ctx, Map.empty[String, String], setter) + assertTrue( + headers("X-B3-TraceId") == traceIdHex && + headers("X-B3-SpanId") == spanIdHex && + headers("X-B3-Sampled") == "1" + ) + }, + test("sets correct headers with unsampled") { + val traceId = TraceId.fromHex(traceIdHex).get + val spanId = SpanId.fromHex(spanIdHex).get + val ctx = SpanContext.create(traceId, spanId, TraceFlags.none, "", isRemote = false) + + val headers = B3Propagator.multi.inject(ctx, Map.empty[String, String], setter) + assertTrue( + headers("X-B3-TraceId") == traceIdHex && + headers("X-B3-SpanId") == spanIdHex && + headers("X-B3-Sampled") == "0" + ) + }, + test("does not inject invalid span context") { + val headers = B3Propagator.multi.inject(SpanContext.invalid, Map.empty[String, String], setter) + assertTrue(headers.isEmpty) + }, + test("preserves existing carrier entries") { + val traceId = TraceId.fromHex(traceIdHex).get + val spanId = SpanId.fromHex(spanIdHex).get + val ctx = SpanContext.create(traceId, spanId, TraceFlags.sampled, "", isRemote = false) + val existingHeaders = Map("x-custom" -> "keep-me") + + val headers = B3Propagator.multi.inject(ctx, existingHeaders, setter) + assertTrue( + headers("x-custom") == "keep-me" && + headers.contains("X-B3-TraceId") + ) + } + ) + + private val multiRoundtripSuite = suite("roundtrip")( + test("inject then extract produces equivalent SpanContext") { + val traceId = TraceId.fromHex(traceIdHex).get + val spanId = SpanId.fromHex(spanIdHex).get + val original = SpanContext.create(traceId, spanId, TraceFlags.sampled, "", isRemote = false) + + val headers = B3Propagator.multi.inject(original, Map.empty[String, String], setter) + val restored = B3Propagator.multi.extract(headers, getter) + + assertTrue( + restored.isDefined && + restored.get.traceId == original.traceId && + restored.get.spanId == original.spanId && + restored.get.traceFlags == original.traceFlags && + restored.get.isRemote + ) + }, + test("roundtrip with unsampled") { + val traceId = TraceId.fromHex(traceIdHex).get + val spanId = SpanId.fromHex(spanIdHex).get + val original = SpanContext.create(traceId, spanId, TraceFlags.none, "", isRemote = false) + + val headers = B3Propagator.multi.inject(original, Map.empty[String, String], setter) + val restored = B3Propagator.multi.extract(headers, getter) + + assertTrue( + restored.isDefined && + restored.get.traceId == original.traceId && + restored.get.spanId == original.spanId && + !restored.get.traceFlags.isSampled + ) + } + ) +} diff --git a/otel/shared/src/test/scala/zio/blocks/otel/InstrumentationScopeSpec.scala b/otel/shared/src/test/scala/zio/blocks/otel/InstrumentationScopeSpec.scala new file mode 100644 index 0000000000..539cd1e3a7 --- /dev/null +++ b/otel/shared/src/test/scala/zio/blocks/otel/InstrumentationScopeSpec.scala @@ -0,0 +1,191 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import zio.test._ + +object InstrumentationScopeSpec extends ZIOSpecDefault { + + def spec = suite("InstrumentationScope")( + suite("constructor with all parameters")( + test("stores name, version (Option), and attributes") { + val attrs = Attributes.builder + .put(Attributes.ServiceName, "test-service") + .build + val scope = + InstrumentationScope("my.instrumentation", Some("1.2.3"), attrs) + assertTrue( + scope.name == "my.instrumentation" && + scope.version == Some("1.2.3") && + scope.attributes.size == 1 + ) + }, + test("accepts None version") { + val attrs = Attributes.builder + .put(Attributes.ServiceName, "test-service") + .build + val scope = + InstrumentationScope("my.instrumentation", None, attrs) + assertTrue( + scope.name == "my.instrumentation" && + scope.version == None && + scope.attributes.size == 1 + ) + }, + test("accepts empty attributes") { + val scope = + InstrumentationScope("my.instrumentation", Some("1.0.0"), Attributes.empty) + assertTrue(scope.attributes.isEmpty) + } + ), + suite("constructor with name only")( + test("creates scope with no version and empty attrs") { + val scope = InstrumentationScope("my.instrumentation") + assertTrue( + scope.name == "my.instrumentation" && + scope.version == None && + scope.attributes.isEmpty + ) + }, + test("stores the name correctly") { + val scope = InstrumentationScope("io.opentelemetry") + assertTrue(scope.name == "io.opentelemetry") + } + ), + suite("constructor with name and version")( + test("creates scope with version and empty attrs") { + val scope = + InstrumentationScope("my.instrumentation", Some("2.0.0")) + assertTrue( + scope.name == "my.instrumentation" && + scope.version == Some("2.0.0") && + scope.attributes.isEmpty + ) + }, + test("stores version as Some") { + val scope = + InstrumentationScope("my.instrumentation", Some("1.5.0")) + assertTrue(scope.version == Some("1.5.0")) + } + ), + suite("name accessor")( + test("returns the instrumentation scope name") { + val scope = InstrumentationScope("com.example.lib", Some("1.0.0")) + assertTrue(scope.name == "com.example.lib") + } + ), + suite("version accessor")( + test("returns Some(version) when version is provided") { + val scope = InstrumentationScope("my.lib", Some("3.2.1")) + assertTrue(scope.version == Some("3.2.1")) + }, + test("returns None when version is not provided") { + val scope = InstrumentationScope("my.lib") + assertTrue(scope.version == None) + } + ), + suite("attributes accessor")( + test("returns the underlying Attributes") { + val attrs = Attributes.builder + .put(Attributes.ServiceName, "test-service") + .build + val scope = + InstrumentationScope("my.lib", Some("1.0.0"), attrs) + val retrieved = scope.attributes + val serviceName = + retrieved.get(Attributes.ServiceName) + assertTrue(serviceName.contains("test-service")) + }, + test("returns empty Attributes when none provided") { + val scope = InstrumentationScope("my.lib") + assertTrue(scope.attributes.isEmpty) + }, + test("is immutable (returned Attributes cannot be modified)") { + val attrs = Attributes.builder + .put(Attributes.ServiceName, "test-service") + .build + val scope = + InstrumentationScope("my.lib", Some("1.0.0"), attrs) + val retrieved = scope.attributes + assertTrue(retrieved.size == 1) + } + ), + suite("equality")( + test("two scopes with same name and version have same properties") { + val attrs1 = Attributes.builder + .put(Attributes.ServiceName, "test-service") + .build + val attrs2 = Attributes.builder + .put(Attributes.ServiceName, "test-service") + .build + val scope1 = + InstrumentationScope("my.lib", Some("1.0.0"), attrs1) + val scope2 = + InstrumentationScope("my.lib", Some("1.0.0"), attrs2) + assertTrue( + scope1.name == scope2.name && + scope1.version == scope2.version && + scope1.attributes.size == scope2.attributes.size + ) + }, + test("scopes with different names are not equal") { + val scope1 = InstrumentationScope("lib1") + val scope2 = InstrumentationScope("lib2") + assertTrue(scope1 != scope2) + }, + test("scopes with different versions are not equal") { + val scope1 = InstrumentationScope("my.lib", Some("1.0.0")) + val scope2 = InstrumentationScope("my.lib", Some("2.0.0")) + assertTrue(scope1 != scope2) + }, + test("scope with version != scope without version") { + val scope1 = InstrumentationScope("my.lib", Some("1.0.0")) + val scope2 = InstrumentationScope("my.lib") + assertTrue(scope1 != scope2) + }, + test("scopes with different attributes are not equal") { + val scope1 = InstrumentationScope( + "my.lib", + Some("1.0.0"), + Attributes.builder + .put(Attributes.ServiceName, "service-1") + .build + ) + val scope2 = InstrumentationScope( + "my.lib", + Some("1.0.0"), + Attributes.builder + .put(Attributes.ServiceName, "service-2") + .build + ) + assertTrue(scope1 != scope2) + } + ), + suite("immutability")( + test("is a case class (implicit immutability)") { + val scope1 = InstrumentationScope("my.lib", Some("1.0.0")) + val scope2 = InstrumentationScope("my.lib", Some("1.0.0")) + assertTrue(scope1 == scope2) + }, + test("constructor creates new instances") { + val scope1 = InstrumentationScope("my.lib", Some("1.0.0")) + val scope2 = InstrumentationScope("my.lib", Some("1.0.0")) + assertTrue(scope1 ne scope2) // Different object references + } + ) + ) +} diff --git a/otel/shared/src/test/scala/zio/blocks/otel/LogRecordSpec.scala b/otel/shared/src/test/scala/zio/blocks/otel/LogRecordSpec.scala new file mode 100644 index 0000000000..790a8d91da --- /dev/null +++ b/otel/shared/src/test/scala/zio/blocks/otel/LogRecordSpec.scala @@ -0,0 +1,330 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import zio.test._ + +object LogRecordSpec extends ZIOSpecDefault { + + def spec = suite("LogRecord")( + suite("Severity")( + suite("levels")( + test("Trace has number 1") { + assertTrue(Severity.Trace.number == 1) + }, + test("Trace2 has number 2") { + assertTrue(Severity.Trace2.number == 2) + }, + test("Trace3 has number 3") { + assertTrue(Severity.Trace3.number == 3) + }, + test("Trace4 has number 4") { + assertTrue(Severity.Trace4.number == 4) + }, + test("Debug has number 5") { + assertTrue(Severity.Debug.number == 5) + }, + test("Debug2 has number 6") { + assertTrue(Severity.Debug2.number == 6) + }, + test("Debug3 has number 7") { + assertTrue(Severity.Debug3.number == 7) + }, + test("Debug4 has number 8") { + assertTrue(Severity.Debug4.number == 8) + }, + test("Info has number 9") { + assertTrue(Severity.Info.number == 9) + }, + test("Info2 has number 10") { + assertTrue(Severity.Info2.number == 10) + }, + test("Info3 has number 11") { + assertTrue(Severity.Info3.number == 11) + }, + test("Info4 has number 12") { + assertTrue(Severity.Info4.number == 12) + }, + test("Warn has number 13") { + assertTrue(Severity.Warn.number == 13) + }, + test("Warn2 has number 14") { + assertTrue(Severity.Warn2.number == 14) + }, + test("Warn3 has number 15") { + assertTrue(Severity.Warn3.number == 15) + }, + test("Warn4 has number 16") { + assertTrue(Severity.Warn4.number == 16) + }, + test("Error has number 17") { + assertTrue(Severity.Error.number == 17) + }, + test("Error2 has number 18") { + assertTrue(Severity.Error2.number == 18) + }, + test("Error3 has number 19") { + assertTrue(Severity.Error3.number == 19) + }, + test("Error4 has number 20") { + assertTrue(Severity.Error4.number == 20) + }, + test("Fatal has number 21") { + assertTrue(Severity.Fatal.number == 21) + }, + test("Fatal2 has number 22") { + assertTrue(Severity.Fatal2.number == 22) + }, + test("Fatal3 has number 23") { + assertTrue(Severity.Fatal3.number == 23) + }, + test("Fatal4 has number 24") { + assertTrue(Severity.Fatal4.number == 24) + } + ), + suite("text values")( + test("Trace has text TRACE") { + assertTrue(Severity.Trace.text == "TRACE") + }, + test("Debug has text DEBUG") { + assertTrue(Severity.Debug.text == "DEBUG") + }, + test("Info has text INFO") { + assertTrue(Severity.Info.text == "INFO") + }, + test("Warn has text WARN") { + assertTrue(Severity.Warn.text == "WARN") + }, + test("Error has text ERROR") { + assertTrue(Severity.Error.text == "ERROR") + }, + test("Fatal has text FATAL") { + assertTrue(Severity.Fatal.text == "FATAL") + }, + test("Trace2 inherits TRACE text") { + assertTrue(Severity.Trace2.text == "TRACE") + }, + test("Info3 inherits INFO text") { + assertTrue(Severity.Info3.text == "INFO") + } + ), + suite("fromNumber")( + test("returns Trace for 1") { + assertTrue(Severity.fromNumber(1) == Some(Severity.Trace)) + }, + test("returns Warn for 13") { + assertTrue(Severity.fromNumber(13) == Some(Severity.Warn)) + }, + test("returns Fatal4 for 24") { + assertTrue(Severity.fromNumber(24) == Some(Severity.Fatal4)) + }, + test("returns None for 0") { + assertTrue(Severity.fromNumber(0).isEmpty) + }, + test("returns None for 25") { + assertTrue(Severity.fromNumber(25).isEmpty) + }, + test("returns None for negative") { + assertTrue(Severity.fromNumber(-1).isEmpty) + } + ), + suite("fromText")( + test("returns Some(Trace) for 'TRACE'") { + assertTrue(Severity.fromText("TRACE").isDefined) + }, + test("returns Some for lowercase 'trace'") { + assertTrue(Severity.fromText("trace").isDefined) + }, + test("returns Some for mixed case 'TrAcE'") { + assertTrue(Severity.fromText("TrAcE").isDefined) + }, + test("returns Some for 'DEBUG'") { + assertTrue(Severity.fromText("DEBUG").isDefined) + }, + test("returns Some for 'INFO'") { + assertTrue(Severity.fromText("INFO").isDefined) + }, + test("returns Some for 'WARN'") { + assertTrue(Severity.fromText("WARN").isDefined) + }, + test("returns Some for 'ERROR'") { + assertTrue(Severity.fromText("ERROR").isDefined) + }, + test("returns Some for 'FATAL'") { + assertTrue(Severity.fromText("FATAL").isDefined) + }, + test("returns None for invalid text") { + assertTrue(Severity.fromText("INVALID").isEmpty) + }, + test("returns None for empty string") { + assertTrue(Severity.fromText("").isEmpty) + } + ) + ), + suite("LogRecord")( + suite("creation")( + test("creates with all fields") { + val record = LogRecord( + timestampNanos = 1000L, + observedTimestampNanos = 2000L, + severity = Severity.Info, + severityText = "INFO", + body = "Test log", + attributes = Attributes.empty, + traceId = None, + spanId = None, + traceFlags = None, + resource = Resource.empty, + instrumentationScope = InstrumentationScope(name = "unknown") + ) + assertTrue(record.timestampNanos == 1000L && record.body == "Test log") + } + ), + suite("builder")( + test("builder creates default LogRecord") { + val record = LogRecord.builder.build + assertTrue( + record.severity == Severity.Info && + record.severityText == "INFO" && + record.body == "" && + record.attributes == Attributes.empty && + record.traceId.isEmpty && + record.spanId.isEmpty && + record.traceFlags.isEmpty + ) + }, + test("builder setTimestamp") { + val record = LogRecord.builder.setTimestamp(5000L).build + assertTrue(record.timestampNanos == 5000L) + }, + test("builder setSeverity") { + val record = LogRecord.builder.setSeverity(Severity.Error).build + assertTrue(record.severity == Severity.Error && record.severityText == "ERROR") + }, + test("builder setBody") { + val record = LogRecord.builder.setBody("Test message").build + assertTrue(record.body == "Test message") + }, + test("builder setAttribute") { + val record = LogRecord.builder + .setAttribute(AttributeKey.string("key1"), "value1") + .build + assertTrue(record.attributes.get(AttributeKey.string("key1")) == Some("value1")) + }, + test("builder setAttribute accumulates multiple attributes") { + val record = LogRecord.builder + .setAttribute(AttributeKey.string("key1"), "value1") + .setAttribute(AttributeKey.string("key2"), "value2") + .setAttribute(AttributeKey.long("key3"), 42L) + .build + assertTrue( + record.attributes.get(AttributeKey.string("key1")) == Some("value1") && + record.attributes.get(AttributeKey.string("key2")) == Some("value2") && + record.attributes.get(AttributeKey.long("key3")) == Some(42L) + ) + }, + test("builder setTraceId") { + val traceId = TraceId.random + val record = LogRecord.builder.setTraceId(traceId).build + assertTrue(record.traceId == Some(traceId)) + }, + test("builder setSpanId") { + val spanId = SpanId.random + val record = LogRecord.builder.setSpanId(spanId).build + assertTrue(record.spanId == Some(spanId)) + }, + test("builder setTraceFlags") { + val flags = TraceFlags.sampled + val record = LogRecord.builder.setTraceFlags(flags).build + assertTrue(record.traceFlags == Some(flags)) + }, + test("builder setResource") { + val resource = Resource.empty + val record = LogRecord.builder.setResource(resource).build + assertTrue(record.resource == resource) + }, + test("builder setInstrumentationScope") { + val scope = InstrumentationScope(name = "unknown") + val record = LogRecord.builder.setInstrumentationScope(scope).build + assertTrue(record.instrumentationScope == scope) + }, + test("builder chains multiple calls") { + val traceId = TraceId.random + val spanId = SpanId.random + val record = LogRecord.builder + .setTimestamp(1000L) + .setSeverity(Severity.Warn) + .setBody("Warning message") + .setTraceId(traceId) + .setSpanId(spanId) + .build + assertTrue( + record.timestampNanos == 1000L && + record.severity == Severity.Warn && + record.body == "Warning message" && + record.traceId == Some(traceId) && + record.spanId == Some(spanId) + ) + } + ), + suite("immutability")( + test("LogRecord cannot be modified after creation") { + val record1 = LogRecord( + timestampNanos = 1000L, + observedTimestampNanos = 2000L, + severity = Severity.Info, + severityText = "INFO", + body = "Test", + attributes = Attributes.empty, + traceId = None, + spanId = None, + traceFlags = None, + resource = Resource.empty, + instrumentationScope = InstrumentationScope(name = "unknown") + ) + val record2 = record1.copy(body = "Modified") + assertTrue(record1.body == "Test" && record2.body == "Modified") + } + ), + suite("trace correlation")( + test("LogRecord preserves trace context") { + val traceId = TraceId.random + val spanId = SpanId.random + val flags = TraceFlags.sampled + val record = LogRecord( + timestampNanos = 1000L, + observedTimestampNanos = 2000L, + severity = Severity.Error, + severityText = "ERROR", + body = "Error occurred", + attributes = Attributes.empty, + traceId = Some(traceId), + spanId = Some(spanId), + traceFlags = Some(flags), + resource = Resource.empty, + instrumentationScope = InstrumentationScope(name = "unknown") + ) + assertTrue( + record.traceId == Some(traceId) && + record.spanId == Some(spanId) && + record.traceFlags == Some(flags) + ) + } + ) + ) + ) +} diff --git a/otel/shared/src/test/scala/zio/blocks/otel/LogSpec.scala b/otel/shared/src/test/scala/zio/blocks/otel/LogSpec.scala new file mode 100644 index 0000000000..ceb13e9644 --- /dev/null +++ b/otel/shared/src/test/scala/zio/blocks/otel/LogSpec.scala @@ -0,0 +1,326 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import zio.test._ + +import scala.collection.mutable.ArrayBuffer + +object LogSpec extends ZIOSpecDefault { + + private class TestLogProcessor extends LogRecordProcessor { + val emitted: ArrayBuffer[LogRecord] = ArrayBuffer.empty + def onEmit(logRecord: LogRecord): Unit = emitted += logRecord + def shutdown(): Unit = () + def forceFlush(): Unit = () + } + + private def withLogger(minSeverity: Severity = Severity.Trace)( + f: TestLogProcessor => Unit + ): TestLogProcessor = { + val processor = new TestLogProcessor + val provider = LoggerProvider.builder + .addLogRecordProcessor(processor) + .setResource(Resource.empty) + .build() + val logger = provider.get("test") + GlobalLogState.install(logger, minSeverity) + try f(processor) + finally { + GlobalLogState.clearAllLevels() + GlobalLogState.uninstall() + } + processor + } + + def spec: Spec[Any, Nothing] = suite("log")( + test("does nothing when GlobalLogState is not installed") { + GlobalLogState.uninstall() + log.info("should not crash") + assertTrue(true) + }, + test("emits when GlobalLogState is installed") { + val processor = withLogger() { _ => + log.info("hello") + } + assertTrue( + processor.emitted.size == 1, + processor.emitted.head.body == "hello", + processor.emitted.head.severity == Severity.Info + ) + }, + test("source location attributes are present") { + val processor = withLogger() { _ => + log.info("located") + } + val attrs = processor.emitted.head.attributes + assertTrue( + attrs.get(AttributeKey.string("code.filepath")).isDefined, + attrs.get(AttributeKey.string("code.namespace")).isDefined, + attrs.get(AttributeKey.string("code.function")).isDefined, + attrs.get(AttributeKey.long("code.lineno")).isDefined + ) + }, + test("level filtering skips messages below minSeverity") { + val processor = withLogger(Severity.Info) { _ => + log.debug("should be skipped") + log.info("should be emitted") + } + assertTrue( + processor.emitted.size == 1, + processor.emitted.head.body == "should be emitted" + ) + }, + test("all six severity methods work") { + val processor = withLogger() { _ => + log.trace("t") + log.debug("d") + log.info("i") + log.warn("w") + log.error("e") + log.fatal("f") + } + assertTrue( + processor.emitted.size == 6, + processor.emitted(0).severity == Severity.Trace, + processor.emitted(1).severity == Severity.Debug, + processor.emitted(2).severity == Severity.Info, + processor.emitted(3).severity == Severity.Warn, + processor.emitted(4).severity == Severity.Error, + processor.emitted(5).severity == Severity.Fatal + ) + }, + suite("LogEnrichment")( + test("string enrichment sets log body") { + val processor = withLogger() { _ => + log.info("hello world") + } + assertTrue( + processor.emitted.size == 1, + processor.emitted.head.body == "hello world" + ) + }, + test("key-value string enrichment adds attribute") { + val processor = withLogger() { _ => + log.info("msg", "userId" -> "abc") + } + val attrs = processor.emitted.head.attributes + assertTrue( + processor.emitted.size == 1, + processor.emitted.head.body == "msg", + attrs.get(AttributeKey.string("userId")).contains("abc") + ) + }, + test("key-value long enrichment adds attribute") { + val processor = withLogger() { _ => + log.info("msg", "count" -> 42L) + } + val attrs = processor.emitted.head.attributes + assertTrue( + processor.emitted.size == 1, + attrs.get(AttributeKey.long("count")).contains(42L) + ) + }, + test("key-value int enrichment adds attribute") { + val processor = withLogger() { _ => + log.info("msg", "count" -> 42) + } + val attrs = processor.emitted.head.attributes + assertTrue( + processor.emitted.size == 1, + attrs.get(AttributeKey.long("count")).contains(42L) + ) + }, + test("key-value double enrichment adds attribute") { + val processor = withLogger() { _ => + log.info("msg", "score" -> 3.14) + } + val attrs = processor.emitted.head.attributes + assertTrue( + processor.emitted.size == 1, + attrs.get(AttributeKey.double("score")).contains(3.14) + ) + }, + test("key-value boolean enrichment adds attribute") { + val processor = withLogger() { _ => + log.info("msg", "active" -> true) + } + val attrs = processor.emitted.head.attributes + assertTrue( + processor.emitted.size == 1, + attrs.get(AttributeKey.boolean("active")).contains(true) + ) + }, + test("throwable enrichment adds exception attributes") { + val ex: Throwable = new RuntimeException("boom") + val processor = withLogger() { _ => + log.info("failed", ex) + } + val attrs = processor.emitted.head.attributes + assertTrue( + processor.emitted.size == 1, + processor.emitted.head.body == "failed", + attrs.get(AttributeKey.string("exception.type")).contains("java.lang.RuntimeException"), + attrs.get(AttributeKey.string("exception.message")).contains("boom"), + attrs.get(AttributeKey.string("exception.stacktrace")).isDefined + ) + }, + test("severity enrichment overrides level") { + val sev: Severity = Severity.Error + val processor = withLogger() { _ => + log.info("msg", sev) + } + assertTrue( + processor.emitted.size == 1, + processor.emitted.head.severity == Severity.Error, + processor.emitted.head.severityText == "ERROR" + ) + }, + test("multiple enrichments combine") { + val processor = withLogger() { _ => + log.info("msg", "k1" -> "v1", "k2" -> 42L) + } + val attrs = processor.emitted.head.attributes + assertTrue( + processor.emitted.size == 1, + processor.emitted.head.body == "msg", + attrs.get(AttributeKey.string("k1")).contains("v1"), + attrs.get(AttributeKey.long("k2")).contains(42L) + ) + } + ) @@ TestAspect.sequential, + suite("Hierarchical log levels")( + test("specific prefix overrides general level") { + val processor = withLogger(Severity.Warn) { _ => + GlobalLogState.setLevel("zio.blocks.otel", Severity.Debug) + log.debug("should be emitted") + } + assertTrue( + processor.emitted.size == 1, + processor.emitted.head.body == "should be emitted" + ) + }, + test("most specific prefix wins") { + var debugLevel = 0 + var noisyLevel = 0 + var otherLevel = 0 + withLogger(Severity.Info) { _ => + GlobalLogState.setLevel("com.example", Severity.Debug) + GlobalLogState.setLevel("com.example.noisy", Severity.Warn) + val state = GlobalLogState.get() + debugLevel = state.effectiveLevel("com.example.Service") + noisyLevel = state.effectiveLevel("com.example.noisy.Thing") + otherLevel = state.effectiveLevel("com.other.Foo") + } + assertTrue( + debugLevel == Severity.Debug.number, + noisyLevel == Severity.Warn.number, + otherLevel == Severity.Info.number + ) + }, + test("clearLevel removes override") { + var level = 0 + withLogger(Severity.Info) { _ => + GlobalLogState.setLevel("com.test", Severity.Debug) + GlobalLogState.clearLevel("com.test") + level = GlobalLogState.get().effectiveLevel("com.test.Foo") + } + assertTrue(level == Severity.Info.number) + }, + test("clearAllLevels removes all overrides") { + var aLevel = 0 + var bLevel = 0 + withLogger(Severity.Info) { _ => + GlobalLogState.setLevel("a", Severity.Debug) + GlobalLogState.setLevel("b", Severity.Warn) + GlobalLogState.clearAllLevels() + val state = GlobalLogState.get() + aLevel = state.effectiveLevel("a.Foo") + bLevel = state.effectiveLevel("b.Bar") + } + assertTrue( + aLevel == Severity.Info.number, + bLevel == Severity.Info.number + ) + } + ) @@ TestAspect.sequential, + suite("log.annotated")( + test("annotations attach to log records") { + val processor = withLogger() { _ => + log.annotated("requestId" -> "abc") { + log.info("test") + } + } + val attrs = processor.emitted.head.attributes + assertTrue( + processor.emitted.size == 1, + attrs.get(AttributeKey.string("requestId")).contains("abc") + ) + }, + test("annotations are removed after scope exits") { + val processor = withLogger() { _ => + log.annotated("k" -> "v") { + log.info("inside") + } + log.info("outside") + } + val insideAttrs = processor.emitted(0).attributes + val outsideAttrs = processor.emitted(1).attributes + assertTrue( + processor.emitted.size == 2, + processor.emitted(0).body == "inside", + processor.emitted(1).body == "outside", + insideAttrs.get(AttributeKey.string("k")).contains("v"), + outsideAttrs.get(AttributeKey.string("k")).isEmpty + ) + }, + test("nested annotations merge") { + val processor = withLogger() { _ => + log.annotated("a" -> "1") { + log.annotated("b" -> "2") { + log.info("nested") + } + } + } + val attrs = processor.emitted.head.attributes + assertTrue( + processor.emitted.size == 1, + attrs.get(AttributeKey.string("a")).contains("1"), + attrs.get(AttributeKey.string("b")).contains("2") + ) + }, + test("annotations cleanup on exception") { + val processor = withLogger() { _ => + try + log.annotated("k" -> "v") { + throw new RuntimeException("boom") + } + catch { + case _: RuntimeException => () + } + log.info("after") + } + val afterAttrs = processor.emitted.head.attributes + assertTrue( + processor.emitted.size == 1, + processor.emitted.head.body == "after", + afterAttrs.get(AttributeKey.string("k")).isEmpty + ) + } + ) @@ TestAspect.sequential + ) @@ TestAspect.sequential @@ TestAspect.withLiveClock +} diff --git a/otel/shared/src/test/scala/zio/blocks/otel/LoggerSpec.scala b/otel/shared/src/test/scala/zio/blocks/otel/LoggerSpec.scala new file mode 100644 index 0000000000..50792a6cb7 --- /dev/null +++ b/otel/shared/src/test/scala/zio/blocks/otel/LoggerSpec.scala @@ -0,0 +1,305 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import zio.test._ + +import scala.collection.mutable.ArrayBuffer + +object LoggerSpec extends ZIOSpecDefault { + + private class TestLogProcessor extends LogRecordProcessor { + val emitted: ArrayBuffer[LogRecord] = ArrayBuffer.empty + var shutdownCalled: Boolean = false + var forceFlushCalled: Boolean = false + + def onEmit(logRecord: LogRecord): Unit = emitted += logRecord + def shutdown(): Unit = shutdownCalled = true + def forceFlush(): Unit = forceFlushCalled = true + } + + private class TestSpanProcessor extends SpanProcessor { + val started: ArrayBuffer[Span] = ArrayBuffer.empty + val ended: ArrayBuffer[SpanData] = ArrayBuffer.empty + var shutdownCalled: Boolean = false + var forceFlushCalled: Boolean = false + + def onStart(span: Span): Unit = started += span + def onEnd(spanData: SpanData): Unit = ended += spanData + def shutdown(): Unit = shutdownCalled = true + def forceFlush(): Unit = forceFlushCalled = true + } + + def spec = suite("Logger")( + suite("LogRecordProcessor")( + test("noop does nothing") { + val noop = LogRecordProcessor.noop + noop.onEmit(LogRecord.builder.build) + noop.shutdown() + noop.forceFlush() + assertTrue(true) + } + ), + suite("LoggerProvider.builder")( + test("builds with defaults") { + val provider = LoggerProvider.builder.build() + val logger = provider.get("test-lib") + assertTrue(logger != null) + }, + test("setResource configures resource") { + val resource = Resource.create( + Attributes.of(AttributeKey.string("service.name"), "my-service") + ) + val provider = LoggerProvider.builder + .setResource(resource) + .build() + val logger = provider.get("test-lib", "1.0.0") + assertTrue(logger != null) + }, + test("addLogRecordProcessor registers processor") { + val processor = new TestLogProcessor + val provider = LoggerProvider.builder + .addLogRecordProcessor(processor) + .build() + val logger = provider.get("test-lib") + logger.info("hello") + assertTrue(processor.emitted.nonEmpty) + }, + test("shutdown calls shutdown on all processors") { + val p1 = new TestLogProcessor + val p2 = new TestLogProcessor + val provider = LoggerProvider.builder + .addLogRecordProcessor(p1) + .addLogRecordProcessor(p2) + .build() + provider.shutdown() + assertTrue(p1.shutdownCalled && p2.shutdownCalled) + } + ), + suite("Logger.emit")( + test("sends log record to all processors") { + val p1 = new TestLogProcessor + val p2 = new TestLogProcessor + val logger = makeLogger(Seq(p1, p2)) + + val record = LogRecord.builder + .setSeverity(Severity.Info) + .setBody("test message") + .build + + logger.emit(record) + + assertTrue( + p1.emitted.size == 1 && + p2.emitted.size == 1 && + p1.emitted.head.body == "test message" && + p2.emitted.head.body == "test message" + ) + } + ), + suite("convenience methods")( + test("info creates LogRecord with correct severity") { + val processor = new TestLogProcessor + val logger = makeLogger(Seq(processor)) + + logger.info("test info message") + + assertTrue( + processor.emitted.size == 1 && + processor.emitted.head.severity == Severity.Info && + processor.emitted.head.severityText == "INFO" && + processor.emitted.head.body == "test info message" + ) + }, + test("trace creates LogRecord with Trace severity") { + val processor = new TestLogProcessor + val logger = makeLogger(Seq(processor)) + + logger.trace("trace message") + + assertTrue( + processor.emitted.head.severity == Severity.Trace && + processor.emitted.head.severityText == "TRACE" + ) + }, + test("debug creates LogRecord with Debug severity") { + val processor = new TestLogProcessor + val logger = makeLogger(Seq(processor)) + + logger.debug("debug message") + + assertTrue( + processor.emitted.head.severity == Severity.Debug && + processor.emitted.head.severityText == "DEBUG" + ) + }, + test("warn creates LogRecord with Warn severity") { + val processor = new TestLogProcessor + val logger = makeLogger(Seq(processor)) + + logger.warn("warn message") + + assertTrue( + processor.emitted.head.severity == Severity.Warn && + processor.emitted.head.severityText == "WARN" + ) + }, + test("error creates LogRecord with Error severity") { + val processor = new TestLogProcessor + val logger = makeLogger(Seq(processor)) + + logger.error("error message") + + assertTrue( + processor.emitted.head.severity == Severity.Error && + processor.emitted.head.severityText == "ERROR" + ) + }, + test("fatal creates LogRecord with Fatal severity") { + val processor = new TestLogProcessor + val logger = makeLogger(Seq(processor)) + + logger.fatal("fatal message") + + assertTrue( + processor.emitted.head.severity == Severity.Fatal && + processor.emitted.head.severityText == "FATAL" + ) + }, + test("convenience methods auto-set timestamp") { + val processor = new TestLogProcessor + val logger = makeLogger(Seq(processor)) + + val before = System.nanoTime() + logger.info("timed message") + val after = System.nanoTime() + + val record = processor.emitted.head + assertTrue( + record.timestampNanos >= before && + record.timestampNanos <= after + ) + }, + test("convenience methods accept attributes") { + val processor = new TestLogProcessor + val logger = makeLogger(Seq(processor)) + + logger.info( + "message with attrs", + "key1" -> AttributeValue.StringValue("value1"), + "key2" -> AttributeValue.LongValue(42L) + ) + + val record = processor.emitted.head + val attrs = record.attributes.toMap + assertTrue( + attrs("key1") == AttributeValue.StringValue("value1") && + attrs("key2") == AttributeValue.LongValue(42L) + ) + }, + test("LogRecord includes resource and instrumentation scope") { + val resource = Resource.create( + Attributes.of(AttributeKey.string("service.name"), "test-svc") + ) + val processor = new TestLogProcessor + val provider = LoggerProvider.builder + .setResource(resource) + .addLogRecordProcessor(processor) + .build() + val logger = provider.get("my-logger", "2.0.0") + + logger.info("scoped message") + + val record = processor.emitted.head + assertTrue( + record.resource == resource && + record.instrumentationScope.name == "my-logger" && + record.instrumentationScope.version.contains("2.0.0") + ) + } + ), + suite("trace correlation")( + test("logger auto-injects traceId and spanId when inside a span") { + val logProcessor = new TestLogProcessor + val spanProcessor = new TestSpanProcessor + + // Shared context storage for trace correlation + val sharedContextStorage = ContextStorage.create[Option[SpanContext]](None) + + // Create Tracer directly with shared context storage + val scope = InstrumentationScope(name = "test-tracer") + val tracer = new Tracer(scope, Resource.default, AlwaysOnSampler, Seq(spanProcessor), sharedContextStorage) + + // Create LoggerProvider with same context storage + val loggerProvider = LoggerProvider.builder + .addLogRecordProcessor(logProcessor) + .setContextStorage(sharedContextStorage) + .build() + val logger = loggerProvider.get("test-logger") + + tracer.span("test-span") { span => + logger.info("inside span") + val record = logProcessor.emitted.head + assertTrue( + record.traceId.contains(span.spanContext.traceId) && + record.spanId.contains(span.spanContext.spanId) && + record.traceFlags.contains(span.spanContext.traceFlags) + ) + } + }, + test("logger has no trace context outside of span") { + val logProcessor = new TestLogProcessor + val logger = makeLogger(Seq(logProcessor)) + + logger.info("outside span") + + val record = logProcessor.emitted.head + assertTrue( + record.traceId.isEmpty && + record.spanId.isEmpty && + record.traceFlags.isEmpty + ) + } + ), + suite("multiple processors")( + test("all processors receive each log record") { + val p1 = new TestLogProcessor + val p2 = new TestLogProcessor + val p3 = new TestLogProcessor + val logger = makeLogger(Seq(p1, p2, p3)) + + logger.info("broadcast message") + + assertTrue( + p1.emitted.size == 1 && + p2.emitted.size == 1 && + p3.emitted.size == 1 + ) + } + ) + ) + + private def makeLogger( + processors: Seq[LogRecordProcessor], + contextStorage: ContextStorage[Option[SpanContext]] = ContextStorage.create[Option[SpanContext]](None) + ): Logger = { + val builder = LoggerProvider.builder + .setContextStorage(contextStorage) + processors.foreach(p => builder.addLogRecordProcessor(p)) + builder.build().get("test-logger") + } +} diff --git a/otel/shared/src/test/scala/zio/blocks/otel/MeterSpec.scala b/otel/shared/src/test/scala/zio/blocks/otel/MeterSpec.scala new file mode 100644 index 0000000000..b2fc9f77bb --- /dev/null +++ b/otel/shared/src/test/scala/zio/blocks/otel/MeterSpec.scala @@ -0,0 +1,203 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import zio.test._ + +object MeterSpec extends ZIOSpecDefault { + + private val regionKey = AttributeKey.string("region") + private val usEast = Attributes.of(regionKey, "us-east-1") + private val euWest = Attributes.of(regionKey, "eu-west-1") + + private def sumPoints(data: MetricData): List[SumDataPoint] = data match { + case MetricData.SumData(points) => points + case MetricData.HistogramData(_) => Nil + case MetricData.GaugeData(_) => Nil + } + + private def histogramPoints(data: MetricData): List[HistogramDataPoint] = data match { + case MetricData.SumData(_) => Nil + case MetricData.HistogramData(points) => points + case MetricData.GaugeData(_) => Nil + } + + private def gaugePoints(data: MetricData): List[GaugeDataPoint] = data match { + case MetricData.SumData(_) => Nil + case MetricData.HistogramData(_) => Nil + case MetricData.GaugeData(points) => points + } + + def spec = suite("Meter & MeterProvider")( + suite("Meter - Counter")( + test("counterBuilder creates a working counter") { + val provider = MeterProvider.builder.build() + val meter = provider.get("test-lib") + val counter = meter.counterBuilder("requests").setDescription("Total requests").setUnit("1").build() + counter.add(1L, usEast) + counter.add(2L, usEast) + val points = sumPoints(counter.collect()) + assertTrue(points.size == 1 && points.head.value == 3L) + }, + test("counterBuilder with defaults") { + val provider = MeterProvider.builder.build() + val meter = provider.get("test-lib") + val counter = meter.counterBuilder("requests").build() + counter.add(5L, usEast) + val points = sumPoints(counter.collect()) + assertTrue(points.size == 1 && points.head.value == 5L) + } + ), + suite("Meter - UpDownCounter")( + test("upDownCounterBuilder creates a working up-down counter") { + val provider = MeterProvider.builder.build() + val meter = provider.get("test-lib") + val counter = meter.upDownCounterBuilder("queue.size").setDescription("Queue depth").setUnit("1").build() + counter.add(10L, usEast) + counter.add(-3L, usEast) + val points = sumPoints(counter.collect()) + assertTrue(points.size == 1 && points.head.value == 7L) + } + ), + suite("Meter - Histogram")( + test("histogramBuilder creates a working histogram") { + val provider = MeterProvider.builder.build() + val meter = provider.get("test-lib") + val histogram = meter.histogramBuilder("latency").setDescription("Request latency").setUnit("ms").build() + histogram.record(42.5, usEast) + histogram.record(10.0, usEast) + val points = histogramPoints(histogram.collect()) + assertTrue( + points.size == 1 && + points.head.count == 2L && + points.head.sum == 52.5 + ) + } + ), + suite("Meter - Gauge")( + test("gaugeBuilder creates a working sync gauge") { + val provider = MeterProvider.builder.build() + val meter = provider.get("test-lib") + val gauge = meter.gaugeBuilder("cpu").setDescription("CPU usage").setUnit("%").build() + gauge.record(50.0, usEast) + val points = gaugePoints(gauge.collect()) + assertTrue(points.size == 1 && points.head.value == 50.0) + }, + test("gaugeBuilder buildWithCallback creates an observable gauge") { + val provider = MeterProvider.builder.build() + val meter = provider.get("test-lib") + val obsGauge = meter.gaugeBuilder("cpu").setDescription("CPU usage").setUnit("%").buildWithCallback { cb => + cb.record(50.0, usEast) + cb.record(75.0, euWest) + } + val points = gaugePoints(obsGauge.collect()) + val values = points.map(_.value).toSet + assertTrue(points.size == 2 && values == Set(50.0, 75.0)) + } + ), + suite("Meter - Observable instruments via builders")( + test("counterBuilder buildWithCallback creates an observable counter") { + val provider = MeterProvider.builder.build() + val meter = provider.get("test-lib") + val obsCounter = meter.counterBuilder("cpu.time").buildWithCallback { cb => + cb.record(150.0, usEast) + } + val points = sumPoints(obsCounter.collect()) + assertTrue(points.size == 1 && points.head.value == 150L) + }, + test("upDownCounterBuilder buildWithCallback creates an observable up-down counter") { + val provider = MeterProvider.builder.build() + val meter = provider.get("test-lib") + val obsCounter = meter.upDownCounterBuilder("pool.active").buildWithCallback { cb => + cb.record(-5.0, usEast) + } + val points = sumPoints(obsCounter.collect()) + assertTrue(points.head.value == -5L) + }, + test("histogramBuilder buildWithCallback is not supported (histogram has no observable variant)") { + assertTrue(true) + } + ), + suite("MetricReader")( + test("collectAllMetrics returns data from all instruments across all meters") { + val provider = MeterProvider.builder.build() + val meter1 = provider.get("lib-a") + val meter2 = provider.get("lib-b") + val counter = meter1.counterBuilder("requests").build() + val histogram = meter2.histogramBuilder("latency").setUnit("ms").build() + counter.add(5L, usEast) + histogram.record(42.5, usEast) + val reader = provider.reader + val metrics = reader.collectAllMetrics() + assertTrue(metrics.size == 2) + }, + test("collectAllMetrics includes observable instruments") { + val provider = MeterProvider.builder.build() + val meter = provider.get("lib-a") + meter.counterBuilder("requests").build().add(1L, usEast) + meter.gaugeBuilder("cpu").buildWithCallback { cb => + cb.record(99.0, usEast) + } + val metrics = provider.reader.collectAllMetrics() + assertTrue(metrics.size == 2) + }, + test("forceFlush and shutdown do not throw") { + val provider = MeterProvider.builder.build() + val reader = provider.reader + reader.forceFlush() + reader.shutdown() + assertTrue(true) + } + ), + suite("MeterProvider")( + test("get returns the same meter for the same name and version") { + val provider = MeterProvider.builder.build() + val meter1 = provider.get("lib", "1.0") + val meter2 = provider.get("lib", "1.0") + assertTrue(meter1 eq meter2) + }, + test("get returns different meters for different names") { + val provider = MeterProvider.builder.build() + val meter1 = provider.get("lib-a") + val meter2 = provider.get("lib-b") + assertTrue(!(meter1 eq meter2)) + }, + test("builder allows setting resource") { + val customResource = Resource.create( + Attributes.builder.put("service.name", "my-service").build + ) + val provider = MeterProvider.builder.setResource(customResource).build() + assertTrue(provider.resource == customResource) + }, + test("shutdown does not throw") { + val provider = MeterProvider.builder.build() + provider.shutdown() + assertTrue(true) + }, + test("multiple meters collect independently") { + val provider = MeterProvider.builder.build() + val meter1 = provider.get("lib-a") + val meter2 = provider.get("lib-b") + meter1.counterBuilder("requests").build().add(10L, usEast) + meter2.counterBuilder("errors").build().add(3L, usEast) + val allMetrics = provider.reader.collectAllMetrics() + val sumValues = allMetrics.flatMap(sumPoints).map(_.value).toSet + assertTrue(allMetrics.size == 2 && sumValues == Set(10L, 3L)) + } + ) + ) +} diff --git a/otel/shared/src/test/scala/zio/blocks/otel/OtelContextSpec.scala b/otel/shared/src/test/scala/zio/blocks/otel/OtelContextSpec.scala new file mode 100644 index 0000000000..5d61a673e7 --- /dev/null +++ b/otel/shared/src/test/scala/zio/blocks/otel/OtelContextSpec.scala @@ -0,0 +1,193 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import zio.test._ +import zio.blocks.context.Context + +object OtelContextSpec extends ZIOSpecDefault { + + def spec = suite("OtelContext")( + suite("construction")( + test("current snapshots None when no span is active") { + val storage = ContextStorage.create[Option[SpanContext]](None) + val otelCtx = OtelContext.current(storage) + assertTrue(otelCtx.spanContext.isEmpty) + }, + test("current snapshots the active SpanContext") { + val sc = SpanContext.create(TraceId.random, SpanId.random, TraceFlags.sampled, "", false) + val storage = ContextStorage.create[Option[SpanContext]](None) + storage.set(Some(sc)) + val otelCtx = OtelContext.current(storage) + assertTrue(otelCtx.spanContext.contains(sc)) + } + ), + suite("spanContext accessor")( + test("returns None when constructed with no span") { + val storage = ContextStorage.create[Option[SpanContext]](None) + val otelCtx = OtelContext.current(storage) + assertTrue(otelCtx.spanContext.isEmpty) + }, + test("returns Some when constructed with active span") { + val sc = SpanContext.create(TraceId.random, SpanId.random, TraceFlags.sampled, "", false) + val otelCtx = OtelContext(Some(sc)) + assertTrue(otelCtx.spanContext.contains(sc) && otelCtx.spanContext.get.isValid) + } + ), + suite("Context[R] integration")( + test("store and retrieve OtelContext via Context.apply") { + val sc = SpanContext.create(TraceId.random, SpanId.random, TraceFlags.sampled, "", false) + val otelCtx = OtelContext(Some(sc)) + val ctx = Context(otelCtx) + val got = ctx.get[OtelContext] + assertTrue(got.spanContext.contains(sc)) + }, + test("store and retrieve via Context.empty.add") { + val sc = SpanContext.create(TraceId.random, SpanId.random, TraceFlags.sampled, "", false) + val otelCtx = OtelContext(Some(sc)) + val ctx = Context.empty.add(otelCtx) + val got = ctx.get[OtelContext] + assertTrue(got.spanContext == otelCtx.spanContext) + }, + test("getOption returns Some for present OtelContext") { + val otelCtx = OtelContext(None) + val ctx = Context(otelCtx) + assertTrue(ctx.getOption[OtelContext].isDefined) + }, + test("getOption returns None for absent OtelContext") { + val ctx = Context.empty + assertTrue(ctx.getOption[OtelContext].isEmpty) + }, + test("OtelContext coexists with other types in Context") { + val sc = SpanContext.create(TraceId.random, SpanId.random, TraceFlags.sampled, "", false) + val otelCtx = OtelContext(Some(sc)) + val ctx = Context.empty.add(otelCtx).add("hello") + assertTrue( + ctx.get[OtelContext].spanContext.contains(sc) && + ctx.get[String] == "hello" + ) + } + ), + suite("withSpan")( + test("makes span's context current during execution") { + val storage = ContextStorage.create[Option[SpanContext]](None) + val sc = SpanContext.create(TraceId.random, SpanId.random, TraceFlags.sampled, "", false) + val span = makeSpan(sc) + + val captured = OtelContext.withSpan(span, storage) { + storage.get() + } + + assertTrue(captured.contains(sc)) + }, + test("restores previous context after execution") { + val storage = ContextStorage.create[Option[SpanContext]](None) + val sc = SpanContext.create(TraceId.random, SpanId.random, TraceFlags.sampled, "", false) + val span = makeSpan(sc) + + OtelContext.withSpan(span, storage) { + () + } + + assertTrue(storage.get().isEmpty) + }, + test("restores context after exception") { + val storage = ContextStorage.create[Option[SpanContext]](None) + val sc = SpanContext.create(TraceId.random, SpanId.random, TraceFlags.sampled, "", false) + val span = makeSpan(sc) + + try { + OtelContext.withSpan(span, storage) { + throw new RuntimeException("boom") + } + } catch { + case _: RuntimeException => () + } + + assertTrue(storage.get().isEmpty) + }, + test("returns the block's result") { + val storage = ContextStorage.create[Option[SpanContext]](None) + val sc = SpanContext.create(TraceId.random, SpanId.random, TraceFlags.sampled, "", false) + val span = makeSpan(sc) + + val result = OtelContext.withSpan(span, storage) { + 42 + } + + assertTrue(result == 42) + } + ), + suite("Tracer integration")( + test("OtelContext.current captures span context set by Tracer") { + val storage = ContextStorage.create[Option[SpanContext]](None) + val processor = new TestSpanProcessor + val tracer = makeTracer(processor, storage) + + var captured: OtelContext = null + tracer.span("integration-op") { _ => + captured = OtelContext.current(storage) + } + + assertTrue( + captured != null && + captured.spanContext.isDefined && + captured.spanContext.get.isValid + ) + } + ) + ) + + private class TestSpanProcessor extends SpanProcessor { + def onStart(span: Span): Unit = () + def onEnd(spanData: SpanData): Unit = () + def shutdown(): Unit = () + def forceFlush(): Unit = () + } + + private def makeSpan(sc: SpanContext): Span = new Span { + val spanContext: SpanContext = sc + val name: String = "test" + val kind: SpanKind = SpanKind.Internal + + def setAttribute[A](key: AttributeKey[A], value: A): Unit = () + def setAttribute(key: String, value: String): Unit = () + def setAttribute(key: String, value: Long): Unit = () + def setAttribute(key: String, value: Double): Unit = () + def setAttribute(key: String, value: Boolean): Unit = () + def addEvent(name: String): Unit = () + def addEvent(name: String, attributes: Attributes): Unit = () + def addEvent(name: String, timestamp: Long, attributes: Attributes): Unit = () + def setStatus(status: SpanStatus): Unit = () + def end(): Unit = () + def end(endTimeNanos: Long): Unit = () + val isRecording: Boolean = false + def toSpanData: SpanData = Span.NoOp.toSpanData + } + + private def makeTracer( + processor: SpanProcessor, + storage: ContextStorage[Option[SpanContext]] + ): Tracer = + new Tracer( + instrumentationScope = InstrumentationScope("test"), + resource = Resource.empty, + sampler = AlwaysOnSampler, + processors = Seq(processor), + contextStorage = storage + ) +} diff --git a/otel/shared/src/test/scala/zio/blocks/otel/ResourceSpec.scala b/otel/shared/src/test/scala/zio/blocks/otel/ResourceSpec.scala new file mode 100644 index 0000000000..fdcf1c63d6 --- /dev/null +++ b/otel/shared/src/test/scala/zio/blocks/otel/ResourceSpec.scala @@ -0,0 +1,190 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import zio.test._ + +object ResourceSpec extends ZIOSpecDefault { + + def spec = suite("Resource")( + suite("empty")( + test("creates Resource with no attributes") { + val resource = Resource.empty + assertTrue(resource.attributes.isEmpty) + }, + test("isValid returns true") { + val resource = Resource.empty + assertTrue(resource.attributes.size == 0) + } + ), + suite("default")( + test("includes service.name") { + val resource = Resource.default + val serviceName = + resource.attributes.get(Attributes.ServiceName) + assertTrue(serviceName.contains("unknown_service")) + }, + test("includes telemetry.sdk.name") { + val resource = Resource.default + val sdkName = resource.attributes.get( + AttributeKey.string("telemetry.sdk.name") + ) + assertTrue(sdkName.contains("zio-blocks")) + }, + test("includes telemetry.sdk.language") { + val resource = Resource.default + val language = resource.attributes.get( + AttributeKey.string("telemetry.sdk.language") + ) + assertTrue(language.contains("scala")) + }, + test("includes telemetry.sdk.version") { + val resource = Resource.default + val version = + resource.attributes.get(AttributeKey.string("telemetry.sdk.version")) + assertTrue(version.isDefined) + }, + test("has at least 4 attributes") { + val resource = Resource.default + assertTrue(resource.attributes.size >= 4) + } + ), + suite("create")( + test("creates Resource from attributes") { + val attrs = Attributes.builder + .put(Attributes.ServiceName, "my-service") + .build + val resource = Resource.create(attrs) + val serviceName = + resource.attributes.get(Attributes.ServiceName) + assertTrue(serviceName.contains("my-service")) + }, + test("returns attributes as-is") { + val attrs = Attributes.empty + val resource = Resource.create(attrs) + assertTrue(resource.attributes.isEmpty) + } + ), + suite("constructor")( + test("Resource(attrs) stores attributes") { + val attrs = Attributes.builder + .put(Attributes.ServiceName, "test-service") + .build + val resource = Resource(attrs) + val serviceName = + resource.attributes.get(Attributes.ServiceName) + assertTrue(serviceName.contains("test-service")) + }, + test("Resource(empty) is equivalent to Resource.empty") { + val r1 = Resource(Attributes.empty) + val r2 = Resource.empty + assertTrue(r1.attributes.isEmpty && r2.attributes.isEmpty) + } + ), + suite("merge")( + test("combines attributes from two resources") { + val attrs1 = Attributes.builder + .put(Attributes.ServiceName, "service-1") + .build + val attrs2 = Attributes.builder + .put(Attributes.ServiceVersion, "1.0.0") + .build + val r1 = Resource(attrs1) + val r2 = Resource(attrs2) + val merged = r1.merge(r2) + val serviceName = + merged.attributes.get(Attributes.ServiceName) + val serviceVersion = + merged.attributes.get(Attributes.ServiceVersion) + assertTrue( + serviceName.contains("service-1") && serviceVersion.contains("1.0.0") + ) + }, + test("other resource wins on conflict") { + val attrs1 = Attributes.builder + .put(Attributes.ServiceName, "service-1") + .build + val attrs2 = Attributes.builder + .put(Attributes.ServiceName, "service-2") + .build + val r1 = Resource(attrs1) + val r2 = Resource(attrs2) + val merged = r1.merge(r2) + val serviceName = + merged.attributes.get(Attributes.ServiceName) + assertTrue(serviceName.contains("service-2")) + }, + test("merge with empty resource returns same attributes") { + val attrs = Attributes.builder + .put(Attributes.ServiceName, "my-service") + .build + val r1 = Resource(attrs) + val r2 = Resource.empty + val merged = r1.merge(r2) + val serviceName = + merged.attributes.get(Attributes.ServiceName) + assertTrue(serviceName.contains("my-service")) + }, + test("preserves non-conflicting attributes from both resources") { + val attrs1 = Attributes.builder + .put(Attributes.ServiceName, "service-1") + .put(AttributeKey.string("custom.attr1"), "value1") + .build + val attrs2 = Attributes.builder + .put(Attributes.ServiceVersion, "1.0.0") + .put(AttributeKey.string("custom.attr2"), "value2") + .build + val r1 = Resource(attrs1) + val r2 = Resource(attrs2) + val merged = r1.merge(r2) + val name = merged.attributes.get(Attributes.ServiceName) + val version = + merged.attributes.get(Attributes.ServiceVersion) + val attr1 = + merged.attributes.get(AttributeKey.string("custom.attr1")) + val attr2 = + merged.attributes.get(AttributeKey.string("custom.attr2")) + assertTrue( + name.contains("service-1") && + version.contains("1.0.0") && + attr1.contains("value1") && + attr2.contains("value2") + ) + } + ), + suite("attributes accessor")( + test("returns the underlying Attributes") { + val attrs = Attributes.builder + .put(Attributes.ServiceName, "test-service") + .build + val resource = Resource(attrs) + val retrieved = resource.attributes + val serviceName = + retrieved.get(Attributes.ServiceName) + assertTrue(serviceName.contains("test-service")) + }, + test("is immutable (returned Attributes cannot be modified)") { + val attrs = Attributes.builder + .put(Attributes.ServiceName, "test-service") + .build + val resource = Resource(attrs) + val retrieved = resource.attributes + assertTrue(retrieved.size == 1) + } + ) + ) +} diff --git a/otel/shared/src/test/scala/zio/blocks/otel/SamplerSpec.scala b/otel/shared/src/test/scala/zio/blocks/otel/SamplerSpec.scala new file mode 100644 index 0000000000..537d2fed48 --- /dev/null +++ b/otel/shared/src/test/scala/zio/blocks/otel/SamplerSpec.scala @@ -0,0 +1,207 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import zio.test._ + +object SamplerSpec extends ZIOSpecDefault { + + private val validTraceId = TraceId(hi = 1L, lo = 2L) + private val validSpanId = SpanId(value = 42L) + + private val sampledParent = SpanContext( + traceId = validTraceId, + spanId = validSpanId, + traceFlags = TraceFlags.sampled, + traceState = "", + isRemote = false + ) + + private val unsampledParent = SpanContext( + traceId = validTraceId, + spanId = validSpanId, + traceFlags = TraceFlags.none, + traceState = "", + isRemote = false + ) + + def spec = suite("Sampler")( + suite("AlwaysOnSampler")( + test("always returns RecordAndSample") { + val result = AlwaysOnSampler.shouldSample( + parentContext = None, + traceId = validTraceId, + name = "test-span", + kind = SpanKind.Internal, + attributes = Attributes.empty, + links = Seq.empty + ) + assertTrue(result.decision == SamplingDecision.RecordAndSample) + }, + test("returns RecordAndSample regardless of parent context") { + val result = AlwaysOnSampler.shouldSample( + parentContext = Some(unsampledParent), + traceId = validTraceId, + name = "test-span", + kind = SpanKind.Server, + attributes = Attributes.empty, + links = Seq.empty + ) + assertTrue(result.decision == SamplingDecision.RecordAndSample) + }, + test("returns empty attributes and trace state") { + val result = AlwaysOnSampler.shouldSample( + parentContext = None, + traceId = validTraceId, + name = "test-span", + kind = SpanKind.Internal, + attributes = Attributes.empty, + links = Seq.empty + ) + assertTrue(result.attributes.isEmpty && result.traceState == "") + }, + test("has correct description") { + assertTrue(AlwaysOnSampler.description == "AlwaysOnSampler") + } + ), + suite("AlwaysOffSampler")( + test("always returns Drop") { + val result = AlwaysOffSampler.shouldSample( + parentContext = None, + traceId = validTraceId, + name = "test-span", + kind = SpanKind.Internal, + attributes = Attributes.empty, + links = Seq.empty + ) + assertTrue(result.decision == SamplingDecision.Drop) + }, + test("returns Drop regardless of parent context") { + val result = AlwaysOffSampler.shouldSample( + parentContext = Some(sampledParent), + traceId = validTraceId, + name = "test-span", + kind = SpanKind.Client, + attributes = Attributes.empty, + links = Seq.empty + ) + assertTrue(result.decision == SamplingDecision.Drop) + }, + test("returns empty attributes and trace state") { + val result = AlwaysOffSampler.shouldSample( + parentContext = None, + traceId = validTraceId, + name = "test-span", + kind = SpanKind.Internal, + attributes = Attributes.empty, + links = Seq.empty + ) + assertTrue(result.attributes.isEmpty && result.traceState == "") + }, + test("has correct description") { + assertTrue(AlwaysOffSampler.description == "AlwaysOffSampler") + } + ), + suite("ParentBasedSampler")( + test("delegates to root sampler when no parent context") { + val sampler = ParentBasedSampler(root = AlwaysOnSampler) + val result = sampler.shouldSample( + parentContext = None, + traceId = validTraceId, + name = "root-span", + kind = SpanKind.Server, + attributes = Attributes.empty, + links = Seq.empty + ) + assertTrue(result.decision == SamplingDecision.RecordAndSample) + }, + test("delegates to root sampler (AlwaysOff) when no parent context") { + val sampler = ParentBasedSampler(root = AlwaysOffSampler) + val result = sampler.shouldSample( + parentContext = None, + traceId = validTraceId, + name = "root-span", + kind = SpanKind.Server, + attributes = Attributes.empty, + links = Seq.empty + ) + assertTrue(result.decision == SamplingDecision.Drop) + }, + test("returns RecordAndSample when parent is sampled") { + val sampler = ParentBasedSampler(root = AlwaysOffSampler) + val result = sampler.shouldSample( + parentContext = Some(sampledParent), + traceId = validTraceId, + name = "child-span", + kind = SpanKind.Internal, + attributes = Attributes.empty, + links = Seq.empty + ) + assertTrue(result.decision == SamplingDecision.RecordAndSample) + }, + test("returns Drop when parent is not sampled") { + val sampler = ParentBasedSampler(root = AlwaysOnSampler) + val result = sampler.shouldSample( + parentContext = Some(unsampledParent), + traceId = validTraceId, + name = "child-span", + kind = SpanKind.Internal, + attributes = Attributes.empty, + links = Seq.empty + ) + assertTrue(result.decision == SamplingDecision.Drop) + }, + test("has correct description") { + val sampler = ParentBasedSampler(root = AlwaysOnSampler) + assertTrue(sampler.description == "ParentBasedSampler(root=AlwaysOnSampler)") + } + ), + suite("SamplingResult")( + test("carries attributes") { + val attrs = Attributes.of(AttributeKey.string("sampler.key"), "value") + val result = SamplingResult( + decision = SamplingDecision.RecordAndSample, + attributes = attrs, + traceState = "" + ) + assertTrue(result.attributes.get(AttributeKey.string("sampler.key")).contains("value")) + }, + test("carries trace state") { + val result = SamplingResult( + decision = SamplingDecision.RecordOnly, + attributes = Attributes.empty, + traceState = "vendor1=value1" + ) + assertTrue(result.traceState == "vendor1=value1") + } + ), + suite("SamplingDecision")( + test("Drop is accessible") { + val _: SamplingDecision = SamplingDecision.Drop + assertTrue(true) + }, + test("RecordOnly is accessible") { + val _: SamplingDecision = SamplingDecision.RecordOnly + assertTrue(true) + }, + test("RecordAndSample is accessible") { + val _: SamplingDecision = SamplingDecision.RecordAndSample + assertTrue(true) + } + ) + ) +} diff --git a/otel/shared/src/test/scala/zio/blocks/otel/SpanContextSpec.scala b/otel/shared/src/test/scala/zio/blocks/otel/SpanContextSpec.scala new file mode 100644 index 0000000000..19c1ea87b5 --- /dev/null +++ b/otel/shared/src/test/scala/zio/blocks/otel/SpanContextSpec.scala @@ -0,0 +1,198 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import zio.test._ + +object SpanContextSpec extends ZIOSpecDefault { + + def spec = suite("SpanContext")( + suite("invalid")( + test("has zero trace ID") { + assertTrue(SpanContext.invalid.traceId == TraceId.invalid) + }, + test("has zero span ID") { + assertTrue(SpanContext.invalid.spanId == SpanId.invalid) + }, + test("has zero trace flags") { + assertTrue(SpanContext.invalid.traceFlags.byte == 0.toByte) + }, + test("has empty trace state") { + assertTrue(SpanContext.invalid.traceState == "") + }, + test("is not remote") { + assertTrue(!SpanContext.invalid.isRemote) + }, + test("isValid returns false") { + assertTrue(!SpanContext.invalid.isValid) + } + ), + suite("create")( + test("creates span context with provided values") { + val traceId = TraceId.random + val spanId = SpanId.random + val traceFlags = TraceFlags.sampled + val traceState = "key1=value1" + val isRemote = true + + val ctx = SpanContext.create(traceId, spanId, traceFlags, traceState, isRemote) + + assertTrue( + ctx.traceId == traceId && + ctx.spanId == spanId && + ctx.traceFlags == traceFlags && + ctx.traceState == traceState && + ctx.isRemote == isRemote + ) + }, + test("creates valid span context when trace ID and span ID are valid") { + val ctx = SpanContext.create( + TraceId.random, + SpanId.random, + TraceFlags.sampled, + "", + false + ) + assertTrue(ctx.isValid) + } + ), + suite("isValid")( + test("returns true when both trace ID and span ID are valid") { + val ctx = SpanContext.create( + TraceId.random, + SpanId.random, + TraceFlags.none, + "", + false + ) + assertTrue(ctx.isValid) + }, + test("returns false when trace ID is invalid") { + val ctx = SpanContext.create( + TraceId.invalid, + SpanId.random, + TraceFlags.none, + "", + false + ) + assertTrue(!ctx.isValid) + }, + test("returns false when span ID is invalid") { + val ctx = SpanContext.create( + TraceId.random, + SpanId.invalid, + TraceFlags.none, + "", + false + ) + assertTrue(!ctx.isValid) + }, + test("returns false when both IDs are invalid") { + assertTrue(!SpanContext.invalid.isValid) + } + ), + suite("isSampled")( + test("returns true when sampled flag is set") { + val ctx = SpanContext.create( + TraceId.random, + SpanId.random, + TraceFlags.sampled, + "", + false + ) + assertTrue(ctx.isSampled) + }, + test("returns false when sampled flag is not set") { + val ctx = SpanContext.create( + TraceId.random, + SpanId.random, + TraceFlags.none, + "", + false + ) + assertTrue(!ctx.isSampled) + } + ), + suite("traceState")( + test("stores and retrieves trace state") { + val state = "key1=value1,key2=value2" + val ctx = SpanContext.create( + TraceId.random, + SpanId.random, + TraceFlags.none, + state, + false + ) + assertTrue(ctx.traceState == state) + }, + test("handles empty trace state") { + val ctx = SpanContext.create( + TraceId.random, + SpanId.random, + TraceFlags.none, + "", + false + ) + assertTrue(ctx.traceState == "") + } + ), + suite("isRemote")( + test("identifies remote span contexts") { + val ctx = SpanContext.create( + TraceId.random, + SpanId.random, + TraceFlags.none, + "", + true + ) + assertTrue(ctx.isRemote) + }, + test("identifies local span contexts") { + val ctx = SpanContext.create( + TraceId.random, + SpanId.random, + TraceFlags.none, + "", + false + ) + assertTrue(!ctx.isRemote) + } + ), + suite("equality")( + test("equal contexts compare equal") { + val traceId = TraceId.random + val spanId = SpanId.random + val traceFlags = TraceFlags.sampled + val ctx1 = SpanContext.create(traceId, spanId, traceFlags, "state", true) + val ctx2 = SpanContext.create(traceId, spanId, traceFlags, "state", true) + assertTrue(ctx1 == ctx2) + }, + test("different contexts compare not equal (different trace ID)") { + val ctx1 = SpanContext.create(TraceId.random, SpanId.random, TraceFlags.none, "", false) + val ctx2 = SpanContext.create(TraceId.random, SpanId.random, TraceFlags.none, "", false) + assertTrue(ctx1 != ctx2) + } + ), + suite("immutability")( + test("is a final case class") { + val ctx1 = SpanContext.create(TraceId.random, SpanId.random, TraceFlags.none, "", false) + val ctx2 = ctx1 + assertTrue(ctx1 eq ctx2) + } + ) + ) +} diff --git a/otel/shared/src/test/scala/zio/blocks/otel/SpanIdSpec.scala b/otel/shared/src/test/scala/zio/blocks/otel/SpanIdSpec.scala new file mode 100644 index 0000000000..e34f781bd2 --- /dev/null +++ b/otel/shared/src/test/scala/zio/blocks/otel/SpanIdSpec.scala @@ -0,0 +1,136 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import zio.test._ + +object SpanIdSpec extends ZIOSpecDefault { + + def spec = suite("SpanId")( + suite("invalid")( + test("is zero") { + assertTrue(SpanId.invalid.value == 0L) + }, + test("isValid returns false") { + assertTrue(!SpanId.invalid.isValid) + } + ), + suite("random")( + test("generates valid SpanIds (non-zero)") { + val s = SpanId.random + assertTrue(s.isValid) + }, + test("never returns zero span") { + val spans = (1 to 100).map(_ => SpanId.random) + assertTrue(spans.forall(_.isValid)) + }, + test("generates different spans") { + val s1 = SpanId.random + val s2 = SpanId.random + assertTrue(s1 != s2) + } + ), + suite("fromHex")( + test("parses valid 16-char hex string") { + val hex = "0102030405060708" + val result = SpanId.fromHex(hex) + assertTrue(result.isDefined) + }, + test("returns None for non-hex characters") { + val hex = "010203040506070g" + val result = SpanId.fromHex(hex) + assertTrue(result.isEmpty) + }, + test("returns None for wrong length (too short)") { + val hex = "010203040506070" + val result = SpanId.fromHex(hex) + assertTrue(result.isEmpty) + }, + test("returns None for wrong length (too long)") { + val hex = "01020304050607081" + val result = SpanId.fromHex(hex) + assertTrue(result.isEmpty) + }, + test("handles uppercase hex") { + val hex = "0102030405060708".toUpperCase + val result = SpanId.fromHex(hex) + assertTrue(result.isDefined) + }, + test("handles all-zero hex (invalid)") { + val hex = "0000000000000000" + val result = SpanId.fromHex(hex) + assertTrue(result.isDefined && !result.get.isValid) + } + ), + suite("toHex")( + test("produces 16-char lowercase hex") { + val s = SpanId.invalid + val hex = s.toHex + assertTrue(hex.length == 16 && hex == "0000000000000000") + }, + test("is zero-padded") { + val s = SpanId(value = 1L) + val hex = s.toHex + assertTrue(hex.length == 16 && hex == "0000000000000001") + }, + test("roundtrips with fromHex") { + val s = SpanId.random + val hex = s.toHex + val parsed = SpanId.fromHex(hex) + assertTrue(parsed.contains(s)) + } + ), + suite("isValid")( + test("returns true for non-zero spans") { + val s = SpanId(value = 1L) + assertTrue(s.isValid) + }, + test("returns false when zero") { + val s = SpanId(value = 0L) + assertTrue(!s.isValid) + } + ), + suite("toByteArray")( + test("produces 8 bytes") { + val s = SpanId.random + val bytes = s.toByteArray + assertTrue(bytes.length == 8) + }, + test("is big-endian") { + val s = SpanId(value = 0x0102030405060708L) + val bytes = s.toByteArray + assertTrue( + bytes(0) == 0x01.toByte && + bytes(1) == 0x02.toByte && + bytes(7) == 0x08.toByte + ) + } + ), + suite("equality")( + test("equal spans compare equal") { + val s1 = SpanId(value = 123L) + val s2 = SpanId(value = 123L) + assertTrue(s1 == s2) + }, + test("different spans compare not equal") { + val s1 = SpanId(value = 123L) + val s2 = SpanId(value = 124L) + assertTrue(s1 != s2) + } + ) + ) +} diff --git a/otel/shared/src/test/scala/zio/blocks/otel/SpanKindSpec.scala b/otel/shared/src/test/scala/zio/blocks/otel/SpanKindSpec.scala new file mode 100644 index 0000000000..6b5721a4e1 --- /dev/null +++ b/otel/shared/src/test/scala/zio/blocks/otel/SpanKindSpec.scala @@ -0,0 +1,76 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import zio.test._ + +object SpanKindSpec extends ZIOSpecDefault { + + def spec = suite("SpanKind")( + suite("sealed trait exhaustiveness")( + test("Internal is accessible") { + val _: SpanKind = SpanKind.Internal + assertTrue(true) + }, + test("Server is accessible") { + val _: SpanKind = SpanKind.Server + assertTrue(true) + }, + test("Client is accessible") { + val _: SpanKind = SpanKind.Client + assertTrue(true) + }, + test("Producer is accessible") { + val _: SpanKind = SpanKind.Producer + assertTrue(true) + }, + test("Consumer is accessible") { + val _: SpanKind = SpanKind.Consumer + assertTrue(true) + } + ), + suite("pattern matching")( + test("exhaustive match on all kinds") { + def spanKindToString(kind: SpanKind): String = kind match { + case SpanKind.Internal => "Internal" + case SpanKind.Server => "Server" + case SpanKind.Client => "Client" + case SpanKind.Producer => "Producer" + case SpanKind.Consumer => "Consumer" + } + assertTrue( + spanKindToString(SpanKind.Internal) == "Internal" && + spanKindToString(SpanKind.Server) == "Server" && + spanKindToString(SpanKind.Client) == "Client" && + spanKindToString(SpanKind.Producer) == "Producer" && + spanKindToString(SpanKind.Consumer) == "Consumer" + ) + } + ), + suite("equality")( + test("same kind equals itself") { + val internal: SpanKind = SpanKind.Internal + assertTrue(internal == internal) + }, + test("different kinds are not equal") { + val internal: SpanKind = SpanKind.Internal + val server: SpanKind = SpanKind.Server + assertTrue(internal != server) + } + ) + ) +} diff --git a/otel/shared/src/test/scala/zio/blocks/otel/SpanStatusSpec.scala b/otel/shared/src/test/scala/zio/blocks/otel/SpanStatusSpec.scala new file mode 100644 index 0000000000..f0e1f40b1a --- /dev/null +++ b/otel/shared/src/test/scala/zio/blocks/otel/SpanStatusSpec.scala @@ -0,0 +1,108 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import zio.test._ + +object SpanStatusSpec extends ZIOSpecDefault { + + def spec = suite("SpanStatus")( + suite("Unset")( + test("is accessible as object") { + val _: SpanStatus = SpanStatus.Unset + assertTrue(true) + }, + test("equals itself") { + assertTrue(SpanStatus.Unset == SpanStatus.Unset) + } + ), + suite("Ok")( + test("is accessible as object") { + val _: SpanStatus = SpanStatus.Ok + assertTrue(true) + }, + test("equals itself") { + assertTrue(SpanStatus.Ok == SpanStatus.Ok) + }, + test("is not equal to Unset") { + val ok: SpanStatus = SpanStatus.Ok + val unset: SpanStatus = SpanStatus.Unset + assertTrue(ok != unset) + } + ), + suite("Error")( + test("can be created with description") { + val error = SpanStatus.Error("test error") + assertTrue(error.description == "test error") + }, + test("stores description") { + val desc = "connection timeout" + val error = SpanStatus.Error(desc) + assertTrue(error.description == desc) + }, + test("equals with same description") { + val err1 = SpanStatus.Error("same message") + val err2 = SpanStatus.Error("same message") + assertTrue(err1 == err2) + }, + test("not equal with different description") { + val err1 = SpanStatus.Error("error 1") + val err2 = SpanStatus.Error("error 2") + assertTrue(err1 != err2) + }, + test("is not equal to Ok") { + val error: SpanStatus = SpanStatus.Error("msg") + val ok: SpanStatus = SpanStatus.Ok + assertTrue(error != ok) + }, + test("is not equal to Unset") { + val error: SpanStatus = SpanStatus.Error("msg") + val unset: SpanStatus = SpanStatus.Unset + assertTrue(error != unset) + } + ), + suite("pattern matching")( + test("exhaustive match on Unset") { + val status: SpanStatus = SpanStatus.Unset + val result = status match { + case SpanStatus.Unset => "unset" + case SpanStatus.Ok => "ok" + case SpanStatus.Error(msg) => s"error: $msg" + } + assertTrue(result == "unset") + }, + test("exhaustive match on Ok") { + val status: SpanStatus = SpanStatus.Ok + val result = status match { + case SpanStatus.Unset => "unset" + case SpanStatus.Ok => "ok" + case SpanStatus.Error(msg) => s"error: $msg" + } + assertTrue(result == "ok") + }, + test("exhaustive match on Error with extraction") { + val status: SpanStatus = SpanStatus.Error("test failure") + val result = status match { + case SpanStatus.Unset => "unset" + case SpanStatus.Ok => "ok" + case SpanStatus.Error(msg) => s"error: $msg" + } + assertTrue(result == "error: test failure") + } + ) + ) +} diff --git a/otel/shared/src/test/scala/zio/blocks/otel/TraceFlagsSpec.scala b/otel/shared/src/test/scala/zio/blocks/otel/TraceFlagsSpec.scala new file mode 100644 index 0000000000..ebf482196c --- /dev/null +++ b/otel/shared/src/test/scala/zio/blocks/otel/TraceFlagsSpec.scala @@ -0,0 +1,133 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import zio.test._ + +object TraceFlagsSpec extends ZIOSpecDefault { + + def spec = suite("TraceFlags")( + suite("none")( + test("is zero byte") { + assertTrue(TraceFlags.none.byte == 0x00.toByte) + }, + test("isSampled returns false") { + assertTrue(!TraceFlags.none.isSampled) + } + ), + suite("sampled")( + test("is 0x01 byte") { + assertTrue(TraceFlags.sampled.byte == 0x01.toByte) + }, + test("isSampled returns true") { + assertTrue(TraceFlags.sampled.isSampled) + } + ), + suite("isSampled")( + test("returns true for 0x01") { + val flags = TraceFlags(byte = 0x01.toByte) + assertTrue(flags.isSampled) + }, + test("returns false for 0x00") { + val flags = TraceFlags(byte = 0x00.toByte) + assertTrue(!flags.isSampled) + }, + test("respects bit 0 only") { + val flags = TraceFlags(byte = 0x02.toByte) + assertTrue(!flags.isSampled) + } + ), + suite("withSampled")( + test("sets sampled flag to true") { + val flags = TraceFlags.none.withSampled(true) + assertTrue(flags.isSampled) + }, + test("sets sampled flag to false") { + val flags = TraceFlags.sampled.withSampled(false) + assertTrue(!flags.isSampled) + }, + test("preserves other bits") { + val flags = TraceFlags(byte = 0xfe.toByte).withSampled(true) + assertTrue(flags.byte == 0xff.toByte) + }, + test("clears sampled bit when false") { + val flags = TraceFlags(byte = 0x01.toByte).withSampled(false) + assertTrue(flags.byte == 0x00.toByte) + } + ), + suite("toHex")( + test("produces 2-char lowercase hex") { + val flags = TraceFlags.none + assertTrue(flags.toHex == "00") + }, + test("produces correct hex for sampled") { + val flags = TraceFlags.sampled + assertTrue(flags.toHex == "01") + }, + test("is zero-padded") { + val flags = TraceFlags(byte = 0x0f.toByte) + assertTrue(flags.toHex == "0f") + } + ), + suite("fromHex")( + test("parses valid 2-char hex") { + val result = TraceFlags.fromHex("01") + assertTrue(result.isDefined && result.get.isSampled) + }, + test("returns None for non-hex") { + val result = TraceFlags.fromHex("0g") + assertTrue(result.isEmpty) + }, + test("returns None for wrong length (too short)") { + val result = TraceFlags.fromHex("0") + assertTrue(result.isEmpty) + }, + test("returns None for wrong length (too long)") { + val result = TraceFlags.fromHex("001") + assertTrue(result.isEmpty) + }, + test("handles uppercase") { + val result = TraceFlags.fromHex("FF") + assertTrue(result.isDefined) + }, + test("roundtrips with toHex") { + val flags = TraceFlags(byte = 0xab.toByte) + val hex = flags.toHex + val parsed = TraceFlags.fromHex(hex) + assertTrue(parsed.contains(flags)) + } + ), + suite("toByte")( + test("returns underlying byte") { + val flags = TraceFlags(byte = 0x42.toByte) + assertTrue(flags.toByte == 0x42.toByte) + } + ), + suite("equality")( + test("equal flags compare equal") { + val f1 = TraceFlags(byte = 0x01.toByte) + val f2 = TraceFlags(byte = 0x01.toByte) + assertTrue(f1 == f2) + }, + test("different flags compare not equal") { + val f1 = TraceFlags(byte = 0x01.toByte) + val f2 = TraceFlags(byte = 0x00.toByte) + assertTrue(f1 != f2) + } + ) + ) +} diff --git a/otel/shared/src/test/scala/zio/blocks/otel/TraceIdSpec.scala b/otel/shared/src/test/scala/zio/blocks/otel/TraceIdSpec.scala new file mode 100644 index 0000000000..68dad51c76 --- /dev/null +++ b/otel/shared/src/test/scala/zio/blocks/otel/TraceIdSpec.scala @@ -0,0 +1,141 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import zio.test._ + +object TraceIdSpec extends ZIOSpecDefault { + + def spec = suite("TraceId")( + suite("invalid")( + test("is all zeros") { + assertTrue(TraceId.invalid.hi == 0L && TraceId.invalid.lo == 0L) + }, + test("isValid returns false") { + assertTrue(!TraceId.invalid.isValid) + } + ), + suite("random")( + test("generates valid TraceIds (non-zero)") { + val t = TraceId.random + assertTrue(t.isValid) + }, + test("never returns all-zero trace") { + val traces = (1 to 100).map(_ => TraceId.random) + assertTrue(traces.forall(_.isValid)) + }, + test("generates different traces") { + val t1 = TraceId.random + val t2 = TraceId.random + assertTrue(t1 != t2) + } + ), + suite("fromHex")( + test("parses valid 32-char hex string") { + val hex = "0102030405060708090a0b0c0d0e0f10" + val result = TraceId.fromHex(hex) + assertTrue(result.isDefined) + }, + test("returns None for non-hex characters") { + val hex = "0102030405060708090a0b0c0d0e0f1g" + val result = TraceId.fromHex(hex) + assertTrue(result.isEmpty) + }, + test("returns None for wrong length (too short)") { + val hex = "0102030405060708090a0b0c0d0e0f" + val result = TraceId.fromHex(hex) + assertTrue(result.isEmpty) + }, + test("returns None for wrong length (too long)") { + val hex = "0102030405060708090a0b0c0d0e0f1011" + val result = TraceId.fromHex(hex) + assertTrue(result.isEmpty) + }, + test("handles uppercase hex") { + val hex = "0102030405060708090A0B0C0D0E0F10" + val result = TraceId.fromHex(hex) + assertTrue(result.isDefined) + }, + test("handles all-zero hex (invalid)") { + val hex = "00000000000000000000000000000000" + val result = TraceId.fromHex(hex) + assertTrue(result.isDefined && !result.get.isValid) + } + ), + suite("toHex")( + test("produces 32-char lowercase hex") { + val t = TraceId.invalid + val hex = t.toHex + assertTrue(hex.length == 32 && hex == "00000000000000000000000000000000") + }, + test("is zero-padded") { + val t = TraceId(hi = 1L, lo = 0L) + val hex = t.toHex + assertTrue(hex.length == 32 && hex.startsWith("0000000000000001")) + }, + test("roundtrips with fromHex") { + val t = TraceId.random + val hex = t.toHex + val parsed = TraceId.fromHex(hex) + assertTrue(parsed.contains(t)) + } + ), + suite("isValid")( + test("returns true for non-zero traces") { + val t = TraceId(hi = 1L, lo = 0L) + assertTrue(t.isValid) + }, + test("returns true when lo is non-zero") { + val t = TraceId(hi = 0L, lo = 1L) + assertTrue(t.isValid) + }, + test("returns false when both are zero") { + val t = TraceId(hi = 0L, lo = 0L) + assertTrue(!t.isValid) + } + ), + suite("toByteArray")( + test("produces 16 bytes") { + val t = TraceId.random + val bytes = t.toByteArray + assertTrue(bytes.length == 16) + }, + test("is big-endian") { + val t = TraceId(hi = 0x0102030405060708L, lo = 0x090a0b0c0d0e0f10L) + val bytes = t.toByteArray + assertTrue( + bytes(0) == 0x01.toByte && + bytes(1) == 0x02.toByte && + bytes(8) == 0x09.toByte && + bytes(15) == 0x10.toByte + ) + } + ), + suite("equality")( + test("equal traces compare equal") { + val t1 = TraceId(hi = 123L, lo = 456L) + val t2 = TraceId(hi = 123L, lo = 456L) + assertTrue(t1 == t2) + }, + test("different traces compare not equal") { + val t1 = TraceId(hi = 123L, lo = 456L) + val t2 = TraceId(hi = 123L, lo = 457L) + assertTrue(t1 != t2) + } + ) + ) +} diff --git a/otel/shared/src/test/scala/zio/blocks/otel/TracerSpec.scala b/otel/shared/src/test/scala/zio/blocks/otel/TracerSpec.scala new file mode 100644 index 0000000000..508a3939a7 --- /dev/null +++ b/otel/shared/src/test/scala/zio/blocks/otel/TracerSpec.scala @@ -0,0 +1,327 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import zio.test._ + +import scala.collection.mutable.ArrayBuffer + +object TracerSpec extends ZIOSpecDefault { + + private class TestProcessor extends SpanProcessor { + val started: ArrayBuffer[Span] = ArrayBuffer.empty + val ended: ArrayBuffer[SpanData] = ArrayBuffer.empty + var shutdownCalled: Boolean = false + var forceFlushCalled: Boolean = false + + def onStart(span: Span): Unit = started += span + def onEnd(spanData: SpanData): Unit = ended += spanData + def shutdown(): Unit = shutdownCalled = true + def forceFlush(): Unit = forceFlushCalled = true + } + + def spec = suite("Tracer")( + suite("TracerProvider.builder")( + test("builds with defaults") { + val provider = TracerProvider.builder.build() + val tracer = provider.get("test-lib") + assertTrue(tracer != null) + }, + test("setResource and setSampler") { + val resource = Resource.create( + Attributes.of(AttributeKey.string("service.name"), "my-service") + ) + val provider = TracerProvider.builder + .setResource(resource) + .setSampler(AlwaysOnSampler) + .build() + val tracer = provider.get("test-lib", "1.0.0") + assertTrue(tracer != null) + }, + test("addSpanProcessor registers processor") { + val processor = new TestProcessor + val provider = TracerProvider.builder + .addSpanProcessor(processor) + .build() + val tracer = provider.get("test-lib") + tracer.span("op")(_ => ()) + assertTrue(processor.started.nonEmpty && processor.ended.nonEmpty) + }, + test("shutdown calls shutdown on all processors") { + val p1 = new TestProcessor + val p2 = new TestProcessor + val provider = TracerProvider.builder + .addSpanProcessor(p1) + .addSpanProcessor(p2) + .build() + provider.shutdown() + assertTrue(p1.shutdownCalled && p2.shutdownCalled) + }, + test("forceFlush calls forceFlush on all processors") { + val p1 = new TestProcessor + val p2 = new TestProcessor + val provider = TracerProvider.builder + .addSpanProcessor(p1) + .addSpanProcessor(p2) + .build() + provider.forceFlush() + assertTrue(p1.forceFlushCalled && p2.forceFlushCalled) + } + ), + suite("Tracer.span scoped block")( + test("creates span with valid IDs") { + val processor = new TestProcessor + val tracer = makeTracer(processor) + + var captured: Span = null + tracer.span("my-op") { span => + captured = span + } + + assertTrue( + captured != null && + captured.spanContext.isValid && + captured.spanContext.traceId.isValid && + captured.spanContext.spanId.isValid && + captured.name == "my-op" + ) + }, + test("span is ended after block exits") { + val processor = new TestProcessor + val tracer = makeTracer(processor) + + var captured: Span = null + tracer.span("ending-op") { span => + assertTrue(span.isRecording) + captured = span + } + + assertTrue(!captured.isRecording) + }, + test("span block returns the value from f") { + val tracer = makeTracer(new TestProcessor) + val result = tracer.span("value-op")(_ => 42) + assertTrue(result == 42) + }, + test("SpanProcessor.onStart is called with the span") { + val processor = new TestProcessor + val tracer = makeTracer(processor) + + tracer.span("start-op")(_ => ()) + + assertTrue( + processor.started.size == 1 && + processor.started.head.name == "start-op" + ) + }, + test("SpanProcessor.onEnd receives SpanData") { + val processor = new TestProcessor + val tracer = makeTracer(processor) + + tracer.span("end-op") { span => + span.setAttribute("key", "value") + } + + assertTrue( + processor.ended.size == 1 && + processor.ended.head.name == "end-op" && + processor.ended.head.attributes.get(AttributeKey.string("key")).contains("value") + ) + }, + test("span with kind") { + val processor = new TestProcessor + val tracer = makeTracer(processor) + + tracer.span("server-op", SpanKind.Server) { span => + assertTrue(span.kind == SpanKind.Server) + } + }, + test("span with kind and attributes") { + val processor = new TestProcessor + val tracer = makeTracer(processor) + val attrs = Attributes.of(AttributeKey.string("http.method"), "GET") + + tracer.span("attr-op", SpanKind.Client, attrs) { span => + val data = span.toSpanData + assertTrue(data.attributes.get(AttributeKey.string("http.method")).contains("GET")) + } + }, + test("span ends even if f throws") { + val processor = new TestProcessor + val tracer = makeTracer(processor) + + var captured: Span = null + try + tracer.span("throwing-op") { span => + captured = span + throw new RuntimeException("boom") + } + catch { case _: RuntimeException => () } + + assertTrue( + !captured.isRecording && + processor.ended.size == 1 + ) + } + ), + suite("nested spans")( + test("child inherits parent's traceId but gets new spanId") { + val processor = new TestProcessor + val tracer = makeTracer(processor) + + var parentCtx: SpanContext = null + var childCtx: SpanContext = null + + tracer.span("parent") { parentSpan => + parentCtx = parentSpan.spanContext + tracer.span("child") { childSpan => + childCtx = childSpan.spanContext + } + } + + assertTrue( + parentCtx.traceId == childCtx.traceId && + parentCtx.spanId != childCtx.spanId + ) + }, + test("child's parentSpanContext points to parent") { + val processor = new TestProcessor + val tracer = makeTracer(processor) + + tracer.span("parent") { _ => + tracer.span("child")(_ => ()) + } + + val childData = processor.ended.find(_.name == "child").get + val parentData = processor.ended.find(_.name == "parent").get + + assertTrue( + childData.parentSpanContext.spanId == parentData.spanContext.spanId + ) + }, + test("context is restored after nested span") { + val processor = new TestProcessor + val tracer = makeTracer(processor) + + tracer.span("outer") { _ => + val beforeNested = tracer.currentSpan + tracer.span("inner")(_ => ()) + val afterNested = tracer.currentSpan + assertTrue(beforeNested == afterNested) + } + } + ), + suite("sampler integration")( + test("AlwaysOffSampler results in Span.NoOp and no processor calls") { + val processor = new TestProcessor + val tracer = makeTracer(processor, AlwaysOffSampler) + + var captured: Span = null + tracer.span("dropped-op") { span => + captured = span + } + + assertTrue( + captured == Span.NoOp && + processor.started.isEmpty && + processor.ended.isEmpty + ) + }, + test("AlwaysOnSampler records span") { + val processor = new TestProcessor + val tracer = makeTracer(processor, AlwaysOnSampler) + + var wasRecording = false + tracer.span("sampled-op") { span => + wasRecording = span.isRecording + } + + assertTrue(wasRecording) + }, + test("RecordOnly sampler records span with TraceFlags.none") { + val recordOnlySampler = new Sampler { + def shouldSample( + parentContext: Option[SpanContext], + traceId: TraceId, + name: String, + kind: SpanKind, + attributes: Attributes, + links: Seq[SpanLink] + ): SamplingResult = + SamplingResult(SamplingDecision.RecordOnly, Attributes.empty, "") + def description: String = "RecordOnlySampler" + } + val processor = new TestProcessor + val tracer = makeTracer(processor, recordOnlySampler) + + var captured: Span = null + tracer.span("record-only-op") { span => + captured = span + } + + assertTrue( + captured != Span.NoOp && + !captured.spanContext.isSampled && + captured.spanContext.traceFlags == TraceFlags.none && + processor.started.nonEmpty && + processor.ended.nonEmpty + ) + } + ), + suite("SpanProcessor.noop")( + test("all methods are no-ops") { + val noop = SpanProcessor.noop + noop.onStart(Span.NoOp) + noop.onEnd(Span.NoOp.toSpanData) + noop.shutdown() + noop.forceFlush() + assertTrue(true) + } + ), + suite("Tracer.spanBuilder")( + test("returns a configured SpanBuilder") { + val processor = new TestProcessor + val tracer = makeTracer(processor) + val builder = tracer.spanBuilder("builder-span") + assertTrue(builder != null) + } + ), + suite("Tracer.currentSpan")( + test("returns None outside of span block") { + val tracer = makeTracer(new TestProcessor) + assertTrue(tracer.currentSpan.isEmpty) + }, + test("returns Some inside span block") { + val tracer = makeTracer(new TestProcessor) + tracer.span("current-op") { span => + val current = tracer.currentSpan + assertTrue(current.isDefined && current.get == span.spanContext) + } + } + ) + ) + + private def makeTracer( + processor: SpanProcessor, + sampler: Sampler = AlwaysOnSampler + ): Tracer = + TracerProvider.builder + .setSampler(sampler) + .addSpanProcessor(processor) + .build() + .get("test-tracer") +} diff --git a/otel/shared/src/test/scala/zio/blocks/otel/W3CTraceContextSpec.scala b/otel/shared/src/test/scala/zio/blocks/otel/W3CTraceContextSpec.scala new file mode 100644 index 0000000000..aa2064bbd3 --- /dev/null +++ b/otel/shared/src/test/scala/zio/blocks/otel/W3CTraceContextSpec.scala @@ -0,0 +1,309 @@ +/* + * Copyright 2024-2026 John A. De Goes and the ZIO Contributors + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.blocks.otel + +import zio.test._ + +object W3CTraceContextSpec extends ZIOSpecDefault { + + private val propagator: Propagator = W3CTraceContextPropagator + + private type Headers = Map[String, String] + + private val getter: (Headers, String) => Option[String] = (carrier, key) => carrier.get(key) + + private val setter: (Headers, String, String) => Headers = (carrier, key, value) => carrier + (key -> value) + + def spec = suite("W3CTraceContextPropagator")( + suite("fields")( + test("returns traceparent and tracestate") { + assertTrue(propagator.fields == Seq("traceparent", "tracestate")) + } + ), + suite("extract")( + test("parses valid traceparent with sampled flag") { + val headers = Map( + "traceparent" -> "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" + ) + val result = propagator.extract(headers, getter) + assertTrue( + result.isDefined && + result.get.traceId.toHex == "4bf92f3577b34da6a3ce929d0e0e4736" && + result.get.spanId.toHex == "00f067aa0ba902b7" && + result.get.traceFlags.isSampled && + result.get.isRemote + ) + }, + test("parses valid traceparent without sampled flag") { + val headers = Map( + "traceparent" -> "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-00" + ) + val result = propagator.extract(headers, getter) + assertTrue( + result.isDefined && + !result.get.traceFlags.isSampled + ) + }, + test("parses traceparent with tracestate") { + val headers = Map( + "traceparent" -> "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", + "tracestate" -> "congo=t61rcWkgMzE,rojo=00f067aa0ba902b7" + ) + val result = propagator.extract(headers, getter) + assertTrue( + result.isDefined && + result.get.traceState == "congo=t61rcWkgMzE,rojo=00f067aa0ba902b7" + ) + }, + test("sets empty traceState when tracestate header is absent") { + val headers = Map( + "traceparent" -> "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" + ) + val result = propagator.extract(headers, getter) + assertTrue( + result.isDefined && + result.get.traceState == "" + ) + }, + test("rejects missing traceparent header") { + val headers = Map.empty[String, String] + val result = propagator.extract(headers, getter) + assertTrue(result.isEmpty) + }, + test("rejects unknown version") { + val headers = Map( + "traceparent" -> "01-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" + ) + val result = propagator.extract(headers, getter) + assertTrue(result.isEmpty) + }, + test("rejects all-zero trace ID") { + val headers = Map( + "traceparent" -> "00-00000000000000000000000000000000-00f067aa0ba902b7-01" + ) + val result = propagator.extract(headers, getter) + assertTrue(result.isEmpty) + }, + test("rejects all-zero span ID") { + val headers = Map( + "traceparent" -> "00-4bf92f3577b34da6a3ce929d0e0e4736-0000000000000000-01" + ) + val result = propagator.extract(headers, getter) + assertTrue(result.isEmpty) + }, + test("rejects wrong total length") { + val headers = Map( + "traceparent" -> "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-0" + ) + val result = propagator.extract(headers, getter) + assertTrue(result.isEmpty) + }, + test("rejects non-hex characters in trace ID") { + val headers = Map( + "traceparent" -> "00-zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz-00f067aa0ba902b7-01" + ) + val result = propagator.extract(headers, getter) + assertTrue(result.isEmpty) + }, + test("rejects non-hex characters in span ID") { + val headers = Map( + "traceparent" -> "00-4bf92f3577b34da6a3ce929d0e0e4736-zzzzzzzzzzzzzzzz-01" + ) + val result = propagator.extract(headers, getter) + assertTrue(result.isEmpty) + }, + test("rejects non-hex characters in flags") { + val headers = Map( + "traceparent" -> "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-zz" + ) + val result = propagator.extract(headers, getter) + assertTrue(result.isEmpty) + }, + test("rejects missing dashes") { + val headers = Map( + "traceparent" -> "004bf92f3577b34da6a3ce929d0e0e473600f067aa0ba902b701" + ) + val result = propagator.extract(headers, getter) + assertTrue(result.isEmpty) + }, + test("handles uppercase hex in traceparent") { + val headers = Map( + "traceparent" -> "00-4BF92F3577B34DA6A3CE929D0E0E4736-00F067AA0BA902B7-01" + ) + val result = propagator.extract(headers, getter) + assertTrue( + result.isDefined && + result.get.traceId.toHex == "4bf92f3577b34da6a3ce929d0e0e4736" && + result.get.spanId.toHex == "00f067aa0ba902b7" + ) + }, + test("parses trace flags with non-sampled bits") { + val headers = Map( + "traceparent" -> "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-ff" + ) + val result = propagator.extract(headers, getter) + assertTrue( + result.isDefined && + result.get.traceFlags.isSampled && + result.get.traceFlags.toHex == "ff" + ) + }, + test("rejects empty traceparent value") { + val headers = Map("traceparent" -> "") + val result = propagator.extract(headers, getter) + assertTrue(result.isEmpty) + } + ), + suite("inject")( + test("formats traceparent correctly") { + val traceId = TraceId.fromHex("4bf92f3577b34da6a3ce929d0e0e4736").get + val spanId = SpanId.fromHex("00f067aa0ba902b7").get + val ctx = SpanContext.create(traceId, spanId, TraceFlags.sampled, "", isRemote = false) + + val headers = propagator.inject(ctx, Map.empty[String, String], setter) + assertTrue(headers("traceparent") == "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01") + }, + test("injects tracestate when non-empty") { + val traceId = TraceId.fromHex("4bf92f3577b34da6a3ce929d0e0e4736").get + val spanId = SpanId.fromHex("00f067aa0ba902b7").get + val ctx = + SpanContext.create( + traceId, + spanId, + TraceFlags.sampled, + "congo=t61rcWkgMzE,rojo=00f067aa0ba902b7", + isRemote = false + ) + + val headers = propagator.inject(ctx, Map.empty[String, String], setter) + assertTrue( + headers("traceparent") == "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" && + headers("tracestate") == "congo=t61rcWkgMzE,rojo=00f067aa0ba902b7" + ) + }, + test("does not inject tracestate when empty") { + val traceId = TraceId.fromHex("4bf92f3577b34da6a3ce929d0e0e4736").get + val spanId = SpanId.fromHex("00f067aa0ba902b7").get + val ctx = SpanContext.create(traceId, spanId, TraceFlags.sampled, "", isRemote = false) + + val headers = propagator.inject(ctx, Map.empty[String, String], setter) + assertTrue( + headers.contains("traceparent") && + !headers.contains("tracestate") + ) + }, + test("formats unsampled flags as 00") { + val traceId = TraceId.fromHex("4bf92f3577b34da6a3ce929d0e0e4736").get + val spanId = SpanId.fromHex("00f067aa0ba902b7").get + val ctx = SpanContext.create(traceId, spanId, TraceFlags.none, "", isRemote = false) + + val headers = propagator.inject(ctx, Map.empty[String, String], setter) + assertTrue(headers("traceparent") == "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-00") + }, + test("does not inject invalid span context") { + val headers = propagator.inject(SpanContext.invalid, Map.empty[String, String], setter) + assertTrue(headers.isEmpty) + } + ), + suite("roundtrip")( + test("inject then extract produces equivalent SpanContext") { + val traceId = TraceId.fromHex("4bf92f3577b34da6a3ce929d0e0e4736").get + val spanId = SpanId.fromHex("00f067aa0ba902b7").get + val original = + SpanContext.create(traceId, spanId, TraceFlags.sampled, "congo=t61rcWkgMzE", isRemote = false) + + val headers = propagator.inject(original, Map.empty[String, String], setter) + val restored = propagator.extract(headers, getter) + + assertTrue( + restored.isDefined && + restored.get.traceId == original.traceId && + restored.get.spanId == original.spanId && + restored.get.traceFlags == original.traceFlags && + restored.get.traceState == original.traceState && + restored.get.isRemote + ) + }, + test("roundtrip with empty tracestate") { + val traceId = TraceId.fromHex("4bf92f3577b34da6a3ce929d0e0e4736").get + val spanId = SpanId.fromHex("00f067aa0ba902b7").get + val original = SpanContext.create(traceId, spanId, TraceFlags.none, "", isRemote = false) + + val headers = propagator.inject(original, Map.empty[String, String], setter) + val restored = propagator.extract(headers, getter) + + assertTrue( + restored.isDefined && + restored.get.traceId == original.traceId && + restored.get.spanId == original.spanId && + restored.get.traceFlags == original.traceFlags && + restored.get.traceState == "" + ) + }, + test("roundtrip with multiple tracestate entries") { + val traceId = TraceId.fromHex("4bf92f3577b34da6a3ce929d0e0e4736").get + val spanId = SpanId.fromHex("00f067aa0ba902b7").get + val state = "vendor1=value1,vendor2=value2,vendor3=value3" + val original = + SpanContext.create(traceId, spanId, TraceFlags.sampled, state, isRemote = true) + + val headers = propagator.inject(original, Map.empty[String, String], setter) + val restored = propagator.extract(headers, getter) + + assertTrue( + restored.isDefined && + restored.get.traceState == state + ) + } + ), + suite("edge cases")( + test("extract with whitespace-trimmed traceparent") { + val headers = Map( + "traceparent" -> " 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 " + ) + val result = propagator.extract(headers, getter) + assertTrue( + result.isDefined && + result.get.traceId.toHex == "4bf92f3577b34da6a3ce929d0e0e4736" + ) + }, + test("extract with whitespace-trimmed tracestate") { + val headers = Map( + "traceparent" -> "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", + "tracestate" -> " congo=t61rcWkgMzE " + ) + val result = propagator.extract(headers, getter) + assertTrue( + result.isDefined && + result.get.traceState == "congo=t61rcWkgMzE" + ) + }, + test("inject preserves existing carrier entries") { + val traceId = TraceId.fromHex("4bf92f3577b34da6a3ce929d0e0e4736").get + val spanId = SpanId.fromHex("00f067aa0ba902b7").get + val ctx = SpanContext.create(traceId, spanId, TraceFlags.sampled, "", isRemote = false) + val existingHeaders = Map("x-custom" -> "keep-me") + + val headers = propagator.inject(ctx, existingHeaders, setter) + assertTrue( + headers("x-custom") == "keep-me" && + headers.contains("traceparent") + ) + } + ) + ) +} diff --git a/rpc/shared/src/main/scala-2/zio/blocks/rpc/RPCCompanionVersionSpecific.scala b/rpc/shared/src/main/scala-2/zio/blocks/rpc/RPCCompanionVersionSpecific.scala new file mode 100644 index 0000000000..e69de29bb2 diff --git a/rpc/shared/src/main/scala-2/zio/blocks/rpc/RPCVersionSpecific.scala b/rpc/shared/src/main/scala-2/zio/blocks/rpc/RPCVersionSpecific.scala new file mode 100644 index 0000000000..e69de29bb2 diff --git a/rpc/shared/src/test/scala-3/zio/blocks/rpc/fixtures/TestFixtures.scala b/rpc/shared/src/test/scala-3/zio/blocks/rpc/fixtures/TestFixtures.scala new file mode 100644 index 0000000000..e69de29bb2 diff --git a/rpc/shared/src/test/scala-3/zio/blocks/rpc/jsonrpc/JsonRpcIntegrationSpec.scala b/rpc/shared/src/test/scala-3/zio/blocks/rpc/jsonrpc/JsonRpcIntegrationSpec.scala new file mode 100644 index 0000000000..e69de29bb2