From 50add0022c6fb13dac1504a88acd98fd35b211f2 Mon Sep 17 00:00:00 2001 From: Pawel Sadlo Date: Thu, 8 Oct 2026 15:38:48 +0200 Subject: [PATCH 01/38] work --- build.sbt | 17 +- core/src/main/scala/ox/computeIntensive.scala | 109 +- core/src/main/scala/ox/flow/Flow.scala | 2 - core/src/main/scala/ox/flow/FlowOps.scala | 1087 +++++++++-------- core/src/main/scala/ox/oxThreadFactory.scala | 4 +- generated-doc/out/utils/oxapp.md | 3 +- otel/src/main/scala/ox/flow/otel/CFlow.scala | 74 ++ .../main/scala/ox/flow/otel/OtelFlow.scala | 448 +++++++ .../src/main/scala/ox/flow/otel/Tarcing.scala | 78 ++ .../ox/telemetry/AdaptiveOxTracing.scala | 29 + .../scala/ox/telemetry/ContextHolder.scala | 28 + .../telemetry/tracing/AutoCloseableSpan.scala | 14 + .../ox/telemetry/tracing/OxTracing.scala | 120 ++ .../telemetry/tracing/OxTracingByName.scala | 51 + .../telemetry/tracing/TracingStrategy.scala | 0 .../tracing/flow/OxFlowTracing.scala | 28 + .../tracing/impl/OxTracingBase.scala | 35 + otel/src/test/scala/ox/kafka/AppTest.scala | 33 + otel/src/test/scala/ox/kafka/OtelTest.scala | 495 ++++++++ 19 files changed, 2075 insertions(+), 580 deletions(-) create mode 100644 otel/src/main/scala/ox/flow/otel/CFlow.scala create mode 100644 otel/src/main/scala/ox/flow/otel/OtelFlow.scala create mode 100644 otel/src/main/scala/ox/flow/otel/Tarcing.scala create mode 100644 otel/src/main/scala/ox/telemetry/AdaptiveOxTracing.scala create mode 100644 otel/src/main/scala/ox/telemetry/ContextHolder.scala create mode 100644 otel/src/main/scala/ox/telemetry/tracing/AutoCloseableSpan.scala create mode 100644 otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala create mode 100644 otel/src/main/scala/ox/telemetry/tracing/OxTracingByName.scala create mode 100644 otel/src/main/scala/ox/telemetry/tracing/TracingStrategy.scala create mode 100644 otel/src/main/scala/ox/telemetry/tracing/flow/OxFlowTracing.scala create mode 100644 otel/src/main/scala/ox/telemetry/tracing/impl/OxTracingBase.scala create mode 100644 otel/src/test/scala/ox/kafka/AppTest.scala create mode 100644 otel/src/test/scala/ox/kafka/OtelTest.scala diff --git a/build.sbt b/build.sbt index cfef6bce..39752907 100644 --- a/build.sbt +++ b/build.sbt @@ -1,5 +1,5 @@ -import com.softwaremill.SbtSoftwareMillCommon.commonSmlBuildSettings import com.softwaremill.Publish.{ossPublishSettings, updateDocs} +import com.softwaremill.SbtSoftwareMillCommon.commonSmlBuildSettings import com.softwaremill.UpdateVersionInDocs import com.typesafe.tools.mima.core.{MissingClassProblem, ProblemFilters} @@ -135,6 +135,21 @@ lazy val otelContext: Project = (project in file("otel-context")) ) .dependsOn(core % "test->test;compile->compile") +lazy val otel: Project = (project in file("otel")) + .settings(commonSettings) + .settings( + name := "otel", + Test / fork := true, + Test / javaOptions += "-Dio.opentelemetry.context.enableStrictContext=true", + libraryDependencies ++= Seq( + "io.opentelemetry" % "opentelemetry-api" % "1.66.0", + "io.opentelemetry" % "opentelemetry-sdk" % "1.66.0", + scalaTest, + "io.opentelemetry" % "opentelemetry-sdk-testing" % "1.66.0" % Test + ) + ) + .dependsOn(core % "test->test;compile->compile", otelContext % "test->test;compile->compile") + lazy val documentation: Project = (project in file("generated-doc")) // important: it must not be doc/ .enablePlugins(MdocPlugin) .settings( diff --git a/core/src/main/scala/ox/computeIntensive.scala b/core/src/main/scala/ox/computeIntensive.scala index 356496dc..d9a482a2 100644 --- a/core/src/main/scala/ox/computeIntensive.scala +++ b/core/src/main/scala/ox/computeIntensive.scala @@ -1,10 +1,6 @@ package ox -import java.util.concurrent.CompletableFuture -import java.util.concurrent.ExecutionException -import java.util.concurrent.ExecutorService -import java.util.concurrent.Executors -import java.util.concurrent.ThreadFactory +import java.util.concurrent.* import java.util.concurrent.atomic.AtomicInteger // @@ -14,11 +10,11 @@ import java.util.concurrent.atomic.AtomicInteger private var customComputeExecutor: ExecutorService = _ /** Sets the executor used to run computations passed to [[computeIntensive]]. Should be called once, at the start of the application, - * before any [[computeIntensive]] calls; the executor's lifecycle (shutdown) is then the responsibility of the caller. - * - * @see - * [[oxComputeExecutor]] - */ + * before any [[computeIntensive]] calls; the executor's lifecycle (shutdown) is then the responsibility of the caller. + * + * @see + * [[oxComputeExecutor]] + */ def setOxComputeExecutor(executor: ExecutorService): Unit = customComputeExecutor = executor // forcing the lazy val: if it wasn't yet initialized, it is now initialized to the executor just set above (so the check passes); @@ -27,25 +23,28 @@ def setOxComputeExecutor(executor: ExecutorService): Unit = throw new RuntimeException("The compute executor was already used before setting a custom one!") /** The executor which is used to run computations passed to [[computeIntensive]]. By default, a fixed pool of - * `Runtime.getRuntime.availableProcessors()` daemon platform threads, named `ox-compute-N`, created lazily on first use. Platform threads - * are preempted by the OS, hence CPU-intensive computations running on this executor can't monopolize the virtual thread scheduler's - * carrier threads. - * - * A custom executor should be set once at the start of the application, before any [[computeIntensive]] calls, using - * [[setOxComputeExecutor]]; its lifecycle (shutdown) is then the responsibility of the caller. - * - * @see - * [[OxApp.Settings]] - */ + * `Runtime.getRuntime.availableProcessors()` daemon platform threads, named `ox-compute-N`, created lazily on first use. Platform threads + * are preempted by the OS, hence CPU-intensive computations running on this executor can't monopolize the virtual thread scheduler's + * carrier threads. + * + * A custom executor should be set once at the start of the application, before any [[computeIntensive]] calls, using + * [[setOxComputeExecutor]]; its lifecycle (shutdown) is then the responsibility of the caller. + * + * @see + * [[OxApp.Settings]] + */ + +private def defaultComputeIntensiveExecutor = + val counter = new AtomicInteger(0) + val threadFactory: ThreadFactory = r => + val t = new Thread(r, s"ox-compute-${counter.getAndIncrement()}") + t.setDaemon(true) + t + Executors.newFixedThreadPool(Runtime.getRuntime.availableProcessors(), threadFactory) + lazy val oxComputeExecutor: ExecutorService = val custom = customComputeExecutor - if custom == null then - val counter = new AtomicInteger(0) - val threadFactory: ThreadFactory = r => - val t = new Thread(r, s"ox-compute-${counter.getAndIncrement()}") - t.setDaemon(true) - t - Executors.newFixedThreadPool(Runtime.getRuntime.availableProcessors(), threadFactory) + if custom == null then defaultComputeIntensiveExecutor else custom end if end oxComputeExecutor @@ -64,35 +63,35 @@ private val currentComputeExecutor = new ThreadLocal[ExecutorService]() // /** Runs `f` on the compute-intensive executor ([[oxComputeExecutor]]), blocking the calling (virtual) thread until it completes. Returns - * the result of `f`, or rethrows the exception with which it failed. - * - * Use for long-running, CPU-intensive computations: virtual threads are not preempted, so such a computation, if run directly in a fork, - * would monopolize a carrier thread of the virtual thread scheduler, potentially starving other virtual threads. The computation is - * instead run on a pool of platform threads (by default sized to the number of available processors), which the OS schedules preemptively. - * For short computation bursts, which can be instrumented with periodic yields, see [[cede]] as a lighter-weight alternative. - * - * As the calling thread blocks until the computation completes, the computation never outlives the enclosing concurrency scope (if any): - * usage remains structured. To evaluate `f` in parallel with other code, combine with a fork, e.g. `fork(computeIntensive(f))`. - * - * If the calling thread is interrupted (e.g. because the enclosing scope ends), the thread running the computation becomes interrupted as - * well, and the call keeps waiting until the computation completes. The computation can co-operate in the cancellation protocol using - * [[checkInterrupt]] or [[cede]]. If the computation hasn't yet started when the interruption occurs, it will never run. - * - * The scope context is not propagated to the computation: [[ForkLocal]]s read their default values, and forks can't be created within `f` - * (this fails with an [[IllegalStateException]]). - * - * @throws InterruptedException - * if the current thread is interrupted, either on entry, or while waiting for the computation to complete. - */ + * the result of `f`, or rethrows the exception with which it failed. + * + * Use for long-running, CPU-intensive computations: virtual threads are not preempted, so such a computation, if run directly in a fork, + * would monopolize a carrier thread of the virtual thread scheduler, potentially starving other virtual threads. The computation is + * instead run on a pool of platform threads (by default sized to the number of available processors), which the OS schedules preemptively. + * For short computation bursts, which can be instrumented with periodic yields, see [[cede]] as a lighter-weight alternative. + * + * As the calling thread blocks until the computation completes, the computation never outlives the enclosing concurrency scope (if any): + * usage remains structured. To evaluate `f` in parallel with other code, combine with a fork, e.g. `fork(computeIntensive(f))`. + * + * If the calling thread is interrupted (e.g. because the enclosing scope ends), the thread running the computation becomes interrupted as + * well, and the call keeps waiting until the computation completes. The computation can co-operate in the cancellation protocol using + * [[checkInterrupt]] or [[cede]]. If the computation hasn't yet started when the interruption occurs, it will never run. + * + * The scope context is not propagated to the computation: [[ForkLocal]]s read their default values, and forks can't be created within `f` + * (this fails with an [[IllegalStateException]]). + * + * @throws InterruptedException + * if the current thread is interrupted, either on entry, or while waiting for the computation to complete. + */ def computeIntensive[T](f: => T): T = computeIntensive(oxComputeExecutor)(f) /** As [[computeIntensive]], but runs `f` on the given `executor`, instead of the default [[oxComputeExecutor]]. - * - * Nested `computeIntensive` calls targeting the same executor (compared by reference) run inline, avoiding a deadlock, which could - * otherwise occur when all threads of a fixed-size pool block on nested tasks, queued behind them. Note that wrapping/decorating an - * executor defeats this detection: nested calls through a wrapper of the current executor are submitted normally, and can deadlock a - * fixed-size pool. - */ + * + * Nested `computeIntensive` calls targeting the same executor (compared by reference) run inline, avoiding a deadlock, which could + * otherwise occur when all threads of a fixed-size pool block on nested tasks, queued behind them. Note that wrapping/decorating an + * executor defeats this detection: nested calls through a wrapper of the current executor are submitted normally, and can deadlock a + * fixed-size pool. + */ def computeIntensive[T](executor: ExecutorService)(f: => T): T = checkInterrupt() if currentComputeExecutor.get() eq executor then f @@ -118,7 +117,7 @@ private class ComputeIntensiveTask[T](executor: ExecutorService, f: () => T): // a direct InterruptedException means the caller was interrupted while waiting; a task-thrown exception // (including a task-thrown InterruptedException) arrives wrapped in an ExecutionException, and is unwrapped below case e: InterruptedException => onCallerInterrupted(e) - case e: ExecutionException => throw causeWithSelfAsSuppressed(e) + case e: ExecutionException => throw causeWithSelfAsSuppressed(e) end submitAndAwait private def run(): Unit = @@ -171,7 +170,7 @@ private class ComputeIntensiveTask[T](executor: ExecutorService, f: () => T): done = true catch case e2: InterruptedException => e.addSuppressed(e2) - case e2: ExecutionException => + case e2: ExecutionException => e.addSuppressed(e2.getCause) // the task's exception; never `e` itself, as that comes from the caller's `get()` done = true end while diff --git a/core/src/main/scala/ox/flow/Flow.scala b/core/src/main/scala/ox/flow/Flow.scala index 0591ac53..762610f7 100644 --- a/core/src/main/scala/ox/flow/Flow.scala +++ b/core/src/main/scala/ox/flow/Flow.scala @@ -26,8 +26,6 @@ class Flow[+T](protected val last: FlowStage[T]) object Flow extends FlowCompanionOps with FlowCompanionIOOps with FlowCompanionReactiveOps -// - /** Contains the logic for running a single flow stage. As part of `run`s implementation, previous flow stages might be run, either * synchronously or asynchronously. */ diff --git a/core/src/main/scala/ox/flow/FlowOps.scala b/core/src/main/scala/ox/flow/FlowOps.scala index 23de8a7e..db6fba20 100644 --- a/core/src/main/scala/ox/flow/FlowOps.scala +++ b/core/src/main/scala/ox/flow/FlowOps.scala @@ -34,12 +34,12 @@ class FlowOps[+T]: outer: Flow[T] => /** When run, the current pipeline is run asynchornously in the background, emitting elements to a buffer. The elements of the buffer are - * then emitted by the returned flow. - * - * The size of the buffer is determined by the [[BufferCapacity]] that is in scope. - * - * Any exceptions are propagated by the returned flow. - */ + * then emitted by the returned flow. + * + * The size of the buffer is determined by the [[BufferCapacity]] that is in scope. + * + * Any exceptions are propagated by the returned flow. + */ def buffer()(using BufferCapacity): Flow[T] = Flow.usingEmitInline: emit => val ch = BufferCapacity.newChannel[T] unsupervised: @@ -47,25 +47,25 @@ class FlowOps[+T]: FlowEmit.channelToEmit(ch, emit) /** Batches elements based on a weighted cost function. When downstream is slower than upstream, elements are eagerly pulled and - * aggregated. - * - * The first upstream element creates the initial aggregate via `seed`. Subsequent elements are aggregated using `aggregate` as long as - * their cumulative cost (determined by `costFn`) does not exceed `maxWeight`. When the cost would be exceeded, the current aggregate is - * emitted downstream and a new aggregate is started from the element that exceeded the budget. - * - * A single element whose cost exceeds `maxWeight` is still emitted (via `seed`), ensuring no elements are dropped. - * - * Creates an asynchronous boundary. - * - * @param maxWeight - * The maximum cumulative cost before emitting the current aggregate. - * @param costFn - * A function computing the cost of each element. Must return non-negative values. - * @param seed - * A function creating the initial aggregate from the first element. - * @param aggregate - * A function combining the current aggregate with a new element. - */ + * aggregated. + * + * The first upstream element creates the initial aggregate via `seed`. Subsequent elements are aggregated using `aggregate` as long as + * their cumulative cost (determined by `costFn`) does not exceed `maxWeight`. When the cost would be exceeded, the current aggregate is + * emitted downstream and a new aggregate is started from the element that exceeded the budget. + * + * A single element whose cost exceeds `maxWeight` is still emitted (via `seed`), ensuring no elements are dropped. + * + * Creates an asynchronous boundary. + * + * @param maxWeight + * The maximum cumulative cost before emitting the current aggregate. + * @param costFn + * A function computing the cost of each element. Must return non-negative values. + * @param seed + * A function creating the initial aggregate from the first element. + * @param aggregate + * A function combining the current aggregate with a new element. + */ def batchWeighted[S](maxWeight: Long, costFn: T => Long, seed: T => S)(aggregate: (S, T) => S)(using BufferCapacity): Flow[S] = require(maxWeight > 0, "maxWeight must be > 0") Flow.usingEmitInline: emit => @@ -149,7 +149,7 @@ class FlowOps[+T]: flushAgg(a) agg match case Some(_) => true // still have data from pending - case None => + case None => output.done() false @@ -161,66 +161,66 @@ class FlowOps[+T]: end batchWeighted /** Batches elements into groups of up to `max` elements. When downstream is slower than upstream, elements are eagerly pulled and - * aggregated using the provided `seed` and `aggregate` functions. - * - * Equivalent to `batchWeighted(max, _ => 1, seed)(aggregate)`. - * - * Creates an asynchronous boundary. - * - * @param max - * The maximum number of elements per batch. - * @param seed - * A function creating the initial aggregate from the first element. - * @param aggregate - * A function combining the current aggregate with a new element. - */ + * aggregated using the provided `seed` and `aggregate` functions. + * + * Equivalent to `batchWeighted(max, _ => 1, seed)(aggregate)`. + * + * Creates an asynchronous boundary. + * + * @param max + * The maximum number of elements per batch. + * @param seed + * A function creating the initial aggregate from the first element. + * @param aggregate + * A function combining the current aggregate with a new element. + */ def batch[S](max: Long, seed: T => S)(aggregate: (S, T) => S)(using BufferCapacity): Flow[S] = require(max > 0, "max must be > 0") batchWeighted(max, _ => 1L, seed)(aggregate) /** Conflates elements when downstream is slower than upstream, using the provided `seed` to initialize the aggregate and `aggregate` to - * combine elements. - * - * Equivalent to `batchWeighted(1, _ => 0, seed)(aggregate)` — cost is always 0 so the budget is never exceeded, resulting in unbounded - * aggregation while downstream is busy. - * - * Creates an asynchronous boundary. - * - * @param seed - * A function creating the initial aggregate from the first element. - * @param aggregate - * A function combining the current aggregate with a new element. - */ + * combine elements. + * + * Equivalent to `batchWeighted(1, _ => 0, seed)(aggregate)` — cost is always 0 so the budget is never exceeded, resulting in unbounded + * aggregation while downstream is busy. + * + * Creates an asynchronous boundary. + * + * @param seed + * A function creating the initial aggregate from the first element. + * @param aggregate + * A function combining the current aggregate with a new element. + */ def conflateWithSeed[S](seed: T => S)(aggregate: (S, T) => S)(using BufferCapacity): Flow[S] = batchWeighted(1L, _ => 0L, seed)(aggregate) /** Conflates elements when downstream is slower than upstream, using the provided `aggregate` function to combine elements. - * - * Equivalent to `conflateWithSeed(identity)(aggregate)`. - * - * Creates an asynchronous boundary. - * - * @param aggregate - * A function combining two elements. - */ + * + * Equivalent to `conflateWithSeed(identity)(aggregate)`. + * + * Creates an asynchronous boundary. + * + * @param aggregate + * A function combining two elements. + */ def conflate[T2 >: T](aggregate: (T2, T2) => T2)(using BufferCapacity): Flow[T2] = conflateWithSeed[T2](identity)(aggregate) /** Expands elements by applying the `expander` function to each upstream element, producing an iterator. When downstream is faster than - * upstream, elements from the iterator are emitted. When a new upstream element arrives, it replaces the current iterator. - * - * Note that upstream elements are not emitted directly — only the elements produced by the `expander` iterator are. To also emit the - * original element, include it in the iterator (e.g., `Iterator.single(elem) ++ ...`), or use [[extrapolate]] which does this - * automatically. - * - * When a new upstream element arrives while a send from the current iterator is pending, the pending value is discarded in favor of the - * new element's iterator. This favors freshness over completeness. - * - * Creates an asynchronous boundary. - * - * @param expander - * A function that creates an iterator from an upstream element. - */ + * upstream, elements from the iterator are emitted. When a new upstream element arrives, it replaces the current iterator. + * + * Note that upstream elements are not emitted directly — only the elements produced by the `expander` iterator are. To also emit the + * original element, include it in the iterator (e.g., `Iterator.single(elem) ++ ...`), or use [[extrapolate]] which does this + * automatically. + * + * When a new upstream element arrives while a send from the current iterator is pending, the pending value is discarded in favor of the + * new element's iterator. This favors freshness over completeness. + * + * Creates an asynchronous boundary. + * + * @param expander + * A function that creates an iterator from an upstream element. + */ def expand[U](expander: T => Iterator[U])(using BufferCapacity): Flow[U] = Flow.usingEmitInline: emit => val output = Channel.rendezvous[U] @@ -278,17 +278,17 @@ class FlowOps[+T]: end expand /** Extrapolates elements when downstream is faster than upstream. Each upstream element is first emitted as-is, then the `extrapolator` - * function is used to generate additional elements. - * - * Optionally, an `initial` element can be provided which will be emitted before the first upstream element arrives. - * - * Creates an asynchronous boundary. - * - * @param extrapolator - * A function generating extra elements from the last upstream element. - * @param initial - * An optional element to emit before any upstream element. - */ + * function is used to generate additional elements. + * + * Optionally, an `initial` element can be provided which will be emitted before the first upstream element arrives. + * + * Creates an asynchronous boundary. + * + * @param extrapolator + * A function generating extra elements from the last upstream element. + * @param initial + * An optional element to emit before any upstream element. + */ def extrapolate[U >: T](extrapolator: U => Iterator[U], initial: Option[U] = None)(using BufferCapacity): Flow[U] = val base: Flow[U] = expand[U](u => Iterator.single(u) ++ extrapolator(u)) initial.fold(base)(e => Flow.fromValues(e).concat(base)) @@ -296,38 +296,38 @@ class FlowOps[+T]: // /** Applies the given mapping function `f` to each element emitted by this flow. The returned flow then emits the results. - * - * @param f - * The mapping function. - */ + * + * @param f + * The mapping function. + */ def map[U](f: T => U): Flow[U] = Flow.usingEmitInline: emit => last.run(FlowEmit.fromInline(t => emit(f(t)))) /** Applies the given mapping function `f` to each element emitted by this flow, in sequence. The given [[FlowSink]] can be used to emit - * an arbirary number of elements. - * - * The `FlowEmit` instance provided to the `f` callback should only be used on the calling thread. That is, `FlowEmit` is thread-unsafe. - * Moreover, the instance should not be stored or captured in closures, which outlive the invocation of `f`. - * - * @param f - * The mapping function. - */ + * an arbirary number of elements. + * + * The `FlowEmit` instance provided to the `f` callback should only be used on the calling thread. That is, `FlowEmit` is thread-unsafe. + * Moreover, the instance should not be stored or captured in closures, which outlive the invocation of `f`. + * + * @param f + * The mapping function. + */ def mapUsingEmit[U](f: T => FlowEmit[U] => Unit): Flow[U] = Flow.usingEmitInline: emit => last.run(FlowEmit.fromInline(t => f(t)(emit))) /** Emits only those elements emitted by this flow, for which `f` returns `true`. - * - * @param f - * The filtering function. - */ + * + * @param f + * The filtering function. + */ def filter(f: T => Boolean): Flow[T] = Flow.usingEmitInline: emit => last.run(FlowEmit.fromInline(t => if f(t) then emit.apply(t))) /** Emits only every nth element emitted by this flow. - * - * @param n - * The interval between two emitted elements. - */ + * + * @param n + * The interval between two emitted elements. + */ def sample(n: Int): Flow[T] = Flow.usingEmitInline: emit => var sampleCounter = 0 last.run( @@ -337,15 +337,15 @@ class FlowOps[+T]: ) /** Remove subsequent, repeating elements - */ + */ def debounce: Flow[T] = debounceBy(identity) /** Remove subsequent, repeating elements matching 'f' - * - * @param f - * The function used to compare the previous and current elements - */ + * + * @param f + * The function used to compare the previous and current elements + */ def debounceBy[U](f: T => U): Flow[T] = Flow.usingEmitInline: emit => var previousElement: Option[U] = None last.run( @@ -356,22 +356,22 @@ class FlowOps[+T]: ) /** Applies the given mapping function `f` to each element emitted by this flow, for which the function is defined, and emits the result. - * If `f` is not defined at an element, the element will be skipped. - * - * @param f - * The mapping function. - */ + * If `f` is not defined at an element, the element will be skipped. + * + * @param f + * The mapping function. + */ def collect[U](f: PartialFunction[T, U]): Flow[U] = Flow.usingEmitInline: emit => last.run(FlowEmit.fromInline(t => if f.isDefinedAt(t) then emit.apply(f(t)))) /** Transforms the elements of the flow by applying an accumulation function to each element, producing a new value at each step. The - * resulting flow contains the accumulated values at each point in the original flow. - * - * @param initial - * The initial value to start the accumulation. - * @param f - * The accumulation function that is applied to each element of the flow. - */ + * resulting flow contains the accumulated values at each point in the original flow. + * + * @param initial + * The initial value to start the accumulation. + * @param f + * The accumulation function that is applied to each element of the flow. + */ def scan[V](initial: V)(f: (V, T) => V): Flow[V] = Flow.usingEmitInline: emit => emit(initial) var accumulator = initial @@ -382,20 +382,21 @@ class FlowOps[+T]: ) /** Applies the given effectful function `f` to each element emitted by this flow. The returned flow emits the elements unchanged. If `f` - * throws an exceptions, the flow fails and propagates the exception. - */ + * throws an exceptions, the flow fails and propagates the exception. + */ def tap(f: T => Unit): Flow[T] = map(t => - f(t); t + f(t); + t ) /** Applies the given mapping function `f` to each element emitted by this flow, obtaining a nested flow to run. The elements emitted by - * the nested flow are then emitted by the returned flow. - * - * The nested flows are run in sequence, that is, the next nested flow is started only after the previous one completes. - * - * @param f - * The mapping function. - */ + * the nested flow are then emitted by the returned flow. + * + * The nested flows are run in sequence, that is, the next nested flow is started only after the previous one completes. + * + * @param f + * The mapping function. + */ def flatMap[U](f: T => Flow[U]): Flow[U] = Flow.usingEmitInline: emit => last.run( FlowEmit.fromInline: t => @@ -406,15 +407,15 @@ class FlowOps[+T]: def intersperse[U >: T](inject: U): Flow[U] = intersperse(None, inject, None) /** Intersperses elements emitted by this flow with `inject` elements. The `start` element is emitted at the beginning; `end` is emitted - * after the current flow emits the last element. - * - * @param start - * An element to be emitted at the beginning. - * @param inject - * An element to be injected between the flow elements. - * @param end - * An element to be emitted at the end. - */ + * after the current flow emits the last element. + * + * @param start + * An element to be emitted at the beginning. + * @param inject + * An element to be injected between the flow elements. + * @param end + * An element to be emitted at the end. + */ def intersperse[U >: T](start: U, inject: U, end: U): Flow[U] = intersperse(Some(start), inject, Some(end)) private def intersperse[U >: T](start: Option[U], inject: U, end: Option[U]): Flow[U] = Flow.usingEmitInline: emit => @@ -429,15 +430,15 @@ class FlowOps[+T]: end.foreach(emit.apply) /** Applies the given mapping function `f` to each element emitted by this flow. At most `parallelism` invocations of `f` are run in - * parallel. - * - * The mapped results are emitted in the same order, in which inputs are received. In other words, ordering is preserved. - * - * @param parallelism - * An upper bound on the number of forks that run in parallel. Each fork runs the function `f` on a single element from the flow. - * @param f - * The mapping function. - */ + * parallel. + * + * The mapped results are emitted in the same order, in which inputs are received. In other words, ordering is preserved. + * + * @param parallelism + * An upper bound on the number of forks that run in parallel. Each fork runs the function `f` on a single element from the flow. + * @param f + * The mapping function. + */ def mapPar[U](parallelism: Int)(f: T => U)(using BufferCapacity): Flow[U] = Flow.usingEmitInline: emit => val s = new Semaphore(parallelism) // providing extra capacity in the `inProgress` channel (but still limiting it so that processing is bounded): @@ -477,8 +478,8 @@ class FlowOps[+T]: inProgress.receiveOrClosed() match // in the fork's result is a `None`, the error is already propagated to the `results` channel case f: Fork[Option[U]] @unchecked => f.join().map(results.sendOrClosed).isDefined - case ChannelClosed.Done => results.done(); false - case ChannelClosed.Error(e) => throw new IllegalStateException("inProgress should never be closed with an error", e) + case ChannelClosed.Done => results.done(); false + case ChannelClosed.Error(e) => throw new IllegalStateException("inProgress should never be closed with an error", e) .discard // in the main body, we call the `emit` methods using the (sequentially received) results; when an error occurs, @@ -487,15 +488,15 @@ class FlowOps[+T]: end mapPar /** Applies the given mapping function `f` to each element emitted by this flow. At most `parallelism` invocations of `f` are run in - * parallel. - * - * The mapped results **might** be emitted out-of-order, depending on the order in which the mapping function completes. - * - * @param parallelism - * An upper bound on the number of forks that run in parallel. Each fork runs the function `f` on a single element from the flow. - * @param f - * The mapping function. - */ + * parallel. + * + * The mapped results **might** be emitted out-of-order, depending on the order in which the mapping function completes. + * + * @param parallelism + * An upper bound on the number of forks that run in parallel. Each fork runs the function `f` on a single element from the flow. + * @param f + * The mapping function. + */ def mapParUnordered[U](parallelism: Int)(f: T => U)(using BufferCapacity): Flow[U] = Flow.usingEmitInline: emit => val results = BufferCapacity.newChannel[U] val s = new Semaphore(parallelism) @@ -522,8 +523,8 @@ class FlowOps[+T]: private val abortTake = new ControlThrowable("abort take") {} /** Takes the first `n` elements from this flow and emits them. If the flow completes before emitting `n` elements, the returned flow - * completes as well. - */ + * completes as well. + */ def take(n: Int): Flow[T] = Flow.usingEmitInline: emit => var taken = 0 try @@ -539,13 +540,13 @@ class FlowOps[+T]: end try /** Transform the flow so that it emits elements as long as predicate `f` is satisfied (returns `true`). If `includeFirstFailing` is - * `true`, the flow will additionally emit the first element that failed the predicate. After that, the flow will complete as done. - * - * @param f - * A predicate function called on incoming elements. - * @param includeFirstFailing - * Whether the flow should also emit the first element that failed the predicate (`false` by default). - */ + * `true`, the flow will additionally emit the first element that failed the predicate. After that, the flow will complete as done. + * + * @param f + * A predicate function called on incoming elements. + * @param includeFirstFailing + * Whether the flow should also emit the first element that failed the predicate (`false` by default). + */ def takeWhile(f: T => Boolean, includeFirstFailing: Boolean = false): Flow[T] = Flow.usingEmitInline: emit => try last.run( @@ -558,10 +559,10 @@ class FlowOps[+T]: catch case `abortTake` => () // done /** Drops `n` elements from this flow and emits subsequent elements. - * - * @param n - * Number of elements to be dropped. - */ + * + * @param n + * Number of elements to be dropped. + */ def drop(n: Int): Flow[T] = Flow.usingEmitInline: emit => var dropped = 0 last.run( @@ -571,22 +572,23 @@ class FlowOps[+T]: ) /** Merges two flows into a single flow. The resulting flow emits elements from both flows in the order they are emitted. If one of the - * flows completes before the other, the remaining elements from the other flow are emitted by the returned flow. This can be changed - * with the `propagateDoneLeft` and `propagateDoneRight` flags. - * - * Both flows are run concurrently in the background. The size of the buffers is determined by the [[BufferCapacity]] that is in scope. - * - * @param other - * The flow to be merged with this flow. - * @param propagateDoneLeft - * Should the resulting flow complete when the left flow (`this`) completes, before the `other` flow. By default `false`, that is any - * remaining elements from the `other` flow are emitted. - * @param propagateDoneRight - * Should the resulting flow complete when the right flow (`outer`) completes, before `this` flow. By default `false`, that is any - * remaining elements from `this` flow are emitted. - */ + * flows completes before the other, the remaining elements from the other flow are emitted by the returned flow. This can be changed + * with the `propagateDoneLeft` and `propagateDoneRight` flags. + * + * Both flows are run concurrently in the background. The size of the buffers is determined by the [[BufferCapacity]] that is in scope. + * + * @param other + * The flow to be merged with this flow. + * @param propagateDoneLeft + * Should the resulting flow complete when the left flow (`this`) completes, before the `other` flow. By default `false`, that is any + * remaining elements from the `other` flow are emitted. + * + * @param propagateDoneRight + * Should the resulting flow complete when the right flow (`outer`) completes, before `this` flow. By default `false`, that is any + * remaining elements from `this` flow are emitted. + */ def merge[U >: T](other: Flow[U], propagateDoneLeft: Boolean = false, propagateDoneRight: Boolean = false)(using - BufferCapacity + BufferCapacity ): Flow[U] = Flow.usingEmitInline: emit => unsupervised: @@ -601,27 +603,27 @@ class FlowOps[+T]: else if !propagateDoneRight then FlowEmit.channelToEmit(c1, emit) false case e: ChannelClosed.Error => throw e.toThrowable - case r: U @unchecked => emit(r); true + case r: U @unchecked => emit(r); true /** Given that this flow emits other flows, flattens the nested flows into a single flow. The resulting flow emits elements from the - * nested flows in the order they are emitted. - * - * The nested flows are run in sequence, that is, the next nested flow is started only after the previous one completes. - */ + * nested flows in the order they are emitted. + * + * The nested flows are run in sequence, that is, the next nested flow is started only after the previous one completes. + */ def flatten[U](using T <:< Flow[U]): Flow[U] = this.flatMap(identity) /** Pipes the elements of child flows into the returned flow. - * - * If the this flow or any of the child flows emit an error, the pulling stops and the output flow propagates the error. - * - * Up to [[parallelism]] child flows are run concurrently in the background. When the limit is reached, until a child flow completes, no - * more child flows are run. - * - * The size of the buffers for the elements emitted by the child flows is determined by the [[BufferCapacity]] that is in scope. - * - * @param parallelism - * An upper bound on the number of child flows that run in parallel. - */ + * + * If the this flow or any of the child flows emit an error, the pulling stops and the output flow propagates the error. + * + * Up to [[parallelism]] child flows are run concurrently in the background. When the limit is reached, until a child flow completes, no + * more child flows are run. + * + * The size of the buffers for the elements emitted by the child flows is determined by the [[BufferCapacity]] that is in scope. + * + * @param parallelism + * An upper bound on the number of child flows that run in parallel. + */ def flattenPar[U](parallelism: Int)(using T <:< Flow[U])(using BufferCapacity): Flow[U] = Flow.usingEmitInline: emit => case class Nested(child: Flow[U]) case object ChildDone @@ -671,28 +673,29 @@ class FlowOps[+T]: end flattenPar /** Concatenates this flow with the `other` flow. The resulting flow will emit elements from this flow first, and then from the `other` - * flow. - * - * @param other - * The flow to be appended to this flow. - */ + * flow. + * + * @param other + * The flow to be appended to this flow. + */ def concat[U >: T](other: Flow[U]): Flow[U] = Flow.concat(List(this, other)) /** Alias for [[concat]]. */ def ++[U >: T](other: Flow[U]): Flow[U] = concat(other) /** Prepends `other` flow to this source. The resulting flow will emit elements from `other` flow first, and then from the this flow. - * - * @param other - * The flow to be prepended to this flow. - */ + * + * @param other + * The flow to be prepended to this flow. + */ def prepend[U >: T](other: Flow[U]): Flow[U] = Flow.concat(List(other, this)) /** Combines elements from this and the other flow into tuples. Completion of either flow completes the returned flow as well. The flows - * are run concurrently. - * @see - * zipAll - */ + * are run concurrently. + * + * @see + * zipAll + */ def zip[U](other: Flow[U]): Flow[(T, U)] = Flow.usingEmitInline: emit => unsupervised: val s1 = outer.runToChannel() @@ -700,24 +703,24 @@ class FlowOps[+T]: repeatWhile: s1.receiveOrClosed() match - case ChannelClosed.Done => false + case ChannelClosed.Done => false case e: ChannelClosed.Error => throw e.toThrowable - case t: T @unchecked => + case t: T @unchecked => s2.receiveOrClosed() match - case ChannelClosed.Done => false + case ChannelClosed.Done => false case e: ChannelClosed.Error => throw e.toThrowable - case u: U @unchecked => emit((t, u)); true + case u: U @unchecked => emit((t, u)); true /** Combines elements from this and the other flow into tuples, handling early completion of either flow with defaults. The flows are run - * concurrently. - * - * @param other - * A flow of elements to be combined with. - * @param thisDefault - * A default element to be used in the result tuple when the other flow is longer. - * @param otherDefault - * A default element to be used in the result tuple when the current flow is longer. - */ + * concurrently. + * + * @param other + * A flow of elements to be combined with. + * @param thisDefault + * A default element to be used in the result tuple when the other flow is longer. + * @param otherDefault + * A default element to be used in the result tuple when the current flow is longer. + */ def zipAll[U >: T, V](other: Flow[V], thisDefault: U, otherDefault: V): Flow[(U, V)] = Flow.usingEmitInline: emit => unsupervised: val s1 = outer.runToChannel() @@ -725,9 +728,9 @@ class FlowOps[+T]: def receiveFromOther(thisElement: U, otherDoneHandler: () => Boolean): Boolean = s2.receiveOrClosed() match - case ChannelClosed.Done => otherDoneHandler() + case ChannelClosed.Done => otherDoneHandler() case e: ChannelClosed.Error => throw e.toThrowable - case v: V @unchecked => emit((thisElement, v)); true + case v: V @unchecked => emit((thisElement, v)); true repeatWhile: s1.receiveOrClosed() match @@ -737,15 +740,16 @@ class FlowOps[+T]: () => false ) case e: ChannelClosed.Error => throw e.toThrowable - case t: T @unchecked => + case t: T @unchecked => receiveFromOther( t, () => - emit((t, otherDefault)); true + emit((t, otherDefault)); + true ) /** Combines each element from this and the index of the element (starting at 0). - */ + */ def zipWithIndex: Flow[(T, Long)] = Flow.usingEmitInline: emit => var index = 0L last.run( @@ -756,41 +760,42 @@ class FlowOps[+T]: ) /** Emits a given number of elements (determined byc `segmentSize`) from this flow to the returned flow, then emits the same number of - * elements from the `other` flow and repeats. The order of elements in both flows is preserved. - * - * If one of the flows is done before the other, the behavior depends on the `eagerCancel` flag. When set to `true`, the returned flow is - * completed immediately, otherwise the remaining elements from the other flow are emitted by the returned flow. - * - * Both flows are run concurrently and asynchronously. - * - * @param other - * The flow whose elements will be interleaved with the elements of this flow. - * @param segmentSize - * The number of elements sent from each flow before switching to the other one. Default is 1. - * @param eagerComplete - * If `true`, the returned flow is completed as soon as either of the flow completes. If `false`, the remaining elements of the - * non-completed flow are sent downstream. - */ + * elements from the `other` flow and repeats. The order of elements in both flows is preserved. + * + * If one of the flows is done before the other, the behavior depends on the `eagerCancel` flag. When set to `true`, the returned flow is + * completed immediately, otherwise the remaining elements from the other flow are emitted by the returned flow. + * + * Both flows are run concurrently and asynchronously. + * + * @param other + * The flow whose elements will be interleaved with the elements of this flow. + * @param segmentSize + * The number of elements sent from each flow before switching to the other one. Default is 1. + * @param eagerComplete + * If `true`, the returned flow is completed as soon as either of the flow completes. If `false`, the remaining elements of the + * non-completed flow are sent downstream. + */ def interleave[U >: T](other: Flow[U], segmentSize: Int = 1, eagerComplete: Boolean = false)(using BufferCapacity): Flow[U] = Flow.interleaveAll(List(this, other), segmentSize, eagerComplete) /** Applies the given mapping function `f`, using additional state, to each element emitted by this flow. The results are emitted by the - * returned flow. Optionally the returned flow emits an additional element, possibly based on the final state, once this flow is done. - * - * The `initializeState` function is evaluated at the start of each run of the returned flow. - * - * The `onComplete` function is called once when this flow is done. If it returns a non-empty value, the value will be emitted by the - * flow, while an empty value will be ignored. - * - * @param initializeState - * A function that initializes the state. - * @param f - * A function that transforms the element from this flow and the state into a pair of the next state and the result which is emitted by - * the returned flow. - * @param onComplete - * A function that transforms the final state into an optional element emitted by the returned flow. By default the final state is - * ignored. - */ + * returned flow. Optionally the returned flow emits an additional element, possibly based on the final state, once this flow is done. + * + * The `initializeState` function is evaluated at the start of each run of the returned flow. + * + * The `onComplete` function is called once when this flow is done. If it returns a non-empty value, the value will be emitted by the + * flow, while an empty value will be ignored. + * + * @param initializeState + * A function that initializes the state. + * @param f + * A function that transforms the element from this flow and the state into a pair of the next state and the result which is emitted by + * the returned flow. + * + * @param onComplete + * A function that transforms the final state into an optional element emitted by the returned flow. By default the final state is + * ignored. + */ def mapStateful[S, U](initializeState: => S)(f: (S, T) => (S, U), onComplete: S => Option[U] = (_: S) => None): Flow[U] = def resultToSome(s: S, t: T) = val (newState, result) = f(s, t) @@ -800,27 +805,28 @@ class FlowOps[+T]: end mapStateful /** Applies the given mapping function `f`, using additional state, to each element emitted by this flow. The returned flow emits the - * results one by one. Optionally the returned flow emits an additional element, possibly based on the final state, once this flow is - * done. - * - * The `initializeState` function is evaluated at the start of each run of the returned flow. - * - * The `onComplete` function is called once when this flow is done. If it returns a non-empty value, the value will be emitted by the - * returned flow, while an empty value will be ignored. - * - * @param initializeState - * A function that initializes the state. - * @param f - * A function that transforms the element from this flow and the state into a pair of the next state and a - * [[scala.collection.IterableOnce]] of results which are emitted one by one by the returned flow. If the result of `f` is empty, - * nothing is emitted by the returned flow. - * @param onComplete - * A function that transforms the final state into an optional element emitted by the returned flow. By default the final state is - * ignored. - */ + * results one by one. Optionally the returned flow emits an additional element, possibly based on the final state, once this flow is + * done. + * + * The `initializeState` function is evaluated at the start of each run of the returned flow. + * + * The `onComplete` function is called once when this flow is done. If it returns a non-empty value, the value will be emitted by the + * returned flow, while an empty value will be ignored. + * + * @param initializeState + * A function that initializes the state. + * @param f + * A function that transforms the element from this flow and the state into a pair of the next state and a + * [[scala.collection.IterableOnce]] of results which are emitted one by one by the returned flow. If the result of `f` is empty, + * nothing is emitted by the returned flow. + * + * @param onComplete + * A function that transforms the final state into an optional element emitted by the returned flow. By default the final state is + * ignored. + */ def mapStatefulConcat[S, U]( - initializeState: => S - )(f: (S, T) => (S, IterableOnce[U]), onComplete: S => Option[U] = (_: S) => None): Flow[U] = Flow.usingEmitInline: emit => + initializeState: => S + )(f: (S, T) => (S, IterableOnce[U]), onComplete: S => Option[U] = (_: S) => None): Flow[U] = Flow.usingEmitInline: emit => var state = initializeState last.run( FlowEmit.fromInline: t => @@ -832,22 +838,62 @@ class FlowOps[+T]: end mapStatefulConcat /** Applies the given mapping function `f` to each element emitted by this flow, using a resource that is acquired when the flow starts - * and released when it completes (either successfully or with an error). - * - * The `create` function is called once when the flow starts to acquire the resource. The `close` function is called once when the flow - * completes. If the flow completes successfully and `close` returns a non-empty value, that value is emitted as a final element. If the - * flow fails, the return value of `close` is discarded. If `close` itself throws while the flow has already failed, the exception is - * added as suppressed to the original error. If `close` throws and there was no prior error, the exception is propagated. - * - * @param create - * A function that acquires the resource. - * @param close - * A function that releases the resource, optionally returning a final element. - * @param f - * A function that transforms each element using the resource. - * @return - * A flow that emits the transformed elements and optionally a final element from `close`. - */ + * and released when it completes (either successfully or with an error). + * + * The `create` function is called once when the flow starts to acquire the resource. The `close` function is called once when the flow + * completes. If the flow completes successfully and `close` returns a non-empty value, that value is emitted as a final element. If the + * flow fails, the return value of `close` is discarded. If `close` itself throws while the flow has already failed, the exception is + * added as suppressed to the original error. If `close` throws and there was no prior error, the exception is propagated. + * + * @param create + * A function that acquires the resource. + * @param close + * A function that releases the resource, optionally returning a final element. + * @param f + * A function that transforms each element using the resource. + * @return + * A flow that emits the transformed elements and optionally a final element from `close`. + */ + def mapWithResourceErr[R, U](create: => R, close: (R,Option[Throwable]) => Option[U])(f: (R, T) => U): Flow[U] = + Flow.usingEmitInline: emit => + val resource = create + var error: Throwable | Null = null + try + last.run( + FlowEmit.fromInline: t => + emit(f(resource, t)) + ) + catch + case e: Throwable => + error = e + throw e + finally + try + val finalElement = close(resource,Option(error)) + if error eq null then finalElement.foreach(emit.apply) + catch + case e: Throwable => + if error ne null then error.nn.addSuppressed(e) + else throw e + end try + end mapWithResourceErr + /** Applies the given mapping function `f` to each element emitted by this flow, using a resource that is acquired when the flow starts + * and released when it completes (either successfully or with an error). + * + * The `create` function is called once when the flow starts to acquire the resource. The `close` function is called once when the flow + * completes. If the flow completes successfully and `close` returns a non-empty value, that value is emitted as a final element. If the + * flow fails, the return value of `close` is discarded. If `close` itself throws while the flow has already failed, the exception is + * added as suppressed to the original error. If `close` throws and there was no prior error, the exception is propagated. + * + * @param create + * A function that acquires the resource. + * @param close + * A function that releases the resource, optionally returning a final element. + * @param f + * A function that transforms each element using the resource. + * @return + * A flow that emits the transformed elements and optionally a final element from `close`. + */ def mapWithResource[R, U](create: => R, close: R => Option[U])(f: (R, T) => U): Flow[U] = Flow.usingEmitInline: emit => val resource = create @@ -871,31 +917,31 @@ class FlowOps[+T]: else throw e end try end mapWithResource - /** Applies the given mapping function `f` to each element emitted by this flow, using an [[AutoCloseable]] resource that is acquired when - * the flow starts and closed when it completes. - * - * This is a convenience method that delegates to [[mapWithResource]] with `close` calling [[AutoCloseable.close]] and returning `None`. - * - * @param create - * A function that acquires the resource. - * @param f - * A function that transforms each element using the resource. - */ + * the flow starts and closed when it completes. + * + * This is a convenience method that delegates to [[mapWithResource]] with `close` calling [[AutoCloseable.close]] and returning `None`. + * + * @param create + * A function that acquires the resource. + * @param f + * A function that transforms each element using the resource. + */ def mapWithCloseableResource[R <: AutoCloseable, U](create: => R)(f: (R, T) => U): Flow[U] = mapWithResource( create, r => - r.close(); None + r.close(); + None )(f) /** Applies the given mapping function `f`, to each element emitted by this source, transforming it into an [[IterableOnce]] of results, - * then the returned flow emits the results one by one. Can be used to unfold incoming sequences of elements into single elements. - * - * @param f - * A function that transforms the element from this flow into an [[IterableOnce]] of results which are emitted one by one by the - * returned flow. If the result of `f` is empty, nothing is emitted by the returned channel. - */ + * then the returned flow emits the results one by one. Can be used to unfold incoming sequences of elements into single elements. + * + * @param f + * A function that transforms the element from this flow into an [[IterableOnce]] of results which are emitted one by one by the + * returned flow. If the result of `f` is empty, nothing is emitted by the returned channel. + */ def mapConcat[U](f: T => IterableOnce[U]): Flow[U] = Flow.usingEmitInline: emit => last.run( FlowEmit.fromInline: t => @@ -903,17 +949,17 @@ class FlowOps[+T]: ) /** Emits elements limiting the throughput to specific number of elements (evenly spaced) per time unit. Note that the element's - * emission-time time is included in the resulting throughput. For instance having `throttle(1, 1.second)` and emission of the next - * element taking `Xms` means that resulting flow will emit elements every `1s + Xms` time. Throttling is not applied to the empty - * source. - * - * @param elements - * Number of elements to be emitted. Must be greater than 0. - * @param per - * Per time unit. Must be greater or equal to 1 ms. - * @return - * A flow that emits at most `elements` `per` time unit. - */ + * emission-time time is included in the resulting throughput. For instance having `throttle(1, 1.second)` and emission of the next + * element taking `Xms` means that resulting flow will emit elements every `1s + Xms` time. Throttling is not applied to the empty + * source. + * + * @param elements + * Number of elements to be emitted. Must be greater than 0. + * @param per + * Per time unit. Must be greater or equal to 1 ms. + * @return + * A flow that emits at most `elements` `per` time unit. + */ def throttle(elements: Int, per: FiniteDuration): Flow[T] = require(elements > 0, "elements must be > 0") require(per.toMillis > 0, "per time must be >= 1 ms") @@ -922,11 +968,11 @@ class FlowOps[+T]: end throttle /** If this flow has no elements then elements from an `alternative` flow are emitted by the returned flow. If this flow is failed then - * the returned flow is failed as well. - * - * @param alternative - * An alternative flow to be used when this flow is empty. - */ + * the returned flow is failed as well. + * + * @param alternative + * An alternative flow to be used when this flow is empty. + */ def orElse[U >: T](alternative: Flow[U]): Flow[U] = Flow.usingEmitInline: emit => var receivedAtLeastOneElement = false last.run( @@ -938,20 +984,20 @@ class FlowOps[+T]: end orElse /** Chunks up the elements into groups of the specified size. The last group may be smaller due to the flow being complete. - * - * @param n - * The number of elements in a group. - */ + * + * @param n + * The number of elements in a group. + */ def grouped(n: Int): Flow[Seq[T]] = groupedWeighted(n)(_ => 1) /** Chunks up the elements into groups that have a cumulative weight greater or equal to the `minWeight`. The last group may be smaller - * due to the flow being complete. - * - * @param minWeight - * The minimum cumulative weight of elements in a group. - * @param costFn - * The function that calculates the weight of an element. - */ + * due to the flow being complete. + * + * @param minWeight + * The minimum cumulative weight of elements in a group. + * @param costFn + * The function that calculates the weight of an element. + */ def groupedWeighted(minWeight: Long)(costFn: T => Long): Flow[Seq[T]] = require(minWeight > 0, "minWeight must be > 0") @@ -973,27 +1019,27 @@ class FlowOps[+T]: end groupedWeighted /** Chunks up the emitted elements into groups, within a time window, or limited by the specified number of elements, whatever happens - * first. The timeout is reset after a group is emitted. If timeout expires and the buffer is empty, nothing is emitted. As soon as a new - * element is emitted, the flow will emit it as a single-element group and reset the timer. - * - * @param n - * The maximum number of elements in a group. - * @param duration - * The time window in which the elements are grouped. - */ + * first. The timeout is reset after a group is emitted. If timeout expires and the buffer is empty, nothing is emitted. As soon as a new + * element is emitted, the flow will emit it as a single-element group and reset the timer. + * + * @param n + * The maximum number of elements in a group. + * @param duration + * The time window in which the elements are grouped. + */ def groupedWithin(n: Int, duration: FiniteDuration)(using BufferCapacity): Flow[Seq[T]] = groupedWeightedWithin(n, duration)(_ => 1) /** Chunks up the emitted elements into groups, within a time window, or limited by the cumulative weight being greater or equal to the - * `minWeight`, whatever happens first. The timeout is reset after a group is emitted. If timeout expires and the buffer is empty, - * nothing is emitted. As soon as a new element is received, the flow will emit it as a single-element group and reset the timer. - * - * @param minWeight - * The minimum cumulative weight of elements in a group if no timeout happens. - * @param duration - * The time window in which the elements are grouped. - * @param costFn - * The function that calculates the weight of an element. - */ + * `minWeight`, whatever happens first. The timeout is reset after a group is emitted. If timeout expires and the buffer is empty, + * nothing is emitted. As soon as a new element is received, the flow will emit it as a single-element group and reset the timer. + * + * @param minWeight + * The minimum cumulative weight of elements in a group if no timeout happens. + * @param duration + * The time window in which the elements are grouped. + * @param costFn + * The function that calculates the weight of an element. + */ def groupedWeightedWithin(minWeight: Long, duration: FiniteDuration)(costFn: T => Long)(using BufferCapacity): Flow[Seq[T]] = case class GroupingTimeout(generation: Long) @@ -1059,13 +1105,13 @@ class FlowOps[+T]: end groupedWeightedWithin /** Creates sliding windows of elements from this flow. The window slides by `step` elements. The last window may be smaller due to flow - * being completed. - * - * @param n - * The number of elements in a window. - * @param step - * The number of elements the window slides by. - */ + * being completed. + * + * @param n + * The number of elements in a window. + * @param step + * The number of elements the window slides by. + */ def sliding(n: Int, step: Int = 1): Flow[Seq[T]] = require(n > 0, "n must be > 0") require(step > 0, "step must be > 0") @@ -1092,14 +1138,14 @@ class FlowOps[+T]: end sliding /** Breaks the input into chunks where the delimiter matches the predicate. The delimiter does not appear in the output. Two adjacent - * delimiters in the input result in an empty chunk in the output. - * - * @param delimiter - * A predicate function that identifies delimiter elements. - * @example - * {{{ scala> Flow.fromIterable(0 to 9).split(_ % 4 == 0).runToList() res0: List[Seq[Int]] = List(Seq(), Seq(1, 2, 3), Seq(5, 6, 7), - * Seq(9)) }}} - */ + * delimiters in the input result in an empty chunk in the output. + * + * @param delimiter + * A predicate function that identifies delimiter elements. + * @example + * {{{ scala> Flow.fromIterable(0 to 9).split(_ % 4 == 0).runToList() res0: List[Seq[Int]] = List(Seq(), Seq(1, 2, 3), Seq(5, 6, 7), + * Seq(9)) }}} + */ def split(delimiter: T => Boolean): Flow[Seq[T]] = Flow.usingEmitInline: emit => var buffer = Vector.empty[T] last.run( @@ -1117,13 +1163,13 @@ class FlowOps[+T]: end split /** Breaks the input into chunks delimited by the given sequence of elements. The delimiter sequence does not appear in the output. Two - * adjacent delimiter sequences in the input result in an empty chunk in the output. - * - * @param delimiter - * A sequence of elements that serves as a delimiter. If empty, the entire input is returned as a single chunk. - * @example - * {{{scala> Flow.fromValues(1, 2, 0, 0, 3, 4, 0, 0, 5).splitOn(List(0, 0)).runToList() res0: List[Seq[Int]] = List(Seq(1, 2), Seq(3, 4), Seq(5))}}} - */ + * adjacent delimiter sequences in the input result in an empty chunk in the output. + * + * @param delimiter + * A sequence of elements that serves as a delimiter. If empty, the entire input is returned as a single chunk. + * @example + * {{{scala> Flow.fromValues(1, 2, 0, 0, 3, 4, 0, 0, 5).splitOn(List(0, 0)).runToList() res0: List[Seq[Int]] = List(Seq(1, 2), Seq(3, 4), Seq(5))}}} + */ def splitOn[U >: T](delimiter: List[U]): Flow[Seq[T]] = Flow.usingEmitInline: emit => if delimiter.isEmpty then // Empty delimiter means no splitting - emit entire input as single chunk @@ -1170,17 +1216,17 @@ class FlowOps[+T]: end splitOn /** Attaches the given [[ox.channels.Sink]] to this flow, meaning elements that pass through will also be sent to the sink. If emitting an - * element, or sending to the `other` sink blocks, no elements will be processed until both are done. The elements are first emitted by - * the flow and then, only if that was successful, to the `other` sink. - * - * If this flow fails, then failure is passed to the `other` sink as well. If the `other` sink is failed or complete, this becomes a - * failure of the returned flow (contrary to [[alsoToTap]] where it's ignored). - * - * @param other - * The sink to which elements from this flow will be sent. - * @see - * [[alsoToTap]] for a version that drops elements when the `other` sink is not available for receive. - */ + * element, or sending to the `other` sink blocks, no elements will be processed until both are done. The elements are first emitted by + * the flow and then, only if that was successful, to the `other` sink. + * + * If this flow fails, then failure is passed to the `other` sink as well. If the `other` sink is failed or complete, this becomes a + * failure of the returned flow (contrary to [[alsoToTap]] where it's ignored). + * + * @param other + * The sink to which elements from this flow will be sent. + * @see + * [[alsoToTap]] for a version that drops elements when the `other` sink is not available for receive. + */ def alsoTo[U >: T](other: Sink[U]): Flow[U] = Flow.usingEmitInline: emit => { last.run( @@ -1195,60 +1241,60 @@ class FlowOps[+T]: private case object NotSent /** Attaches the given [[ox.channels.Sink]] to this flow, meaning elements that pass through will also be sent to the sink. If the `other` - * sink is not available for receive, the elements are still emitted by the returned flow, but not sent to the `other` sink. - * - * If this flow fails, then failure is passed to the `other` sink as well. If the `other` sink fails or closes, then failure or closure - * is ignored and it doesn't affect the resulting flow (contrary to [[alsoTo]] where it's propagated). - * - * @param other - * The sink to which elements from this source will be sent. - * @see - * [[alsoTo]] for a version that ensures that elements are emitted both by the returned flow and sent to the `other` sink. - */ + * sink is not available for receive, the elements are still emitted by the returned flow, but not sent to the `other` sink. + * + * If this flow fails, then failure is passed to the `other` sink as well. If the `other` sink fails or closes, then failure or closure + * is ignored and it doesn't affect the resulting flow (contrary to [[alsoTo]] where it's propagated). + * + * @param other + * The sink to which elements from this source will be sent. + * @see + * [[alsoTo]] for a version that ensures that elements are emitted both by the returned flow and sent to the `other` sink. + */ def alsoToTap[U >: T](other: Sink[U]): Flow[U] = Flow.usingEmitInline: emit => { - last.run( - FlowEmit.fromInline: t => - emit(t).tapException(e => other.errorOrClosed(e).discard) - selectOrClosed(other.sendClause(t), Default(NotSent)).discard - ) - other.doneOrClosed().discard - }.tapException(other.errorOrClosed(_).discard) + last.run( + FlowEmit.fromInline: t => + emit(t).tapException(e => other.errorOrClosed(e).discard) + selectOrClosed(other.sendClause(t), Default(NotSent)).discard + ) + other.doneOrClosed().discard + }.tapException(other.errorOrClosed(_).discard) end alsoToTap /** Groups elements emitted by this flow into child flows. Elements for which [[predicate]] returns the same value (of type `V`) end up in - * the same child flow. [[childFlowTransform]] is applied to each created child flow, and the resulting flow is run in the background. - * Finally, the child flows are merged back, that is any elements that they emit are emitted by the returned flow. - * - * Up to [[parallelism]] child flows are run concurrently in the background. When the limit is reached, the child flow which didn't - * receive a new element the longest is completed as done. - * - * Child flows for `V` values might be created multiple times (if, after completing a child flow because of parallelism limit, new - * elements arrive, mapped to a given `V` value). However, it is guaranteed that for a given `V` value, there will be at most one child - * flow running at any time. - * - * Child flows should only complete as done when the flow of received `T` elements completes. Otherwise, the entire stream will fail with - * an error. - * - * Errors that occur in this flow, or in any child flows, become errors of the returned flow (exceptions are wrapped in - * [[ChannelClosedException]]). - * - * The size of the buffers for the elements emitted by this flow (which is also run in the background) and the child flows are determined - * by the [[BufferCapacity]] that is in scope. - * - * @param parallelism - * An upper bound on the number of child flows that run in parallel at any time. - * @param predicate - * Function used to determine the group for an element of type `T`. Each group is represented by a value of type `V`. - * @param childFlowTransform - * The function that is used to create a child flow, which is later in the background. The arguments are the group value, for which the - * flow is created, and a flow of `T` elements in that group (each such element has the same group value `V` returned by `predicated`). - */ + * the same child flow. [[childFlowTransform]] is applied to each created child flow, and the resulting flow is run in the background. + * Finally, the child flows are merged back, that is any elements that they emit are emitted by the returned flow. + * + * Up to [[parallelism]] child flows are run concurrently in the background. When the limit is reached, the child flow which didn't + * receive a new element the longest is completed as done. + * + * Child flows for `V` values might be created multiple times (if, after completing a child flow because of parallelism limit, new + * elements arrive, mapped to a given `V` value). However, it is guaranteed that for a given `V` value, there will be at most one child + * flow running at any time. + * + * Child flows should only complete as done when the flow of received `T` elements completes. Otherwise, the entire stream will fail with + * an error. + * + * Errors that occur in this flow, or in any child flows, become errors of the returned flow (exceptions are wrapped in + * [[ChannelClosedException]]). + * + * The size of the buffers for the elements emitted by this flow (which is also run in the background) and the child flows are determined + * by the [[BufferCapacity]] that is in scope. + * + * @param parallelism + * An upper bound on the number of child flows that run in parallel at any time. + * @param predicate + * Function used to determine the group for an element of type `T`. Each group is represented by a value of type `V`. + * @param childFlowTransform + * The function that is used to create a child flow, which is later in the background. The arguments are the group value, for which the + * flow is created, and a flow of `T` elements in that group (each such element has the same group value `V` returned by `predicated`). + */ def groupBy[V, U](parallelism: Int, predicate: T => V)(childFlowTransform: V => Flow[T] => Flow[U])(using BufferCapacity): Flow[U] = groupByImpl(outer, parallelism, predicate)(childFlowTransform) /** Discard all elements emitted by this flow. The returned flow completes only when this flow completes (successfully or with an error). - */ + */ def drain(): Flow[Nothing] = Flow.usingEmitInline: emit => last.run(FlowEmit.fromInline(_ => ())) @@ -1267,25 +1313,25 @@ class FlowOps[+T]: last.run(emit).tapException(f) /** Retries the upstream flow execution using the provided retry configuration. If the flow fails with an exception, it will be retried - * according to the schedule defined in the retry config until it succeeds or the retry policy decides to stop. - * - * Each retry attempt will run the complete upstream flow, from start up to this point. The retry behavior is controlled by the - * [[RetryConfig]]. - * - * Note that this retries the flow execution itself, not individual elements within the flow. If you need to retry individual operations - * within the flow, consider using retry logic inside methods such as [[map]]. - * - * Creates an asynchronous boundary (see [[buffer]]) to isolate failures when running the upstream flow. - * - * @param config - * The retry configuration that specifies the retry schedule and success/failure conditions. - * @return - * A new flow that will retry execution according to the provided configuration. - * @throws anything - * The exception from the last retry attempt if all retries are exhausted. - * @see - * [[ox.resilience.retry]] - */ + * according to the schedule defined in the retry config until it succeeds or the retry policy decides to stop. + * + * Each retry attempt will run the complete upstream flow, from start up to this point. The retry behavior is controlled by the + * [[RetryConfig]]. + * + * Note that this retries the flow execution itself, not individual elements within the flow. If you need to retry individual operations + * within the flow, consider using retry logic inside methods such as [[map]]. + * + * Creates an asynchronous boundary (see [[buffer]]) to isolate failures when running the upstream flow. + * + * @param config + * The retry configuration that specifies the retry schedule and success/failure conditions. + * @return + * A new flow that will retry execution according to the provided configuration. + * @throws anything + * The exception from the last retry attempt if all retries are exhausted. + * @see + * [[ox.resilience.retry]] + */ def retry(config: RetryConfig[Throwable, Unit])(using BufferCapacity): Flow[T] = Flow.usingEmitInline: emit => val ch = BufferCapacity.newChannel[T] unsupervised: @@ -1296,20 +1342,20 @@ class FlowOps[+T]: FlowEmit.channelToEmit(ch, emit) /** @see - * [[retry(RetryConfig)]] - */ + * [[retry(RetryConfig)]] + */ def retry(schedule: Schedule): Flow[T] = retry(RetryConfig(schedule)) /** Recovers from errors in the upstream flow by emitting a recovery value when the error is handled by the partial function. If the - * partial function is not defined for the error, the original error is propagated. - * - * Creates an asynchronous boundary (see [[buffer]]) to isolate failures when running the upstream flow. - * - * @param pf - * A partial function that handles specific exceptions and returns a recovery value to emit. - * @return - * A flow that emits elements from the upstream flow, and emits a recovery value if the upstream fails with a handled exception. - */ + * partial function is not defined for the error, the original error is propagated. + * + * Creates an asynchronous boundary (see [[buffer]]) to isolate failures when running the upstream flow. + * + * @param pf + * A partial function that handles specific exceptions and returns a recovery value to emit. + * @return + * A flow that emits elements from the upstream flow, and emits a recovery value if the upstream fails with a handled exception. + */ def recover[U >: T](pf: PartialFunction[Throwable, U])(using BufferCapacity): Flow[U] = Flow.usingEmitInline: emit => val ch = BufferCapacity.newChannel[U] unsupervised: @@ -1321,41 +1367,41 @@ class FlowOps[+T]: FlowEmit.channelToEmit(ch, emit) /** Recovers from errors in the upstream flow by switching to an alternative flow produced by the partial function. Elements already - * emitted by the upstream flow before the error are preserved. If the partial function is not defined for the error, the original error - * is propagated. - * - * Creates an asynchronous boundary (see [[buffer]]) to isolate failures when running the upstream flow. - * - * @param pf - * A partial function that handles specific exceptions and returns an alternative flow to switch to. - * @return - * A flow that emits elements from the upstream flow, and switches to the recovery flow if the upstream fails with a handled exception. - */ + * emitted by the upstream flow before the error are preserved. If the partial function is not defined for the error, the original error + * is propagated. + * + * Creates an asynchronous boundary (see [[buffer]]) to isolate failures when running the upstream flow. + * + * @param pf + * A partial function that handles specific exceptions and returns an alternative flow to switch to. + * @return + * A flow that emits elements from the upstream flow, and switches to the recovery flow if the upstream fails with a handled exception. + */ def recoverWith[U >: T](pf: PartialFunction[Throwable, Flow[U]])(using BufferCapacity): Flow[U] = Flow.usingEmitInline: emit => val ch = BufferCapacity.newChannel[U] unsupervised: forkPropagate(ch) { try last.run(FlowEmit.fromInline(t => ch.send(t))) - catch case e: Throwable if pf.isDefinedAt(e) => pf(e).runToEmit(FlowEmit.fromInline(t => ch.send(t))) + catch case e: Throwable if pf.isDefinedAt(e) => pf(e).runToEmit(FlowEmit.fromInline(t => ch.send(t))) // todo - shouldnt we use applyOrElse? ch.done() }.discard FlowEmit.channelToEmit(ch, emit) /** Recovers from errors in the upstream flow by switching to an alternative flow, with retry support. On each failure matching the - * partial function, the recovery flow is materialized and run. If the recovery flow also fails and retries remain (as specified by the - * retry config), the recovery is attempted again. After exhausting all retries, the error is propagated. - * - * Creates an asynchronous boundary (see [[buffer]]) to isolate failures when running the upstream flow. - * - * @param config - * The retry configuration that specifies the schedule and policy for recovery attempts. - * @param pf - * A partial function that handles specific exceptions and returns an alternative flow to switch to. - * @return - * A flow that emits elements from the upstream flow, and switches to recovery flows on failure, retrying according to the config. - */ + * partial function, the recovery flow is materialized and run. If the recovery flow also fails and retries remain (as specified by the + * retry config), the recovery is attempted again. After exhausting all retries, the error is propagated. + * + * Creates an asynchronous boundary (see [[buffer]]) to isolate failures when running the upstream flow. + * + * @param config + * The retry configuration that specifies the schedule and policy for recovery attempts. + * @param pf + * A partial function that handles specific exceptions and returns an alternative flow to switch to. + * @return + * A flow that emits elements from the upstream flow, and switches to recovery flows on failure, retrying according to the config. + */ def recoverWithRetry[U >: T](config: RetryConfig[Throwable, Unit])(pf: PartialFunction[Throwable, Flow[U]])(using - BufferCapacity + BufferCapacity ): Flow[U] = Flow.usingEmitInline: emit => val ch = BufferCapacity.newChannel[U] @@ -1374,15 +1420,16 @@ class FlowOps[+T]: recoverWithRetry(RetryConfig(schedule))(pf) /** Completes the flow normally (without error) when the upstream fails with an exception matching the partial function. Elements already - * emitted before the error are preserved. If the partial function is not defined for the error, the original error is propagated. - * Exceptions thrown by downstream operators are not suppressed. - * - * @param pf - * A partial function that determines which exceptions should cause the flow to complete. The function should return `true` if the - * exception should be suppressed. - * @return - * A flow that completes normally when an error matching `pf` occurs. - */ + * emitted before the error are preserved. If the partial function is not defined for the error, the original error is propagated. + * Exceptions thrown by downstream operators are not suppressed. + * + * @param pf + * A partial function that determines which exceptions should cause the flow to complete. The function should return `true` if the + * exception should be suppressed. + * + * @return + * A flow that completes normally when an error matching `pf` occurs. + */ def onErrorComplete(pf: PartialFunction[Throwable, Boolean]): Flow[T] = Flow.usingEmitInline: emit => var downstreamFailure: Throwable | Null = null val guardedEmit = FlowEmit.fromInline[T]: t => @@ -1395,12 +1442,12 @@ class FlowOps[+T]: catch case e: Throwable if (downstreamFailure ne e) && pf.applyOrElse(e, (_: Throwable) => false) => () /** Completes the flow normally (without error) when the upstream fails with any exception. Elements already emitted before the error are - * preserved. Does not catch non-exception throwables such as fatal errors or control throwables. Exceptions thrown by downstream - * operators are not suppressed. - * - * @return - * A flow that completes normally when any exception occurs. - */ + * preserved. Does not catch non-exception throwables such as fatal errors or control throwables. Exceptions thrown by downstream + * operators are not suppressed. + * + * @return + * A flow that completes normally when any exception occurs. + */ def onErrorComplete: Flow[T] = Flow.usingEmitInline: emit => var downstreamFailure: Throwable | Null = null val guardedEmit = FlowEmit.fromInline[T]: t => @@ -1413,15 +1460,15 @@ class FlowOps[+T]: catch case e: Exception if downstreamFailure ne e => () /** Recovers from any error in the upstream flow by emitting a value produced by `f`. Unlike [[recover]], this takes a total function and - * handles all throwables (including control throwables and fatal errors). - * - * Creates an asynchronous boundary (see [[buffer]]) to isolate failures when running the upstream flow. - * - * @param f - * A function that maps an exception to a recovery value to emit. - * @return - * A flow that emits elements from the upstream flow, and emits a recovery value if the upstream fails. - */ + * handles all throwables (including control throwables and fatal errors). + * + * Creates an asynchronous boundary (see [[buffer]]) to isolate failures when running the upstream flow. + * + * @param f + * A function that maps an exception to a recovery value to emit. + * @return + * A flow that emits elements from the upstream flow, and emits a recovery value if the upstream fails. + */ def onErrorRecover[U >: T](f: Throwable => U)(using BufferCapacity): Flow[U] = recover { case e => f(e) } // diff --git a/core/src/main/scala/ox/oxThreadFactory.scala b/core/src/main/scala/ox/oxThreadFactory.scala index 68f20fc0..b91e7434 100644 --- a/core/src/main/scala/ox/oxThreadFactory.scala +++ b/core/src/main/scala/ox/oxThreadFactory.scala @@ -2,6 +2,7 @@ package ox import java.util.concurrent.ThreadFactory +private def defaultThreadFactory = Thread.ofVirtual().factory() private var customThreadFactory: ThreadFactory = _ /** @see [[oxThreadFactory]] */ @@ -18,4 +19,5 @@ def setOxThreadFactory(tf: ThreadFactory): Unit = */ lazy val oxThreadFactory: ThreadFactory = val custom = customThreadFactory - if custom == null then Thread.ofVirtual().factory() else custom + if custom == null then defaultThreadFactory else custom + diff --git a/generated-doc/out/utils/oxapp.md b/generated-doc/out/utils/oxapp.md index 99b3a0ae..5acc5d99 100644 --- a/generated-doc/out/utils/oxapp.md +++ b/generated-doc/out/utils/oxapp.md @@ -48,8 +48,9 @@ has to return an `ox.ExitCode` value which translates to the exit code returned defined as: ```scala + enum ExitCode(val code: Int): - case Success extends ExitCode(0) + case Success extends ox.ExitCode(0) case Failure(exitCode: Int = 1) extends ExitCode(exitCode) ``` diff --git a/otel/src/main/scala/ox/flow/otel/CFlow.scala b/otel/src/main/scala/ox/flow/otel/CFlow.scala new file mode 100644 index 00000000..13a47135 --- /dev/null +++ b/otel/src/main/scala/ox/flow/otel/CFlow.scala @@ -0,0 +1,74 @@ +package ox.flow.otel + +import ox.flow.Flow + + +//opaque type CFlow[T <: (?, ?)] = Flow[T] +// +//case class Ctx[C, T](c: C, t: T) +// +//trait CtxMaker[C, T]: +// type C1 +// type T1 +// +// def make(c: C, t: T): Ctx[C1, T1] +// +//object CtxMaker extends LowPrio: +// type Aux[C, T, CC, TT] = CtxMaker[C, T] {type C1 = CC; type T1 = TT} +// +// given [C, T, C2]: CtxMaker.Aux[C, Ctx[C2, T], C2, T] = new CtxMaker[C, Ctx[C2, T]] { +// override type C1 = C2 +// override type T1 = T +// +// override def make(c: C1, t: Ctx[C2, T]): Ctx[C2, T] = Ctx(t.c, t.t) +// } +// +// +//trait LowPrio: +// given [C, T]: CtxMaker.Aux[C, T, C, T] = new CtxMaker[C, T] { +// override type C1 = C +// override type T1 = T +// +// override def make(c: C1, t: T): Ctx[C1, T] = Ctx(c, t) +// } +// +//trait CtxAccess[C](c: C): +// def get: C +// +// extension [T](t: T) +// def withContext[CC](cc: CC): Ctx[CC, T] = Ctx(cc, t) +// +// def use[T](f: C => T): T +// +//extension (c: CtxAccess.type) +// def use[T, C, U](f: C => T => U): CtxAccess[C] => T => U = ??? +// +// +//trait ContextPush[U] +// +////object ContextPush: +//// given [C, T]: ContextPush[(Ctx[C], T)] = ??? +// +//object CtxAccess: +// def apply[C](c: C) = new CtxAccess[C](c) {} +// +// +//object CFlow: +// extension [C, T](cflow: CFlow[(C, T)]) +// def value: Flow[(C, T)] = cflow +// inline def map[U, R](inline f: CtxAccess[C] ?=> T => U)(using cm: CtxMaker[C, U]): CFlow[(cm.C1, cm.T1)] = ??? +// +// +// def fromFlow[CT <: (?, ?)](flow: Flow[CT]): CFlow[CT] = flow +// +// given t[C, T](using C): (C, T) = ??? +// +//trait Foo: +// def pp: Unit = ??? +// +//object T: +// val x: CFlow[(Foo, Int)] = ??? +// val xx = x.map: ctx ?=> +// ctx.use: c => +// c.pp +// tt => tt + 1 diff --git a/otel/src/main/scala/ox/flow/otel/OtelFlow.scala b/otel/src/main/scala/ox/flow/otel/OtelFlow.scala new file mode 100644 index 00000000..6fb78f1f --- /dev/null +++ b/otel/src/main/scala/ox/flow/otel/OtelFlow.scala @@ -0,0 +1,448 @@ +package ox.flow.otel + +import io.opentelemetry.api.common.Attributes +import io.opentelemetry.api.trace.* +import io.opentelemetry.context.Context +import ox.flow.Flow +import ox.otel.context.PropagatingVirtualThreadFactory + +import scala.compiletime.erasedValue + + +//class TracedFlow[-Env, +T](env: ForkLocal[Env], last: FlowStage[T]) extends Flow[T](last): +// override def flatMap[Env1 <: Env, U](f: T => TracedFlow[Env1, U]): TracedFlow[Env1, U] = TracedFlow(env, super.flatMap[U](f).last) +// +// def tapEnv(f: Env => Unit): TracedFlow[Env, T] = TracedFlow(env, super.tap(_ => f(env.get())).last) +// +//object TracedFlow: +// def apply[Env, T](env: Env)(flow: Flow[T]) = TracedFlow(ForkLocal.apply(env), flow.last) +//trait StartedSpan[T]: +// def endSpan(f: Span => Unit): T + +//trait OxTelemetry: +// protected def contextHolder: ContextHolder + + + + +trait OxMetrics + + +//trait TracingOps: +// self: Tracing => + +// def traceWith(fun: Flow[T] => Flow[T]): Flow[T] = Flow.usingEmitInline: emit => +// fun(flow).runToEmit(emit) + + +//trait FlowTracingOps extends TracingOps + + +// def mapSpanned[U](f: T => U)(startSpan: Tracer => Span, endSpan: Span => Unit): Flow[U] = +// Flow.usingEmitInline: emit => +// flow.runToEmit(FlowEmit.fromInline { t => +// val span = startSpan(tracer) +// emit(f(t)) +// endSpan(span) +// }) + + +// +//sealed class TracingAccess(): +// def foo = ??? +// +// extension [T](f: T) +// def prependSpan: T = +// f +// +//// extension (o:Opened[T]) +//// def close(f: Span => Unit) : T +// +//extension [T](flow: Flow[T]) +// def usingCtx[C, U](c: C)(f: C ?=> Flow[T] => Flow[U]): Flow[U] = Flow.usingEmitInline: emit => +// f(using c)(flow).runToEmit(emit) +// +//val flow: Flow[Int] = ??? + +//class FlowTracing(tracing: Tracer): +// extension [T](flow: Flow[T]) : + +//val flowTracing: FlowTracing +//(???) +//given TracingAccess = ??? +//val tt = 1.prependSpan +//val x = foo + +// +//class traced(val tr: Tracer): +// def apply[T](usage: traced ?=> T): T = usage(using this) +// +// extension [T](flow: Flow[T]) +// def traceWith(fun: TracingAccess ?=> Flow[T] => Flow[T]): Flow[T] = Flow.usingEmitInline: emit => +// val ta = new TracingAccess() +// fun(using ta)(flow).runToEmit(emit) +// +// +//def t(using traced) = +// val f: Flow[Int] = ??? +// f.traceWith() + +// extension [T](extended: Flow[T]) +//extension[T](f:Flow[T]) +// def spanInterface(using traced)(usage: SpanInterface[Flow[T]] => Flow[T]):Flow[T] = +// usage(new SpanInterface[Flow[T]](f) { +// override def prependWith(f: Tracer => Span): Opened[Flow[Flow[T]]] = ??? +// }) +// extension (o: Opened[T]) +// override def close(f: Span => Unit): T = ??? +// } + +// def currentSpan: Span = Span.current() +// def spanBuilder(name:String) = tracer.spanBuilder(name) + + +// extension [T](flow: Flow[T]) +// def spanAccess(usage: SpanAccess[flow.type] ?=> Flow[T]): Flow[T] = usage(using new SpanAccess[flow.type] { +// extension (t: flow.type) +// override def prependWith(start: Tracer => Span):Flow[T] = Flow.usingEmit[T](emit => +// val span = start(tracer) +// flow.runToEmit(emit) +// ) +// } +// ) +// def prependSpan(start: Tracer => Span): SpanAccess[flow.type] ?=> StartedSpan[Flow[T]] = (s: SpanAccess[flow.type]) ?=> new StartedSpan: +// def endSpan(endSpan: Span => Unit): Flow[T] = Flow.usingEmit[T](emit => +// val span = start(tracer) +// flow.runToEmit(emit) +// endSpan(span) +// ) + +// extension [T](t: T) +// def startSpan(startSpan:Tracer => Span, endSpan:Span => Unit): T = ??? + + +//object Testing: +// val tracer2: Tracer = ??? +// val tracer: Tracer = ??? +// val tracedWithMyTracer: Tracing = Tracing(tracer) +// val f: Flow[Int] = ??? +// val f2 = tracedWithMyTracer: +// val fff = f.map(x => (x + 1)).traceWith: c1 ?=> +// flow => +// traced(tracer2): +// flow.traceWith: c ?=> +// f => +// f.prependSpan +// +// Flow.fromValues(3, 4, 3) ++ fff + + +// st.endSpan(_.end()) + + +// def attachContext[C](contextInit: => C): Flow[C ?=> T] = +// Flow.usingEmitInline[C ?=> T]: emit => +// val f: C ?=> T = +// flow.runToEmit(FlowEmit.fromInline { t => +// val f = emit.apply(using ctx) +// +// +// }) + + +// +//opaque type ContextFlow[+C, +T] = Flow[(C, T)] +// +//object ContextFlow: +// def fromFlow[C, T](flow: Flow[(C, T)]): ContextFlow[C, T] = flow +// +// extension [C, T](c: ContextFlow[C, T]) +// def toFlow: Flow[(C, T)] = c +// +// def map[U](f: T => U): ContextFlow[C, U] = fromFlow(toFlow.map((c, x) => (c, f(x)))) +// +// def filter(f: T => Boolean): ContextFlow[C, T] = fromFlow(toFlow.filter((c, x) => f(x))) +// +// def mapContext[C1](f: C => C1): ContextFlow[C1, T] = fromFlow(toFlow.map((c, x) => (f(c), x))) +// +// def tapContext(f: C => Unit): ContextFlow[C, T] = fromFlow(toFlow.tap(x => f(x._1))) +// +// /** Todo - this is questionable, because we are using Flow in mapping fun, yet scala for-comprehension is working*/ +// def flatMap[B](f: T => Flow[B]): ContextFlow[C, B] = fromFlow(toFlow.flatMap((c, t) => f(t).map(c -> _))) +// +// +//extension [T](flow: Flow[T]) +// def withContext[C](context: C): ContextFlow[C, T] = ContextFlow.fromFlow(flow.map(context -> _)) +// def extractContext[C](f: T => C): ContextFlow[C, T] = ContextFlow.fromFlow(flow.map(x => f(x) -> x)) +// +// +//object Usage: +// val flow: Flow[Int] = Flow.fromValues(1, 2, 3) +// val tracer: Tracer = ??? +// val meter:Meter = ??? +// val flowWithContext: ContextFlow[Tracer, Int] = flow.withContext(tracer) +// val withStartedSpan: ContextFlow[(Tracer, Span), Int] = flowWithContext.mapContext(t => (t, t.spanBuilder("").startSpan())) +// val operatingOnElements: ContextFlow[(Tracer, Span), Int] = withStartedSpan.map(_ + 1) +// +// val endedSpan: ContextFlow[Tracer, Int] = operatingOnElements.mapContext { (t, s) => s.end(); t } +// +// val moreSpans: ContextFlow[(Tracer, List[Span]), Int] = withStartedSpan.mapContext { (t, s) => (t, t.spanBuilder("newSpan").startSpan() :: s :: Nil) } +// +// val flow2: Flow[String] = Flow.fromValues("one", "two") +// +// val questionable: ContextFlow[(Tracer, List[Span]), Int] = for { +// s <- moreSpans +// s2 <- flow +// s3 <- flow2 +// } yield s + s3.length +// +// val backToFlow: Flow[((Tracer, List[Span]), Int)] = moreSpans.toFlow +// +// val withMeter: ContextFlow[(Meter, Tracer, List[Span]), Int] = moreSpans.mapContext((t, s) => (meter, t, s)) +// +// val withCounter: ContextFlow[(Meter, Tracer, List[Span], LongCounter), Int] = withMeter.mapContext((m, t, s) => (m, t, s, m.counterBuilder("s").build())) +// +// val counted: ContextFlow[(Meter, Tracer, List[Span], LongCounter), Int] = withCounter.tapContext(_._4.add(1)) +// +// +// val sink = Channel.rendezvous[ObservableLongMeasurement] +// +// val subscribed: ObservableLongCounter = meter.counterBuilder("i will subscribe to this").buildWithCallback(sink.send) +// +// val flowConected: Flow[ObservableLongMeasurement] = Flow.usingEmit[ObservableLongMeasurement]: emit => +// FlowEmit.channelToEmit(sink, emit) +// +// +// +// + + +// +//type MeteredFlow[T] = ContextFlow[Meter, T] +////type TracedFlow[S<:TracingStack[S],T] = ContextFlow[TracingStack[S], T] +// +//opaque type TracedF[T] = Tracer => Flow[T] +//opaque type MeteredF[T] = Meter => Flow[T] +// +//class EnvFlow[-E, +T](val wrapped: E ?=> Flow[T]) +// +//object EnvFlow: +// def apply[E, T](f: E ?=> Flow[T]): EnvFlow[E, T] = new EnvFlow(f) +// +//val x: EnvFlow[Tracer ?=> Meter, Int] = ??? +// +////extension [G <: ContextFunction1[?, ?]](tupled: G) +//// def untuple[F](using tf: TupledFunction[F, G]): F = tf.untupled(tupled) +// +//extension [A, T](f: ContextFunction1[A, T]) +// def toFun: (A) => T = (a: A) => f(using a) +// +// +//extension [E, T](ef: EnvFlow[E, T]) +// def map[U](f: T => U): EnvFlow[E, U] = { +// EnvFlow(E ?=> ef.wrapped.map(f)) +// def requireEnv[Env]: EnvFlow[(Env, E), T] = +// type CT = ContextFunction1[ContextFunction1[E, Env], T] + +// val inner: ContextFunction1[Env, E ?=> Flow[T]] = (e1: Env) ?=> (e: E) ?=> ef.wrapped(using e) +// val innerrr = toFun(inner) +// val inner2: ContextFunction2[Env, E, Flow[T]] = inner +// val inner2Tupled: ContextFunction1[(Env, E), Flow[T]] = (t: (Env, E)) ?=> inner2(using t._1, t._2) +// val inner2UnTupled: ContextFunction1[Env, E ?=> Flow[T]] = (e: Env) ?=> (e1: E) ?=> inner2Tupled(using (e, e1)) +//// val inner2UnTupledGen = untuple(inner2Tupled) +// val inner2UnTupled2: ContextFunction2[Env, E, Flow[T]] = inner2UnTupled + +// val inner3: ContextFunction1[Env ?=> E, Flow[T]] = (e1: Env) ?=> (e2: E) ?=> inner2(using e1, e2) +// +// ??? +// } +// +// +//case class Enved[EnvedT](e: EnvedT) +// +//trait LeafMapper[From, A]: +// type To[_] +// +// def map[B](from: From)(f: A => B): To[B] +// +//object LeafMapper extends LowPrio: +// type Aux[From, A, ToT[_]] = LeafMapper[From, A] {type To[t] = ToT[t]} +// +// def map[From, A, B](from: From)(f: A => B)(using m: LeafMapper[From, A]): m.To[B] = m.map(from)(f) +// // extension [From,A](from:From) +// // def map[B](f:A => B)(using m:LeafMapper.Aux[From,A,B]):m.To[B] = m.map(from)(f) +// +// given forEnved[From <: ContextFunction1[?, ?], A, ToT[_]](using delegate: LeafMapper.Aux[From, A, ToT]): LeafMapper[Enved[From], A] with +// type To[b] = Enved[ToT[b]] +// +// override def map[B](from: Enved[From])(f: A => B): To[B] = Enved(delegate.map(from.e)(f)) +// +// +// given rec[Env, Inner, A, ToT[_]](using rec: LeafMapper.Aux[Inner, A, ToT]): LeafMapper[ContextFunction1[Env, Inner], A] with +// +// override type To[b] = Env ?=> ToT[b] +// +// override def map[B](from: Env ?=> Inner)(f: A => B): To[B] = (env: Env) ?=> rec.map(from)(f) +// +//trait LowPrio: +// given last[Env, A](using NotGiven[A <:< ContextFunction1[?, ?]]): LeafMapper[ContextFunction1[Env, A], A] with +// +// override type To[b] = Env ?=> b +// +// override def map[B](from: Env ?=> A)(f: A => B): To[B] = Env ?=> f(from) +// +//import ox.otel.LeafMapper.map +// +//val mapped: Int ?=> String ?=> Int = LeafMapper.map((i: Int) ?=> (s: String) ?=> List(2, 3, 4))(_.size) +//val e: Enved[Int ?=> String ?=> List[Int]] = ??? +//val res = LeafMapper.map(e)(_.size) +// +//// +//// override def map[B](f: Elem => B): Ret = +// +//trait EnvWrapper[Repr, T, LiftedRepr[+_]]: +// type LiftedReprT[+t] = LiftedRepr[t] +// +// def wrap(x: Repr): Enved[T, LiftedRepr] +// +//object EnvWrapper extends LowPrio: +// given rec[A, B, IT, IW[+_]](using inner: EnvWrapper[B, IT, IW]): EnvWrapper[ContextFunction1[A, B], IT, [t] =>> A ?=> IW[t]] with +// self => +// override def wrap(x: A ?=> B): Enved[IT, this.LiftedReprT] = new Enved[IT, self.LiftedReprT]: +// override def unwrap: self.LiftedReprT[IT] = A ?=> inner.wrap(x).unwrap +// +//trait LowPrio: +// inline given last[A, B](using NotGiven[B <:< ContextFunction1[?, ?]]): EnvWrapper[ContextFunction1[A, B],B,[t] =>> A ?=> t] with +// self => +// override def wrap(x: A ?=> B): Enved[B, self.LiftedReprT] = new Enved[B, self.LiftedReprT]: +// override def unwrap: self.LiftedReprT[B] = A ?=> x +// +////trait EnvUnwrapper[From, To]: +//// def unwrap(x: From): To +//// +////object EnvUnwrapper extends LowPrio: +//// given rec[A, B, IT, IW[+_]](using inner: EnvUnwrapper[B, IT, IW]): EnvUnwrapper[ContextFunction1[A, B], IT, [t] =>> A ?=> IW[t]] with +//// self => +//// override def unwrap(x: A ?=> B): Enved[IT, this.LiftedReprT] = new Enved[IT, self.LiftedReprT]: +//// override def unwrap: self.LiftedReprT[IT] = A ?=> inner.wrap(x).unwrap +//// +////trait LowPrio: +//// inline given last[A, B](using NotGiven[B <:< ContextFunction1[?, ?]]): EnvUnwrapper[ContextFunction1[A, B],B,[t] =>> A ?=> t] with +//// self => +//// override def unwrap(x: A ?=> B): Enved[B, self.LiftedReprT] = new Enved[B, self.LiftedReprT]: +//// override def unwrap: self.LiftedReprT[B] = A ?=> x +// +// +//object Enved: +// def wrap[A, B, T, W[+_]](f: ContextFunction1[A, B])(using e: EnvWrapper[ContextFunction1[A, B], T, W]): Enved[T, W] = e.wrap(f) +// +// extension [T](t: T) +// def requireEnv[Env]: Enved[T, [t] =>> Env ?=> t] = new Enved: +// override def unwrap: Env ?=> T = (e: Env) ?=> t +// +// extension [T, WO[+_] <: ContextFunction1[?, ?]](tt: Enved[T, WO]) +// def requireEnv[Env]: Enved[T, [t] =>> Env ?=> WO[t]] = new Enved[T, [t] =>> Env ?=> WO[t]]: +// override def unwrap: Env ?=> WO[T] = (e: Env) ?=> tt.unwrap +// +//// extension [T,Repr[+_]](enved:Enved[T,Repr]) +//// def useEnv[Env,U](f: Env ?=> T ?=> U):U = +// +//// extension [T, W[+_]](enved: Enved[T, W]) +//// def map[U](t: T => U)(using Unwrap) = +//// enved.unwrap +// +// +//trait EnvProvider[Env, T]: +// type Ret +// +// def provide(env: Env, enved: T): Ret +// +////[t] =>> ContextFunction1[Env,t]] +//object EnvProvider: +// given last[T, Env, W[+t] <: ContextFunction1[Env, t]]: EnvProvider[Env, Enved[T, W]] with { +// type Ret = T +// +// override def provide(env: Env, enved: Enved[T, W]): T = +// enved.unwrap(using env) +// } +// +// extension [T, WO[+_] <: ContextFunction1[?, ?]](tt: Enved[T, WO]) +// def provide[Env](env: Env)(using provider: EnvProvider[Env, Enved[T, WO]]): provider.Ret = provider.provide(env, tt) +// +// +//import ox.otel.Enved.requireEnv +// +////val r = 1.enved +//val ss: String ?=> Tracer ?=> Int = 1.requireEnv[Tracer].unwrap +//val wrp: Enved[String, [T] =>> Int ?=> String ?=> T] = Enved.wrap((x: Int) ?=> (y: String) ?=> "") +////wrp.using: +// +// +//def fun[T,C](enved:Enved[T,C]) = ??? +// +////def insideOut[A, B, C](f: A => B => C): B => A => C = (b: B) => (a: A) => f(a)(b) +// +////val e2 = x.map(_ + 1) +// +////given ttt:Tracer = ??? +////given iii:Meter = ??? +////val r: Flow[Int] = e2.wrapped +//// +////val sss: Flow[Int] = ??? +// +// +//// +// +////trait SpanStack[Depth<:Int]: +//// def pop:Span +//// +////class SpanStack +//// +////extension (last: SpanStack[1]) +//// def end(): = last.pop.end() +//// +//// +// +// +//// override def map[U](f: T => U): TracingStack[U] = ??? +// +// +////extension [T<:TracingStack[T]](stack:TracingStack[T]) +//// def elem:T = stack.elem +////extension [T](stack:TracingStack[T]) +//// def elem:T = stack.elem +// +// +//// def elem:T +// +// +////sealed trait TracingStack[T <: TracingStack[T]]: +//// def tracer: Tracer +//// protected def scope:Scope +// +// +////object TracingStack: +//// private final class Root(val tracer: Tracer) extends TracingStack[Nothing]: +//// override protected def scope: Scope = ??? +// +// +////, T +////] +////// +//// +// +// + +// +//////extension (span: SpanBuilder) +////// def start(): Span = +//// println("starting") +// span.startSpan() +// +//extension [T](flow: Flow[(Tracer, T)]) +// def startSpan(name: String): Flow[(Span, T)] = flow.map { (tracer, elem) => +// tracer.spanBuilder(name).startSpan() -> elem +// } +// def map[U](f: T => U): Flow[(Tracer, U)] = flow.map((t, x) => (t, (f(x)))) + + diff --git a/otel/src/main/scala/ox/flow/otel/Tarcing.scala b/otel/src/main/scala/ox/flow/otel/Tarcing.scala new file mode 100644 index 00000000..40e0614d --- /dev/null +++ b/otel/src/main/scala/ox/flow/otel/Tarcing.scala @@ -0,0 +1,78 @@ +//package ox.flow.otel +// +//import ox.flow.Flow +// +////object Ctxing extends App: +// + +// given cFromCtx [C](using x:Ctx[C]):C = x.get + +// def usingCtx[C,T,U](using c:CtxAccess[C])(f: C => T => U): T => U = f(c) +// +// +// val fl:Flow[String ?=> Int] = null +// +// val cf : CFlow[String ?=> Int]= CFlow.fromFlow(fl) +// import CFlow.map +// val cfm:CFlow[String ?=> Int] = cf.map( _ + 1) +// +// +// +// val cfm2:CFlow[String ?=> List[Int]] = cf.map: +// usingCtx: ctx => +// ctx.charAt(0) +// List.apply(_) + + +//package ox.otel +// +//import io.opentelemetry.api.trace.Tracer +// +//object Tarcing { +// trait TracingStack[T]: +// def tracer: Tracer +// +// def elem: T +// +// def startSpan(): TracingStack[TracingStack[T]] +// +// def endSpan(): T +// +// object TracingStack: +// trait ElemObtain[T]: +// type Ret +// +// def obtain(x: T): Ret +// +// object ElemObtain extends ElemObtainLowPrio: +// extension[T](t:TracingStack[T]) +// def elemObtain(using ob:ElemObtain[TracingStack[T]]):ob.Ret = ob.obtain(t) +// +// given [R,T <: TracingStack[R]](using rec: ElemObtain[T] {type Ret = R}): ElemObtain[TracingStack[T]] with +// type Ret = R +// +// override def obtain(x: TracingStack[T]): R = rec.obtain(x.elem) +// trait ElemObtainLowPrio: +// given last[T]: ElemObtain[TracingStack[T]] with +// type Ret = T +// +// override def obtain(x: TracingStack[T]): T = x.elem +// +// +// +// class Root[T](val tracer: Tracer, val elem: T) extends TracingStack[T]: +// self => +// def startSpan(): TracingStack[TracingStack[T]] = new TracingStack[TracingStack[T]] { +// override def tracer: Tracer = self.tracer +// +// override def elem: T = self.elem +// } +// +// override def endSpan(): T = self.elem +// +// import ox.otel.Tarcing.TracingStack.ElemObtain.elemObtain +// @main +// def m = +// val root = TracingStack.Root(null, 2) +// val elem = root.startSpan().startSpan().startSpan().startSpan().endSpan().endSpan().endSpan().endSpan().endSpan() +//} diff --git a/otel/src/main/scala/ox/telemetry/AdaptiveOxTracing.scala b/otel/src/main/scala/ox/telemetry/AdaptiveOxTracing.scala new file mode 100644 index 00000000..02dda46c --- /dev/null +++ b/otel/src/main/scala/ox/telemetry/AdaptiveOxTracing.scala @@ -0,0 +1,29 @@ +package ox.telemetry + +import io.opentelemetry.api.common.Attributes +import io.opentelemetry.api.trace.* +import ox.flow.Flow +import ox.telemetry.tracing.OxTracingByName +import ox.telemetry.tracing.flow.{OxFlowTracing, OxForkTracing} +import ox.telemetry.tracing.impl.OxTracingBase + +import scala.compiletime.erasedValue + +//final class AdaptiveOxTracing(base: OxTracingBase,oxTracingByName: OxTracingByName, oxFlowTracing: OxFlowTracing,oxForkTracing: OxForkTracing): +// export oxTracingByName.{span as _, *} +// export oxFlowTracing.{span as _, *} +// export oxForkTracing.{spanFork as _, *} +// export base.{span as _, *} +// extension [T](t: => T) +// inline def span(spanName: String, +// spanKind: SpanKind = SpanKind.INTERNAL, +// attributes: Attributes = Attributes.empty(), +// links: Seq[SpanContext] = Seq.empty, +// beforeClose: ((Span, Option[Throwable])) => Span = _._1 +// ): T = +// inline erasedValue[T] match +// case _: Flow[t] => oxFlowTracing.span(t.asInstanceOf[Flow[t]])(spanName, spanKind, attributes, links, beforeClose).asInstanceOf[T] +// case _ => oxTracingByName.byNameSpan(t)(spanName, spanKind, attributes, links, beforeClose) match { +// case itWasFlow: Flow[?] => System.err.println("AdaptiveOxTracing improperly applied to a flow"); itWasFlow +// case ok => ok +// } \ No newline at end of file diff --git a/otel/src/main/scala/ox/telemetry/ContextHolder.scala b/otel/src/main/scala/ox/telemetry/ContextHolder.scala new file mode 100644 index 00000000..5524f1bd --- /dev/null +++ b/otel/src/main/scala/ox/telemetry/ContextHolder.scala @@ -0,0 +1,28 @@ +package ox.telemetry + +import io.opentelemetry.context.Context +import ox.otel.context.PropagatingVirtualThreadFactory + + +trait ContextHolder: + def get(): Context + + def set(context: Context): Unit + +object ContextHolder: + object Native extends ContextHolder: + private var checked = false + + override def get(): Context = + checkThreadFactory() + Context.current() + + override def set(context: Context): Unit = + checkThreadFactory() + context.makeCurrent(): Unit + + private def checkThreadFactory() = + if !checked then + if !ox.oxThreadFactory.isInstanceOf[PropagatingVirtualThreadFactory] then System.err.println(s"Otel should use ${classOf[PropagatingVirtualThreadFactory].getName}") + checked = true + diff --git a/otel/src/main/scala/ox/telemetry/tracing/AutoCloseableSpan.scala b/otel/src/main/scala/ox/telemetry/tracing/AutoCloseableSpan.scala new file mode 100644 index 00000000..7db66395 --- /dev/null +++ b/otel/src/main/scala/ox/telemetry/tracing/AutoCloseableSpan.scala @@ -0,0 +1,14 @@ +package ox.telemetry.tracing + +import io.opentelemetry.api.trace.Span +import io.opentelemetry.context.Context +import ox.telemetry.ContextHolder + +trait AutoCloseableSpan extends AutoCloseable: + def span: Span + +object AutoCloseableSpan: + private [tracing] final class ContextHolderBased(val span: Span, contextHolder: ContextHolder, previousContext: Context) extends AutoCloseableSpan: + def close(): Unit = + span.end() + contextHolder.set(previousContext) \ No newline at end of file diff --git a/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala b/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala new file mode 100644 index 00000000..7d9257d5 --- /dev/null +++ b/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala @@ -0,0 +1,120 @@ +package ox.telemetry.tracing + +import io.opentelemetry.api.common.Attributes +import io.opentelemetry.api.trace.{Span, SpanContext, SpanKind, Tracer} +import ox.telemetry.ContextHolder +import ox.telemetry.tracing.flow.OxFlowTracing.OxFlowTracingImpl +import ox.telemetry.tracing.flow.{OxFlowTracing, OxForkTracing} +import ox.telemetry.tracing.impl.OxTracingBase +import ox.telemetry.tracing.impl.OxTracingBase.OxTracingBaseImpl + +trait OxTracing extends OxFlowTracing, OxTracingByName, OxTracingBase, OxForkTracing + +object OxTracing: + extension [T: TracingStrategy](t: T) + def span(spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty, + beforeClose: ((Span, Option[Throwable])) => Span = _._1) = + summon[TracingStrategy[T]].span(t)(spanName, spanKind, attributes, links, beforeClose) + + +import io.opentelemetry.api.common.Attributes +import io.opentelemetry.api.trace.{Span, SpanContext, SpanKind, Tracer} +import ox.flow.Flow +import ox.telemetry.tracing.flow.OxFlowTracing.OxFlowTracingImpl +import ox.telemetry.tracing.flow.{OxFlowTracing, OxForkTracing} +import ox.telemetry.tracing.impl.OxTracingBase +import ox.telemetry.tracing.impl.OxTracingBase.OxTracingBaseImpl +import ox.telemetry.{ContextHolder, tracing} + +import scala.compiletime.summonInline + + +trait TracingStrategy[T]: + def span(t: => T)(spanName: String, spanKind: SpanKind, attributes: Attributes, links: Seq[SpanContext], beforeClose: ((Span, Option[Throwable])) => Span): T + +object TracingStrategy extends LowPrio: + given [T]: TracingStrategy[Flow[T]] with { + override def span(flow: => Flow[T])(spanName: String, spanKind: SpanKind, attributes: Attributes, links: Seq[SpanContext], beforeClose: ((Span, Option[Throwable])) => Span): Flow[T] = + val preEvaluate = flow + Flow.usingEmit: emit => + byName.span(preEvaluate.runToEmit( + emit + ))(spanName, spanKind, attributes, links, beforeClose) + + } + +trait LowPrio: + inline def isAbstract[T](using q: quoted.Quotes, t: quoted.Type[T]): Boolean = + import q.reflect.{*, given} + import q.{*, given} + val tRefSym = TypeRepr.of[T].typeSymbol + tRefSym.isAbstractType + + + inline given byName[T](using q: quoted.Quotes, t: quoted.Type[T]): TracingStrategy[T] = + if isAbstract[T] then + summonInline[TracingStrategy[T]] + else + byName[T] + + + def byName[T]: TracingStrategy[T] = new { + extension (t: => T) + override def span(spanName: String, spanKind: SpanKind, attributes: Attributes, links: Seq[SpanContext], beforeClose: ((Span, Option[Throwable])) => Span): T = + var error: Throwable | Null = null + var finalizer: AutoCloseableSpan | Null = null + try + val finalize = ??? //spanUnsafe(spanName, spanKind, attributes, links) + finalizer = finalize + t + catch + case e: Throwable => + error = e + throw e + finally + try + if finalizer ne null then beforeClose(finalizer.nn.span, Option(error)): Unit + catch + case e: Throwable => + if error ne null then error.nn.addSuppressed(e) + else throw e + finally + if finalizer ne null then finalizer.nn.close() + end try + end try + } + + + + + + + + + +// def native(tracer: Tracer): OxTracing = +// val ch = ContextHolder.Native +// val base = OxTracingBaseImpl(tracer, ch) +// val byName = OxTracingByName.Impl(base) +// val oxFlowTracing = OxFlowTracingImpl(byName) +// val oxForkTracing: OxForkTracing = null +// Impl(oxFlowTracing, byName, base,oxForkTracing) +// +// def nativeAdaptive(tracer: Tracer): AdaptiveOxTracing = +// val ch = ContextHolder.Native +// val base = OxTracingBaseImpl(tracer, ch) +// val byName = OxTracingByName.Impl(base) +// val oxFlowTracing = OxFlowTracingImpl(byName) +// val oxForkTracing: OxForkTracing = null +// AdaptiveOxTracing(base, byName, oxFlowTracing,oxForkTracing) +// +// private class Impl(flow: OxFlowTracing, byName: OxTracingByName, base: OxTracingBase, fork:OxForkTracing) extends OxTracing: +// export flow.* +// export byName.* +// export base.* +// export fork.* +// +// diff --git a/otel/src/main/scala/ox/telemetry/tracing/OxTracingByName.scala b/otel/src/main/scala/ox/telemetry/tracing/OxTracingByName.scala new file mode 100644 index 00000000..d07a2094 --- /dev/null +++ b/otel/src/main/scala/ox/telemetry/tracing/OxTracingByName.scala @@ -0,0 +1,51 @@ +package ox.telemetry.tracing + +import io.opentelemetry.api.common.Attributes +import io.opentelemetry.api.trace.* +import ox.telemetry.tracing.flow.OxFlowTracing.OxFlowTracingImpl +import ox.telemetry.tracing.impl.OxTracingBase +import ox.telemetry.{AdaptiveOxTracing, ContextHolder} + + +trait OxTracingByName: + extension [T](t: => T) + def byNameSpan(spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty, + beforeClose: ((Span, Option[Throwable])) => Span = _._1): T + +object OxTracingByName: + class Impl(oxTracingBase: OxTracingBase) extends OxTracingByName: + import oxTracingBase.* + + extension [T](t: => T) + def byNameSpan(spanName: String, + spanKind: SpanKind, + attributes: Attributes, + links: Seq[SpanContext], + beforeClose: ((Span, Option[Throwable])) => Span + ) = { + var error: Throwable | Null = null + var finalizer: AutoCloseableSpan | Null = null + try + val finalize = spanUnsafe(spanName, spanKind, attributes, links) + finalizer = finalize + t + catch + case e: Throwable => + error = e + throw e + finally + try + if finalizer ne null then beforeClose(finalizer.nn.span, Option(error)): Unit + catch + case e: Throwable => + if error ne null then error.nn.addSuppressed(e) + else throw e + finally + if finalizer ne null then finalizer.nn.close() + end try + end try + } + \ No newline at end of file diff --git a/otel/src/main/scala/ox/telemetry/tracing/TracingStrategy.scala b/otel/src/main/scala/ox/telemetry/tracing/TracingStrategy.scala new file mode 100644 index 00000000..e69de29b diff --git a/otel/src/main/scala/ox/telemetry/tracing/flow/OxFlowTracing.scala b/otel/src/main/scala/ox/telemetry/tracing/flow/OxFlowTracing.scala new file mode 100644 index 00000000..7e4cc2ca --- /dev/null +++ b/otel/src/main/scala/ox/telemetry/tracing/flow/OxFlowTracing.scala @@ -0,0 +1,28 @@ +package ox.telemetry.tracing.flow + +import io.opentelemetry.api.common.Attributes +import io.opentelemetry.api.trace.{Span, SpanContext, SpanKind} +import ox.flow.Flow +import ox.telemetry.tracing.OxTracingByName + +trait OxFlowTracing: + def span[T](flow: Flow[T])(spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty, + beforeClose: ((Span, Option[Throwable])) => Span = _._1 + ): Flow[T] + +object OxFlowTracing: + class OxFlowTracingImpl(oxTracingByName: OxTracingByName) extends OxFlowTracing: + override def span[T](flow: Flow[T])(spanName: String, spanKind: SpanKind, attributes: Attributes, links: Seq[SpanContext], beforeClose: ((Span, Option[Throwable])) => Span): Flow[T] = + Flow.usingEmit: emit => + oxTracingByName.byNameSpan(flow.runToEmit( + emit + ))(spanName, spanKind, attributes, links, beforeClose) + + + +// extension [T](flow: Flow[T]) +// def span(spanName: String, spanKind: SpanKind, attributes: Attributes, links: Seq[SpanContext], beforeClose: ((Span, Option[Throwable])) => Span): Flow[T] = +// diff --git a/otel/src/main/scala/ox/telemetry/tracing/impl/OxTracingBase.scala b/otel/src/main/scala/ox/telemetry/tracing/impl/OxTracingBase.scala new file mode 100644 index 00000000..5f5ce12a --- /dev/null +++ b/otel/src/main/scala/ox/telemetry/tracing/impl/OxTracingBase.scala @@ -0,0 +1,35 @@ +package ox.telemetry.tracing.impl + +import io.opentelemetry.api.common.Attributes +import io.opentelemetry.api.trace.* +import ox.telemetry.tracing.flow.OxFlowTracing.OxFlowTracingImpl +import ox.telemetry.tracing.{AutoCloseableSpan} +import ox.telemetry.{AdaptiveOxTracing, ContextHolder} + +trait OxTracingBase: + def currentSpanUnsafe(): Span + + def spanUnsafe(spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty, + ): AutoCloseableSpan + + +object OxTracingBase: + private[tracing] final class OxTracingBaseImpl(tracer: Tracer, contextHolder: ContextHolder) extends OxTracingBase: + override def currentSpanUnsafe(): Span = Span.fromContext(contextHolder.get()) + + def spanUnsafe(spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty, + ): AutoCloseableSpan = { + val previousContext = contextHolder.get() + val builder = tracer.spanBuilder(spanName).setSpanKind(spanKind).setAllAttributes(attributes).setParent(previousContext) + val withLinks = links.foldLeft(builder)((b, l) => b.addLink(l)) + val started = withLinks.startSpan() + contextHolder.set(previousContext.`with`(started)) + + AutoCloseableSpan.ContextHolderBased(started, contextHolder, previousContext) + } diff --git a/otel/src/test/scala/ox/kafka/AppTest.scala b/otel/src/test/scala/ox/kafka/AppTest.scala new file mode 100644 index 00000000..77e9c25d --- /dev/null +++ b/otel/src/test/scala/ox/kafka/AppTest.scala @@ -0,0 +1,33 @@ +package ox.kafka + +import org.scalatest.BeforeAndAfterAll +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import ox.* +import ox.ExitCode.Success +import ox.otel.context.PropagatingVirtualThreadFactory + + +class AppTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll//: + +// "flow.spanned" should "not open span if not runned" in new Fixture { +// +// assert(this.settings.threadFactory.get.isInstanceOf[PropagatingVirtualThreadFactory]) +// } +// "flow.spanned" should "not open span if not runnefssfd" in new Fixture2 { +// +// assert(this.settings.threadFactory.get.isInstanceOf[PropagatingVirtualThreadFactory]) +// } + +// class Fixture extends OxApp.WithEitherErrors[String] with OtelOxApp.WithOtelSupport: +// override def run(args: Vector[String])(using Ox, EitherError[String]): ExitCode = ??? +// +// override def handleError(e: String): ExitCode = ??? +// +// +// class Fixture2 extends OxApp.Simple, OtelOxApp.WithOtelSupport: +// override protected def settings: OxApp.Settings = super.settings.copy(interruptedExitCode = Success) +// +// override def run(using Ox): Unit = ??? + +end AppTest diff --git a/otel/src/test/scala/ox/kafka/OtelTest.scala b/otel/src/test/scala/ox/kafka/OtelTest.scala new file mode 100644 index 00000000..4b712754 --- /dev/null +++ b/otel/src/test/scala/ox/kafka/OtelTest.scala @@ -0,0 +1,495 @@ +package ox.kafka + +import io.opentelemetry.api.metrics.Meter +import io.opentelemetry.sdk.OpenTelemetrySdk +import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter +import io.opentelemetry.sdk.trace.SdkTracerProvider +import io.opentelemetry.sdk.trace.`export`.SimpleSpanProcessor +import org.scalatest.BeforeAndAfterAll +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import ox.* +import ox.channels.BufferCapacity +import ox.either.ok +import ox.flow.Flow +import ox.flow.otel.* +import ox.otel.context.PropagatingVirtualThreadFactory +import ox.telemetry.AdaptiveOxTracing +import ox.telemetry.tracing.OxTracing +import ox.telemetry.tracing.impl.OxTracingBase + +import java.util.UUID +import scala.collection.JavaConverters.asScalaBufferConverter + + +class OtelTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: + setOxThreadFactory(new PropagatingVirtualThreadFactory) + + class My extends OxApp.WithEitherErrors[String]/* , OtelOxApp.WithOtelSupport */: + override def run(args: Vector[String])(using Ox, EitherError[String]): ExitCode = ??? + + override def handleError(e: String): ExitCode = ??? + + + val exporter = InMemorySpanExporter.create() + + val tracerProvider = + + SdkTracerProvider.builder() + + .addSpanProcessor(SimpleSpanProcessor.create(exporter)) + + .build() + + val openTelemetry = + + OpenTelemetrySdk.builder() + + .setTracerProvider(tracerProvider) + + .build() + + val tracer = openTelemetry.getTracer("test") + val tracer2 = openTelemetry.tracerBuilder("ftest").build() + val meter: Meter = openTelemetry.meterBuilder("ftest").build() + + val myTracing: OxTracing = OxTracing.native(tracer) + + import myTracing.* + + "flow.spanned" should "not open span if not runned" in { + val prefix = UUID.randomUUID().toString + + val flow = Flow.fromValues(1, 2, 3) + + val spannedFlow = flow.span(s"$prefix-span") + + val spans = exporter.getFinishedSpanItems + + val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) + assert(thisTestSpans.isEmpty) + + } + + "flow.spanned" should "open one span even if flow has multiple elements" in { + val prefix = UUID.randomUUID().toString + + val flow = Flow.fromValues(1, 2, 3) + + + val spannedFlow = flow.span(s"$prefix-span") + + spannedFlow.runDrain() + + val spans = exporter.getFinishedSpanItems + val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) + + assert(thisTestSpans.size == 1) + + val span = spans.get(0) + + assert(span.getName == s"$prefix-span") + } + + "flow.spanned" should "open two spans for two invocations" in { + val prefix = UUID.randomUUID().toString + + val flow = Flow.fromValues(1, 2, 3) + + val spanned1 = flow.span(s"$prefix-span") ++ Flow.fromValues(4, 2) + + val spanned2 = spanned1.span(s"$prefix-span2") + + spanned2.runDrain() + + val spans = exporter.getFinishedSpanItems + val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) + + assert(thisTestSpans.size == 2) + + assert(thisTestSpans.head.getName == s"$prefix-span") + assert(thisTestSpans(1).getName == s"$prefix-span2") + } + + + "flow.spanned" should "open only one span despite parralelized map" in { + + val prefix = UUID.randomUUID().toString + + val flow = Flow.fromValues(1, 2, 3) + + val spanned = flow.span(s"$prefix-span") + + spanned.mapPar(3)(_ + 1).runDrain() + + val spans = exporter.getFinishedSpanItems + + val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) + + assert(thisTestSpans.size == 1) + + val span = thisTestSpans(0) + + assert(span.getName == s"$prefix-span") + } + + + "flow.spanned" should "properly end span in case of error" in { + + val prefix = UUID.randomUUID().toString + + val flow = Flow.fromValues(1, 2, 3) + + + try { + flow.tap { case 2 => throw new Exception("boom"); case _ => () }.span(s"$prefix-span", beforeClose = (s, thr) => s.addEvent(thr.toString)).runToList() + } catch case _ => () + + + val spans = exporter.getFinishedSpanItems + + val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) + + assert(thisTestSpans.size == 1) + + val span = thisTestSpans(0) + + assert(span.getName == s"$prefix-span") + } + "flow.spanned" should "properly end span in case of error if mapped after span" in { + + val prefix = UUID.randomUUID().toString + + val flow = Flow.fromValues(1, 2, 3) + + val spanned = flow.span(s"$prefix-span", beforeClose = (s, thr) => s.addEvent(thr.toString)) + + try { + spanned.map { case 2 => throw new Exception("boom"); case n => n }.runToList() + } catch case _ => () + + + val spans = exporter.getFinishedSpanItems + + val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) + + assert(thisTestSpans.size == 1) + + val span = thisTestSpans(0) + + assert(span.getName == s"$prefix-span") + } + + "flow.spanned" should "correctly picks parent span" in { + + val prefix = UUID.randomUUID().toString + + val flow = Flow.fromValues(1, 2, 3) + + val finalizer = myTracing.spanUnsafe(s"$prefix-parentSpan") + + val spanned = flow.span(s"$prefix-span") + + spanned.runToList(): Unit + + finalizer.close() + + val spans = exporter.getFinishedSpanItems + + val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) + + assert(thisTestSpans.size == 2) + + val (parentSpans, childrenSpans) = thisTestSpans.partitionMap { s => if s.getName.endsWith("parentSpan") then Left(s) else Right(s) } + + assert(parentSpans.size == 1) + assert(childrenSpans.size == 1) + val parentSpan = parentSpans.head + + for { + span <- childrenSpans + } yield { + assert(span.getParentSpanId == parentSpan.getSpanId) + } + } + + "flow.spanned" should "open several spans if flatMapped" in { + + val prefix = UUID.randomUUID().toString + + val flow = Flow.fromValues(1, 2, 3) + + val spanned = flow.span(s"$prefix-span") + + flow.flatMap { _ => spanned }.span(s"$prefix-parentSpan").runToList(): Unit + + val spans = exporter.getFinishedSpanItems + + val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) + + assert(thisTestSpans.size == 4) + + val (parentSpans, childrenSpans) = thisTestSpans.partitionMap { s => if s.getName.endsWith("parentSpan") then Left(s) else Right(s) } + + assert(parentSpans.size == 1) + assert(childrenSpans.size == 3) + val parentSpan = parentSpans.head + + for { + span <- childrenSpans + } yield { + assert(span.getParentSpanId == parentSpan.getSpanId) + } + } + + "flow.spanned" should "properly end span in case of error2" in { + + val prefix = UUID.randomUUID().toString + + val flow = Flow.fromValues(1, 2, 3) + + val spanned = flow.span(s"$prefix-span") + + + spanned.map { case 2 => throw new Exception("boom"); case x => x }.recover(_ => 5).runToList() + + + val spans = exporter.getFinishedSpanItems + + val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) + + assert(thisTestSpans.size == 1) + + val span = thisTestSpans(0) + + assert(span.getName == s"$prefix-span") + } + "flow.spanned" should "properly end span in case of error2fd" in { + + val prefix = UUID.randomUUID().toString + + val flow = Flow.fromValues(1, 2, 3) + + val spanned = span(flow)(s"$prefix-span") + + val _ = span(spanned.map { case 2 => throw new Exception("boom"); case x => currentSpanUnsafe().addEvent(x.toString); x }.recover { x => currentSpanUnsafe().addEvent("recovery"); 5 })(s"$prefix-recover").runToList() + + val spans = exporter.getFinishedSpanItems + + val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) + + assert(thisTestSpans.size == 2) + + val recoverySpan = thisTestSpans.find(_.getName.endsWith("recover")).get + val flowSpan = thisTestSpans.find(_.getName.endsWith("span")).get + + assert(flowSpan.getParentSpanId == recoverySpan.getSpanId) + } + + "flow.spanned" should "properly end span in case of error2fdfs" in { + + val prefix = UUID.randomUUID().toString + + val flow = Flow.fromValues(1, 2, 3) + + val spanned = span(flow)(s"$prefix-span") + + + val finalinzer = spanUnsafe(s"$prefix-parent") + + supervised { + (1 to 10).foreach(_ => fork(println(spanned.runToList()))) + } + + finalinzer.close() + val spans = exporter.getFinishedSpanItems + + val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)).filterNot(s => s.getSpanId == finalinzer.span.getSpanContext.getSpanId) + + assert(thisTestSpans.size == 10) + + thisTestSpans.foreach { + s => assert(s.getParentSpanId == finalinzer.span.getSpanContext.getSpanId) + } + } + + "flow.spanned" should "properly end span in case of ejknrror2fdfs" in { + + val prefix = UUID.randomUUID().toString + + val flow = Flow.fromValues(1, 2, 3) + + val spanned = span(flow)(s"$prefix-span") + + + val finalinzer = spanUnsafe(s"$prefix-parent") + + supervised { + (1 to 10).foreach { + case 1 => fork(spanned.tap(_ => throw new Exception("fds")).runToList()) + case _ => fork(spanned.runToList()) + } + } + + finalinzer.close() + val spans = exporter.getFinishedSpanItems + + val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)).filterNot(s => s.getSpanId == finalinzer.span.getSpanContext.getSpanId) + + assert(thisTestSpans.size == 10) + + thisTestSpans.foreach { + s => assert(s.getParentSpanId == finalinzer.span.getSpanContext.getSpanId) + } + } + + "flow.spanned" should "properly end span in case of ejknrror2fdsfsfdffssfss" in { + + val em = EitherMode[String] + val prefix = UUID.randomUUID().toString + + val flow = Flow.fromValues(1, 2, 3) + + val spanned = span(flow)(s"$prefix-level1") + + val finalinzer = spanUnsafe(s"$prefix-level0") + + supervised { + (1 to 10).map { + case 1 => + forkUser(span(flow)(s"$prefix-1-level1").flatMap { + el => + val innerFlow = supervised { + flow.span(s"$prefix-level2", beforeClose = _._1.addEvent("boomed")) + } + innerFlow + }.runToList()) + + case _ => forkUser(spanned.runToList()) + } + } + + finalinzer.close() + val spans = exporter.getFinishedSpanItems + + val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) + val level1Spans = thisTestSpans.filter(s => s.getName.endsWith("level1")) + val level2Spans = thisTestSpans.filter(s => s.getName.endsWith("level2")) + + val level1_1 = level1Spans.find(s => s.getName.endsWith("1-level1")).get + assert(level1Spans.size == 10) + val level1Span = level1Spans.head + assert(level2Spans.size == 3) + + level1Spans.foreach { + s => assert(s.getParentSpanId == finalinzer.span.getSpanContext.getSpanId) + } + + level2Spans.foreach { + s => assert(s.getParentSpanId == level1_1.getSpanContext.getSpanId) + } + + } + + "flow.spanned" should "properly end span in case of ejknrror2fdffssfss" in { + + val em = EitherMode[String] + val prefix = UUID.randomUUID().toString + + val flow = Flow.fromValues(1, 2, 3) + + val finalinzer = spanUnsafe(s"$prefix-level0") + + val spanned = span(flow)(s"$prefix-level1") + + try + println: + supervised { + (1 to 10).map { + case 1 => forkUser { + println: + (span(flow)(s"$prefix-1-level1").flatMap { + el => { + supervisedError(em): + either: + val innerFlow = { + em.pure(flow.span(s"$prefix-level2", beforeClose = _._1.addEvent(s"boomed at $el")).map { case 1 => em.pureError("ops").ok(); case x => x }) + } + innerFlow.ok() + }.toOption.getOrElse(Flow.empty) + }.runToList()) + } + case _ => forkUser(spanned.runToList()) + } + } + catch case _ => () + + finalinzer.close() + val spans = exporter.getFinishedSpanItems + + val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) + val level1Spans = thisTestSpans.filter(s => s.getName.endsWith("level1")) + val level2Spans = thisTestSpans.filter(s => s.getName.endsWith("level2")) + val level1_1 = level1Spans.find(s => s.getName.endsWith("1-level1")).get + + assert(level1Spans.size == 10) + val level1Span = level1Spans.head + assert(level2Spans.size == 1) //because flow.flatMap is evaluated sequentially and exception ends whole flow + + level1Spans.foreach { + s => assert(s.getParentSpanId == finalinzer.span.getSpanContext.getSpanId) + } + + level2Spans.foreach { + s => + assert(s.getParentSpanId == level1_1.getSpanContext.getSpanId) + assert(s.getEvents().get(0).getName.startsWith("boomed")) + + } + + + } + + case class ContextAccess[C](c: C): + def use[C1, T](f: C => C1): C1 ?=> T = ??? + // object ContextState: + // given refl[C](using c:ContextState[C]):ContextState[C] = c + // val newC = ContextState(f(c)) + + + // extension [C](c:C) + // def use(f: () => C1): C1 = + + + def useContext[C, CC, T](using ContextAccess[C])(use: C => CC): ContextAccess[CC] ?=> T = ??? + + // case class FFF[C, T](c: C, x: T): + // transparent inline def map[U](inline f: ContextAccess[C] ?=> T => U): FFF[Any, U] = + // val ca = ContextAccess(c) + // f(using ca).apply(x) + // + // // given cu: ContextUser[C1] = new ContextUser[C1] + // + // // f.apply() + // + // + // val s: Span = ??? + // s.addEvent("fs") + // + // val ff = FFF[Span, Int](???, ???) + // val mapped = ff.map: c ?=> + // _ + 1 + + // val ffT :FFF[Span,Int] = mapped + + + // val mapped2 = ff.map: ctx ?=> + // ctx.end() + // _ + 1 + + // val ff: Flow[Span ?=> Int] = ??? + // val fff = ff.map { x => + // x + 1 + // } + +end OtelTest From 0056b71545646509be0b5a159220848ca1a746bb Mon Sep 17 00:00:00 2001 From: Pawel Sadlo Date: Fri, 9 Oct 2026 08:44:13 +0200 Subject: [PATCH 02/38] ... --- .../ox/telemetry/AdaptiveOxTracing.scala | 58 ++--- .../telemetry/tracing/AutoCloseableSpan.scala | 6 +- .../ox/telemetry/tracing/OxTracing.scala | 223 ++++++++++-------- .../telemetry/tracing/OxTracingByName.scala | 51 ---- .../tracing/flow/OxFlowTracing.scala | 37 ++- .../tracing/impl/OxTracingBase.scala | 35 --- .../tracing/internal/OxTracingBase.scala | 72 ++++++ .../test/scala/{ox/kafka => }/OtelTest.scala | 51 +--- 8 files changed, 243 insertions(+), 290 deletions(-) delete mode 100644 otel/src/main/scala/ox/telemetry/tracing/OxTracingByName.scala delete mode 100644 otel/src/main/scala/ox/telemetry/tracing/impl/OxTracingBase.scala create mode 100644 otel/src/main/scala/ox/telemetry/tracing/internal/OxTracingBase.scala rename otel/src/test/scala/{ox/kafka => }/OtelTest.scala (90%) diff --git a/otel/src/main/scala/ox/telemetry/AdaptiveOxTracing.scala b/otel/src/main/scala/ox/telemetry/AdaptiveOxTracing.scala index 02dda46c..6270dc5e 100644 --- a/otel/src/main/scala/ox/telemetry/AdaptiveOxTracing.scala +++ b/otel/src/main/scala/ox/telemetry/AdaptiveOxTracing.scala @@ -1,29 +1,29 @@ -package ox.telemetry - -import io.opentelemetry.api.common.Attributes -import io.opentelemetry.api.trace.* -import ox.flow.Flow -import ox.telemetry.tracing.OxTracingByName -import ox.telemetry.tracing.flow.{OxFlowTracing, OxForkTracing} -import ox.telemetry.tracing.impl.OxTracingBase - -import scala.compiletime.erasedValue - -//final class AdaptiveOxTracing(base: OxTracingBase,oxTracingByName: OxTracingByName, oxFlowTracing: OxFlowTracing,oxForkTracing: OxForkTracing): -// export oxTracingByName.{span as _, *} -// export oxFlowTracing.{span as _, *} -// export oxForkTracing.{spanFork as _, *} -// export base.{span as _, *} -// extension [T](t: => T) -// inline def span(spanName: String, -// spanKind: SpanKind = SpanKind.INTERNAL, -// attributes: Attributes = Attributes.empty(), -// links: Seq[SpanContext] = Seq.empty, -// beforeClose: ((Span, Option[Throwable])) => Span = _._1 -// ): T = -// inline erasedValue[T] match -// case _: Flow[t] => oxFlowTracing.span(t.asInstanceOf[Flow[t]])(spanName, spanKind, attributes, links, beforeClose).asInstanceOf[T] -// case _ => oxTracingByName.byNameSpan(t)(spanName, spanKind, attributes, links, beforeClose) match { -// case itWasFlow: Flow[?] => System.err.println("AdaptiveOxTracing improperly applied to a flow"); itWasFlow -// case ok => ok -// } \ No newline at end of file +//package ox.telemetry +// +//import io.opentelemetry.api.common.Attributes +//import io.opentelemetry.api.trace.* +//import ox.flow.Flow +//import ox.telemetry.tracing.OxTracingByName +//import ox.telemetry.tracing.flow.{OxFlowTracing, OxForkTracing} +//import ox.telemetry.tracing.internal.OxTracingBase +// +//import scala.compiletime.erasedValue +// +////final class AdaptiveOxTracing(base: OxTracingBase,oxTracingByName: OxTracingByName, oxFlowTracing: OxFlowTracing,oxForkTracing: OxForkTracing): +//// export oxTracingByName.{span as _, *} +//// export oxFlowTracing.{span as _, *} +//// export oxForkTracing.{spanFork as _, *} +//// export base.{span as _, *} +//// extension [T](t: => T) +//// inline def span(spanName: String, +//// spanKind: SpanKind = SpanKind.INTERNAL, +//// attributes: Attributes = Attributes.empty(), +//// links: Seq[SpanContext] = Seq.empty, +//// beforeClose: ((Span, Option[Throwable])) => Span = _._1 +//// ): T = +//// inline erasedValue[T] match +//// case _: Flow[t] => oxFlowTracing.span(t.asInstanceOf[Flow[t]])(spanName, spanKind, attributes, links, beforeClose).asInstanceOf[T] +//// case _ => oxTracingByName.byNameSpan(t)(spanName, spanKind, attributes, links, beforeClose) match { +//// case itWasFlow: Flow[?] => System.err.println("AdaptiveOxTracing improperly applied to a flow"); itWasFlow +//// case ok => ok +//// } \ No newline at end of file diff --git a/otel/src/main/scala/ox/telemetry/tracing/AutoCloseableSpan.scala b/otel/src/main/scala/ox/telemetry/tracing/AutoCloseableSpan.scala index 7db66395..00ac9dac 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/AutoCloseableSpan.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/AutoCloseableSpan.scala @@ -4,11 +4,11 @@ import io.opentelemetry.api.trace.Span import io.opentelemetry.context.Context import ox.telemetry.ContextHolder -trait AutoCloseableSpan extends AutoCloseable: +sealed trait AutoCloseableSpan extends AutoCloseable: def span: Span -object AutoCloseableSpan: - private [tracing] final class ContextHolderBased(val span: Span, contextHolder: ContextHolder, previousContext: Context) extends AutoCloseableSpan: +private object AutoCloseableSpan: + private [ox] final class ContextHolderBased(val span: Span, contextHolder: ContextHolder, previousContext: Context) extends AutoCloseableSpan: def close(): Unit = span.end() contextHolder.set(previousContext) \ No newline at end of file diff --git a/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala b/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala index 7d9257d5..ced1f872 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala @@ -1,120 +1,139 @@ package ox.telemetry.tracing import io.opentelemetry.api.common.Attributes -import io.opentelemetry.api.trace.{Span, SpanContext, SpanKind, Tracer} +import io.opentelemetry.api.trace.{Span, SpanKind, Tracer} +import ox.flow.Flow import ox.telemetry.ContextHolder +import ox.telemetry.tracing.flow.OxFlowTracing import ox.telemetry.tracing.flow.OxFlowTracing.OxFlowTracingImpl -import ox.telemetry.tracing.flow.{OxFlowTracing, OxForkTracing} -import ox.telemetry.tracing.impl.OxTracingBase -import ox.telemetry.tracing.impl.OxTracingBase.OxTracingBaseImpl +import ox.telemetry.tracing.internal.OxTracingBase +import ox.telemetry.tracing.internal.OxTracingBase.OxTracingBaseImpl -trait OxTracing extends OxFlowTracing, OxTracingByName, OxTracingBase, OxForkTracing +sealed trait OxTracing extends OxTracingBase, OxFlowTracing object OxTracing: - extension [T: TracingStrategy](t: T) - def span(spanName: String, - spanKind: SpanKind = SpanKind.INTERNAL, - attributes: Attributes = Attributes.empty(), - links: Seq[SpanContext] = Seq.empty, - beforeClose: ((Span, Option[Throwable])) => Span = _._1) = - summon[TracingStrategy[T]].span(t)(spanName, spanKind, attributes, links, beforeClose) - - -import io.opentelemetry.api.common.Attributes -import io.opentelemetry.api.trace.{Span, SpanContext, SpanKind, Tracer} -import ox.flow.Flow -import ox.telemetry.tracing.flow.OxFlowTracing.OxFlowTracingImpl -import ox.telemetry.tracing.flow.{OxFlowTracing, OxForkTracing} -import ox.telemetry.tracing.impl.OxTracingBase -import ox.telemetry.tracing.impl.OxTracingBase.OxTracingBaseImpl -import ox.telemetry.{ContextHolder, tracing} - -import scala.compiletime.summonInline - - -trait TracingStrategy[T]: - def span(t: => T)(spanName: String, spanKind: SpanKind, attributes: Attributes, links: Seq[SpanContext], beforeClose: ((Span, Option[Throwable])) => Span): T - -object TracingStrategy extends LowPrio: - given [T]: TracingStrategy[Flow[T]] with { - override def span(flow: => Flow[T])(spanName: String, spanKind: SpanKind, attributes: Attributes, links: Seq[SpanContext], beforeClose: ((Span, Option[Throwable])) => Span): Flow[T] = - val preEvaluate = flow - Flow.usingEmit: emit => - byName.span(preEvaluate.runToEmit( - emit - ))(spanName, spanKind, attributes, links, beforeClose) - - } - -trait LowPrio: - inline def isAbstract[T](using q: quoted.Quotes, t: quoted.Type[T]): Boolean = - import q.reflect.{*, given} - import q.{*, given} - val tRefSym = TypeRepr.of[T].typeSymbol - tRefSym.isAbstractType - + def native(tracer: Tracer): OxTracing = + val ch = ContextHolder.Native + val base = OxTracingBase(tracer, ch) + val oxFlowTracing = OxFlowTracingImpl(base) + Impl(base, oxFlowTracing) - inline given byName[T](using q: quoted.Quotes, t: quoted.Type[T]): TracingStrategy[T] = - if isAbstract[T] then - summonInline[TracingStrategy[T]] - else - byName[T] + private class Impl(base: OxTracingBase, flow: OxFlowTracing) extends OxTracing: + export flow.* + export base.* - def byName[T]: TracingStrategy[T] = new { - extension (t: => T) - override def span(spanName: String, spanKind: SpanKind, attributes: Attributes, links: Seq[SpanContext], beforeClose: ((Span, Option[Throwable])) => Span): T = - var error: Throwable | Null = null - var finalizer: AutoCloseableSpan | Null = null - try - val finalize = ??? //spanUnsafe(spanName, spanKind, attributes, links) - finalizer = finalize - t - catch - case e: Throwable => - error = e - throw e - finally - try - if finalizer ne null then beforeClose(finalizer.nn.span, Option(error)): Unit - catch - case e: Throwable => - if error ne null then error.nn.addSuppressed(e) - else throw e - finally - if finalizer ne null then finalizer.nn.close() - end try - end try - } - - - - - - -// def native(tracer: Tracer): OxTracing = -// val ch = ContextHolder.Native -// val base = OxTracingBaseImpl(tracer, ch) -// val byName = OxTracingByName.Impl(base) -// val oxFlowTracing = OxFlowTracingImpl(byName) -// val oxForkTracing: OxForkTracing = null -// Impl(oxFlowTracing, byName, base,oxForkTracing) // -// def nativeAdaptive(tracer: Tracer): AdaptiveOxTracing = -// val ch = ContextHolder.Native -// val base = OxTracingBaseImpl(tracer, ch) -// val byName = OxTracingByName.Impl(base) -// val oxFlowTracing = OxFlowTracingImpl(byName) -// val oxForkTracing: OxForkTracing = null -// AdaptiveOxTracing(base, byName, oxFlowTracing,oxForkTracing) +// override def currentSpanUnsafe(): Span = Span.fromContext(contextHolder.get()) +// +// +// def spanUnsafe(spanName: String, +// spanKind: SpanKind = SpanKind.INTERNAL, +// attributes: Attributes = Attributes.empty(), +// links: Seq[SpanContext] = Seq.empty, +// ): AutoCloseableSpan = { +// val previousContext = contextHolder.get() +// val builder = tracer.spanBuilder(spanName).setSpanKind(spanKind).setAllAttributes(attributes).setParent(previousContext) +// val withLinks = links.foldLeft(builder)((b, l) => b.addLink(l)) +// val started = withLinks.startSpan() +// contextHolder.set(previousContext.`with`(started)) +// +// AutoCloseableSpan.ContextHolderBased(started, contextHolder, previousContext) +// } +// +// +// def byNameSpan[T](t: T)(spanName: String, +// spanKind: SpanKind, +// attributes: Attributes, +// links: Seq[SpanContext], +// beforeClose: ((Span, Option[Throwable])) => Span +// ) = { +// var error: Throwable | Null = null +// var finalizer: AutoCloseableSpan | Null = null +// try +// val finalize = spanUnsafe(spanName, spanKind, attributes, links) +// finalizer = finalize +// t +// catch +// case e: Throwable => +// error = e +// throw e +// finally +// try +// if finalizer ne null then beforeClose(finalizer.nn.span, Option(error)): Unit +// catch +// case e: Throwable => +// if error ne null then error.nn.addSuppressed(e) +// else throw e +// finally +// if finalizer ne null then finalizer.nn.close() +// end try +// end try +// } +// +// def spanFlow[T](flow: Flow[T])(spanName: String, spanKind: SpanKind, attributes: Attributes, links: Seq[SpanContext], beforeClose: ((Span, Option[Throwable])) => Span): Flow[T] = +// Flow.usingEmit: emit => +// byNameSpan(flow.runToEmit( +// emit +// ))(spanName, spanKind, attributes, links, beforeClose) +//} // -// private class Impl(flow: OxFlowTracing, byName: OxTracingByName, base: OxTracingBase, fork:OxForkTracing) extends OxTracing: -// export flow.* -// export byName.* -// export base.* -// export fork.* // +////trait TracingStrategy[T]: +//// def span(t: => T)(spanName: String, spanKind: SpanKind, attributes: Attributes, links: Seq[SpanContext], beforeClose: ((Span, Option[Throwable])) => Span): T +//// +////object TracingStrategy extends LowPrio: +//// given [T]: TracingStrategy[Flow[T]] with { +//// override def span(flow: => Flow[T])(spanName: String, spanKind: SpanKind, attributes: Attributes, links: Seq[SpanContext], beforeClose: ((Span, Option[Throwable])) => Span): Flow[T] = +//// val preEvaluate = flow +//// Flow.usingEmit: emit => +//// byName.span(preEvaluate.runToEmit( +//// emit +//// ))(spanName, spanKind, attributes, links, beforeClose) +//// +//// } +//// +////trait LowPrio: +//// inline def isAbstract[T](using q: quoted.Quotes, t: quoted.Type[T]): Boolean = +//// import q.reflect.{*, given} +//// import q.{*, given} +//// val tRefSym = TypeRepr.of[T].typeSymbol +//// tRefSym.isAbstractType +//// +//// +//// inline given byName[T](using q: quoted.Quotes, t: quoted.Type[T]): TracingStrategy[T] = +//// if isAbstract[T] then +//// summonInline[TracingStrategy[T]] +//// else +//// byName[T] +//// +//// +//// def byName[T]: TracingStrategy[T] = new { +//// extension (t: => T) +//// override def span(spanName: String, spanKind: SpanKind, attributes: Attributes, links: Seq[SpanContext], beforeClose: ((Span, Option[Throwable])) => Span): T = +//// var error: Throwable | Null = null +//// var finalizer: AutoCloseableSpan | Null = null +//// try +//// val finalize = ??? //spanUnsafe(spanName, spanKind, attributes, links) +//// finalizer = finalize +//// t +//// catch +//// case e: Throwable => +//// error = e +//// throw e +//// finally +//// try +//// if finalizer ne null then beforeClose(finalizer.nn.span, Option(error)): Unit +//// catch +//// case e: Throwable => +//// if error ne null then error.nn.addSuppressed(e) +//// else throw e +//// finally +//// if finalizer ne null then finalizer.nn.close() +//// end try +//// end try +//// } // diff --git a/otel/src/main/scala/ox/telemetry/tracing/OxTracingByName.scala b/otel/src/main/scala/ox/telemetry/tracing/OxTracingByName.scala deleted file mode 100644 index d07a2094..00000000 --- a/otel/src/main/scala/ox/telemetry/tracing/OxTracingByName.scala +++ /dev/null @@ -1,51 +0,0 @@ -package ox.telemetry.tracing - -import io.opentelemetry.api.common.Attributes -import io.opentelemetry.api.trace.* -import ox.telemetry.tracing.flow.OxFlowTracing.OxFlowTracingImpl -import ox.telemetry.tracing.impl.OxTracingBase -import ox.telemetry.{AdaptiveOxTracing, ContextHolder} - - -trait OxTracingByName: - extension [T](t: => T) - def byNameSpan(spanName: String, - spanKind: SpanKind = SpanKind.INTERNAL, - attributes: Attributes = Attributes.empty(), - links: Seq[SpanContext] = Seq.empty, - beforeClose: ((Span, Option[Throwable])) => Span = _._1): T - -object OxTracingByName: - class Impl(oxTracingBase: OxTracingBase) extends OxTracingByName: - import oxTracingBase.* - - extension [T](t: => T) - def byNameSpan(spanName: String, - spanKind: SpanKind, - attributes: Attributes, - links: Seq[SpanContext], - beforeClose: ((Span, Option[Throwable])) => Span - ) = { - var error: Throwable | Null = null - var finalizer: AutoCloseableSpan | Null = null - try - val finalize = spanUnsafe(spanName, spanKind, attributes, links) - finalizer = finalize - t - catch - case e: Throwable => - error = e - throw e - finally - try - if finalizer ne null then beforeClose(finalizer.nn.span, Option(error)): Unit - catch - case e: Throwable => - if error ne null then error.nn.addSuppressed(e) - else throw e - finally - if finalizer ne null then finalizer.nn.close() - end try - end try - } - \ No newline at end of file diff --git a/otel/src/main/scala/ox/telemetry/tracing/flow/OxFlowTracing.scala b/otel/src/main/scala/ox/telemetry/tracing/flow/OxFlowTracing.scala index 7e4cc2ca..6cbcfb7e 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/flow/OxFlowTracing.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/flow/OxFlowTracing.scala @@ -3,26 +3,23 @@ package ox.telemetry.tracing.flow import io.opentelemetry.api.common.Attributes import io.opentelemetry.api.trace.{Span, SpanContext, SpanKind} import ox.flow.Flow -import ox.telemetry.tracing.OxTracingByName +import ox.telemetry.tracing.internal.OxTracingBase -trait OxFlowTracing: - def span[T](flow: Flow[T])(spanName: String, - spanKind: SpanKind = SpanKind.INTERNAL, - attributes: Attributes = Attributes.empty(), - links: Seq[SpanContext] = Seq.empty, - beforeClose: ((Span, Option[Throwable])) => Span = _._1 - ): Flow[T] +private[tracing] trait OxFlowTracing: + extension [T](flow: Flow[T]) + def span(spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty, + beforeClose: ((Span, Option[Throwable])) => Span = _._1 + ): Flow[T] -object OxFlowTracing: - class OxFlowTracingImpl(oxTracingByName: OxTracingByName) extends OxFlowTracing: - override def span[T](flow: Flow[T])(spanName: String, spanKind: SpanKind, attributes: Attributes, links: Seq[SpanContext], beforeClose: ((Span, Option[Throwable])) => Span): Flow[T] = - Flow.usingEmit: emit => - oxTracingByName.byNameSpan(flow.runToEmit( - emit - ))(spanName, spanKind, attributes, links, beforeClose) +private[tracing] object OxFlowTracing: + private [tracing] final class OxFlowTracingImpl(base: OxTracingBase) extends OxFlowTracing: + extension [T](flow: Flow[T]) + override def span(spanName: String, spanKind: SpanKind, attributes: Attributes, links: Seq[SpanContext], beforeClose: ((Span, Option[Throwable])) => Span): Flow[T] = + Flow.usingEmit: emit => + base.byNameSpan(flow.runToEmit( + emit + ))(spanName, spanKind, attributes, links, beforeClose) - - -// extension [T](flow: Flow[T]) -// def span(spanName: String, spanKind: SpanKind, attributes: Attributes, links: Seq[SpanContext], beforeClose: ((Span, Option[Throwable])) => Span): Flow[T] = -// diff --git a/otel/src/main/scala/ox/telemetry/tracing/impl/OxTracingBase.scala b/otel/src/main/scala/ox/telemetry/tracing/impl/OxTracingBase.scala deleted file mode 100644 index 5f5ce12a..00000000 --- a/otel/src/main/scala/ox/telemetry/tracing/impl/OxTracingBase.scala +++ /dev/null @@ -1,35 +0,0 @@ -package ox.telemetry.tracing.impl - -import io.opentelemetry.api.common.Attributes -import io.opentelemetry.api.trace.* -import ox.telemetry.tracing.flow.OxFlowTracing.OxFlowTracingImpl -import ox.telemetry.tracing.{AutoCloseableSpan} -import ox.telemetry.{AdaptiveOxTracing, ContextHolder} - -trait OxTracingBase: - def currentSpanUnsafe(): Span - - def spanUnsafe(spanName: String, - spanKind: SpanKind = SpanKind.INTERNAL, - attributes: Attributes = Attributes.empty(), - links: Seq[SpanContext] = Seq.empty, - ): AutoCloseableSpan - - -object OxTracingBase: - private[tracing] final class OxTracingBaseImpl(tracer: Tracer, contextHolder: ContextHolder) extends OxTracingBase: - override def currentSpanUnsafe(): Span = Span.fromContext(contextHolder.get()) - - def spanUnsafe(spanName: String, - spanKind: SpanKind = SpanKind.INTERNAL, - attributes: Attributes = Attributes.empty(), - links: Seq[SpanContext] = Seq.empty, - ): AutoCloseableSpan = { - val previousContext = contextHolder.get() - val builder = tracer.spanBuilder(spanName).setSpanKind(spanKind).setAllAttributes(attributes).setParent(previousContext) - val withLinks = links.foldLeft(builder)((b, l) => b.addLink(l)) - val started = withLinks.startSpan() - contextHolder.set(previousContext.`with`(started)) - - AutoCloseableSpan.ContextHolderBased(started, contextHolder, previousContext) - } diff --git a/otel/src/main/scala/ox/telemetry/tracing/internal/OxTracingBase.scala b/otel/src/main/scala/ox/telemetry/tracing/internal/OxTracingBase.scala new file mode 100644 index 00000000..1531bfb4 --- /dev/null +++ b/otel/src/main/scala/ox/telemetry/tracing/internal/OxTracingBase.scala @@ -0,0 +1,72 @@ +package ox.telemetry.tracing.internal + +import io.opentelemetry.api.common.Attributes +import io.opentelemetry.api.trace.* +import ox.telemetry.ContextHolder +import ox.telemetry.tracing.AutoCloseableSpan + +private[tracing] trait OxTracingBase: + def currentSpanUnsafe(): Span + + def spanUnsafe(spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty, + ): AutoCloseableSpan + + extension [T](t: => T) + def byNameSpan(spanName: String, + spanKind: SpanKind, + attributes: Attributes, + links: Seq[SpanContext], + beforeClose: ((Span, Option[Throwable])) => Span + ): T + +private[tracing] object OxTracingBase: + def apply(tracer: Tracer, contextHolder: ContextHolder): OxTracingBase = new OxTracingBaseImpl(tracer, contextHolder) + + private final class OxTracingBaseImpl(tracer: Tracer, contextHolder: ContextHolder) extends OxTracingBase: + + override def currentSpanUnsafe(): Span = Span.fromContext(contextHolder.get()) + + + def spanUnsafe(spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty, + ): AutoCloseableSpan = + val previousContext = contextHolder.get() + val builder = tracer.spanBuilder(spanName).setSpanKind(spanKind).setAllAttributes(attributes).setParent(previousContext) + val withLinks = links.foldLeft(builder)((b, l) => b.addLink(l)) + val started = withLinks.startSpan() + contextHolder.set(previousContext.`with`(started)) + + AutoCloseableSpan.ContextHolderBased(started, contextHolder, previousContext) + + extension [T](t: => T) + override def byNameSpan(spanName: String, spanKind: SpanKind, attributes: Attributes, links: Seq[SpanContext], beforeClose: ((Span, Option[Throwable])) => Span): T = { + var error: Throwable | Null = null + var finalizer: AutoCloseableSpan | Null = null + try + val finalize = spanUnsafe(spanName, spanKind, attributes, links) + finalizer = finalize + t + catch + case e: Throwable => + error = e + throw e + finally + try + if finalizer ne null then beforeClose(finalizer.nn.span, Option(error)): Unit + catch + case e: Throwable => + if error ne null then error.nn.addSuppressed(e) + else throw e + finally + if finalizer ne null then finalizer.nn.close() + end try + end try + } + + + end OxTracingBaseImpl diff --git a/otel/src/test/scala/ox/kafka/OtelTest.scala b/otel/src/test/scala/OtelTest.scala similarity index 90% rename from otel/src/test/scala/ox/kafka/OtelTest.scala rename to otel/src/test/scala/OtelTest.scala index 4b712754..84115060 100644 --- a/otel/src/test/scala/ox/kafka/OtelTest.scala +++ b/otel/src/test/scala/OtelTest.scala @@ -1,5 +1,3 @@ -package ox.kafka - import io.opentelemetry.api.metrics.Meter import io.opentelemetry.sdk.OpenTelemetrySdk import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter @@ -12,11 +10,8 @@ import ox.* import ox.channels.BufferCapacity import ox.either.ok import ox.flow.Flow -import ox.flow.otel.* import ox.otel.context.PropagatingVirtualThreadFactory -import ox.telemetry.AdaptiveOxTracing import ox.telemetry.tracing.OxTracing -import ox.telemetry.tracing.impl.OxTracingBase import java.util.UUID import scala.collection.JavaConverters.asScalaBufferConverter @@ -25,7 +20,7 @@ import scala.collection.JavaConverters.asScalaBufferConverter class OtelTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: setOxThreadFactory(new PropagatingVirtualThreadFactory) - class My extends OxApp.WithEitherErrors[String]/* , OtelOxApp.WithOtelSupport */: + class My extends OxApp.WithEitherErrors[String] /* , OtelOxApp.WithOtelSupport */: override def run(args: Vector[String])(using Ox, EitherError[String]): ExitCode = ??? override def handleError(e: String): ExitCode = ??? @@ -62,7 +57,6 @@ class OtelTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val flow = Flow.fromValues(1, 2, 3) - val spannedFlow = flow.span(s"$prefix-span") val spans = exporter.getFinishedSpanItems @@ -321,7 +315,6 @@ class OtelTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val spanned = span(flow)(s"$prefix-span") - val finalinzer = spanUnsafe(s"$prefix-parent") supervised { @@ -450,46 +443,4 @@ class OtelTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: } - case class ContextAccess[C](c: C): - def use[C1, T](f: C => C1): C1 ?=> T = ??? - // object ContextState: - // given refl[C](using c:ContextState[C]):ContextState[C] = c - // val newC = ContextState(f(c)) - - - // extension [C](c:C) - // def use(f: () => C1): C1 = - - - def useContext[C, CC, T](using ContextAccess[C])(use: C => CC): ContextAccess[CC] ?=> T = ??? - - // case class FFF[C, T](c: C, x: T): - // transparent inline def map[U](inline f: ContextAccess[C] ?=> T => U): FFF[Any, U] = - // val ca = ContextAccess(c) - // f(using ca).apply(x) - // - // // given cu: ContextUser[C1] = new ContextUser[C1] - // - // // f.apply() - // - // - // val s: Span = ??? - // s.addEvent("fs") - // - // val ff = FFF[Span, Int](???, ???) - // val mapped = ff.map: c ?=> - // _ + 1 - - // val ffT :FFF[Span,Int] = mapped - - - // val mapped2 = ff.map: ctx ?=> - // ctx.end() - // _ + 1 - - // val ff: Flow[Span ?=> Int] = ??? - // val fff = ff.map { x => - // x + 1 - // } - end OtelTest From 8e0051438453d3cff14a2d9f7d056724383caa4b Mon Sep 17 00:00:00 2001 From: Pawel Sadlo Date: Fri, 9 Oct 2026 08:45:21 +0200 Subject: [PATCH 03/38] ... --- .../ox/telemetry/tracing/OxTracing.scala | 119 +----------------- 1 file changed, 1 insertion(+), 118 deletions(-) diff --git a/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala b/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala index ced1f872..2676fdde 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala @@ -7,7 +7,6 @@ import ox.telemetry.ContextHolder import ox.telemetry.tracing.flow.OxFlowTracing import ox.telemetry.tracing.flow.OxFlowTracing.OxFlowTracingImpl import ox.telemetry.tracing.internal.OxTracingBase -import ox.telemetry.tracing.internal.OxTracingBase.OxTracingBaseImpl sealed trait OxTracing extends OxTracingBase, OxFlowTracing @@ -20,120 +19,4 @@ object OxTracing: private class Impl(base: OxTracingBase, flow: OxFlowTracing) extends OxTracing: export flow.* - export base.* - - - - - -// -// override def currentSpanUnsafe(): Span = Span.fromContext(contextHolder.get()) -// -// -// def spanUnsafe(spanName: String, -// spanKind: SpanKind = SpanKind.INTERNAL, -// attributes: Attributes = Attributes.empty(), -// links: Seq[SpanContext] = Seq.empty, -// ): AutoCloseableSpan = { -// val previousContext = contextHolder.get() -// val builder = tracer.spanBuilder(spanName).setSpanKind(spanKind).setAllAttributes(attributes).setParent(previousContext) -// val withLinks = links.foldLeft(builder)((b, l) => b.addLink(l)) -// val started = withLinks.startSpan() -// contextHolder.set(previousContext.`with`(started)) -// -// AutoCloseableSpan.ContextHolderBased(started, contextHolder, previousContext) -// } -// -// -// def byNameSpan[T](t: T)(spanName: String, -// spanKind: SpanKind, -// attributes: Attributes, -// links: Seq[SpanContext], -// beforeClose: ((Span, Option[Throwable])) => Span -// ) = { -// var error: Throwable | Null = null -// var finalizer: AutoCloseableSpan | Null = null -// try -// val finalize = spanUnsafe(spanName, spanKind, attributes, links) -// finalizer = finalize -// t -// catch -// case e: Throwable => -// error = e -// throw e -// finally -// try -// if finalizer ne null then beforeClose(finalizer.nn.span, Option(error)): Unit -// catch -// case e: Throwable => -// if error ne null then error.nn.addSuppressed(e) -// else throw e -// finally -// if finalizer ne null then finalizer.nn.close() -// end try -// end try -// } -// -// def spanFlow[T](flow: Flow[T])(spanName: String, spanKind: SpanKind, attributes: Attributes, links: Seq[SpanContext], beforeClose: ((Span, Option[Throwable])) => Span): Flow[T] = -// Flow.usingEmit: emit => -// byNameSpan(flow.runToEmit( -// emit -// ))(spanName, spanKind, attributes, links, beforeClose) -//} -// -// -////trait TracingStrategy[T]: -//// def span(t: => T)(spanName: String, spanKind: SpanKind, attributes: Attributes, links: Seq[SpanContext], beforeClose: ((Span, Option[Throwable])) => Span): T -//// -////object TracingStrategy extends LowPrio: -//// given [T]: TracingStrategy[Flow[T]] with { -//// override def span(flow: => Flow[T])(spanName: String, spanKind: SpanKind, attributes: Attributes, links: Seq[SpanContext], beforeClose: ((Span, Option[Throwable])) => Span): Flow[T] = -//// val preEvaluate = flow -//// Flow.usingEmit: emit => -//// byName.span(preEvaluate.runToEmit( -//// emit -//// ))(spanName, spanKind, attributes, links, beforeClose) -//// -//// } -//// -////trait LowPrio: -//// inline def isAbstract[T](using q: quoted.Quotes, t: quoted.Type[T]): Boolean = -//// import q.reflect.{*, given} -//// import q.{*, given} -//// val tRefSym = TypeRepr.of[T].typeSymbol -//// tRefSym.isAbstractType -//// -//// -//// inline given byName[T](using q: quoted.Quotes, t: quoted.Type[T]): TracingStrategy[T] = -//// if isAbstract[T] then -//// summonInline[TracingStrategy[T]] -//// else -//// byName[T] -//// -//// -//// def byName[T]: TracingStrategy[T] = new { -//// extension (t: => T) -//// override def span(spanName: String, spanKind: SpanKind, attributes: Attributes, links: Seq[SpanContext], beforeClose: ((Span, Option[Throwable])) => Span): T = -//// var error: Throwable | Null = null -//// var finalizer: AutoCloseableSpan | Null = null -//// try -//// val finalize = ??? //spanUnsafe(spanName, spanKind, attributes, links) -//// finalizer = finalize -//// t -//// catch -//// case e: Throwable => -//// error = e -//// throw e -//// finally -//// try -//// if finalizer ne null then beforeClose(finalizer.nn.span, Option(error)): Unit -//// catch -//// case e: Throwable => -//// if error ne null then error.nn.addSuppressed(e) -//// else throw e -//// finally -//// if finalizer ne null then finalizer.nn.close() -//// end try -//// end try -//// } -// + export base.* \ No newline at end of file From 9d448d3dd0afa3af925d5a8ccb61d8d055ae6929 Mon Sep 17 00:00:00 2001 From: Pawel Sadlo Date: Fri, 9 Oct 2026 08:46:57 +0200 Subject: [PATCH 04/38] ... --- .../ox/telemetry/AdaptiveOxTracing.scala | 29 ------------------- 1 file changed, 29 deletions(-) delete mode 100644 otel/src/main/scala/ox/telemetry/AdaptiveOxTracing.scala diff --git a/otel/src/main/scala/ox/telemetry/AdaptiveOxTracing.scala b/otel/src/main/scala/ox/telemetry/AdaptiveOxTracing.scala deleted file mode 100644 index 6270dc5e..00000000 --- a/otel/src/main/scala/ox/telemetry/AdaptiveOxTracing.scala +++ /dev/null @@ -1,29 +0,0 @@ -//package ox.telemetry -// -//import io.opentelemetry.api.common.Attributes -//import io.opentelemetry.api.trace.* -//import ox.flow.Flow -//import ox.telemetry.tracing.OxTracingByName -//import ox.telemetry.tracing.flow.{OxFlowTracing, OxForkTracing} -//import ox.telemetry.tracing.internal.OxTracingBase -// -//import scala.compiletime.erasedValue -// -////final class AdaptiveOxTracing(base: OxTracingBase,oxTracingByName: OxTracingByName, oxFlowTracing: OxFlowTracing,oxForkTracing: OxForkTracing): -//// export oxTracingByName.{span as _, *} -//// export oxFlowTracing.{span as _, *} -//// export oxForkTracing.{spanFork as _, *} -//// export base.{span as _, *} -//// extension [T](t: => T) -//// inline def span(spanName: String, -//// spanKind: SpanKind = SpanKind.INTERNAL, -//// attributes: Attributes = Attributes.empty(), -//// links: Seq[SpanContext] = Seq.empty, -//// beforeClose: ((Span, Option[Throwable])) => Span = _._1 -//// ): T = -//// inline erasedValue[T] match -//// case _: Flow[t] => oxFlowTracing.span(t.asInstanceOf[Flow[t]])(spanName, spanKind, attributes, links, beforeClose).asInstanceOf[T] -//// case _ => oxTracingByName.byNameSpan(t)(spanName, spanKind, attributes, links, beforeClose) match { -//// case itWasFlow: Flow[?] => System.err.println("AdaptiveOxTracing improperly applied to a flow"); itWasFlow -//// case ok => ok -//// } \ No newline at end of file From 5a5eeda266928995e0026fb8d913ffaabb4e57c1 Mon Sep 17 00:00:00 2001 From: Pawel Sadlo Date: Fri, 9 Oct 2026 08:48:13 +0200 Subject: [PATCH 05/38] ... --- otel/src/main/scala/ox/flow/otel/CFlow.scala | 74 --- .../main/scala/ox/flow/otel/OtelFlow.scala | 448 ------------------ .../src/main/scala/ox/flow/otel/Tarcing.scala | 78 --- 3 files changed, 600 deletions(-) delete mode 100644 otel/src/main/scala/ox/flow/otel/CFlow.scala delete mode 100644 otel/src/main/scala/ox/flow/otel/OtelFlow.scala delete mode 100644 otel/src/main/scala/ox/flow/otel/Tarcing.scala diff --git a/otel/src/main/scala/ox/flow/otel/CFlow.scala b/otel/src/main/scala/ox/flow/otel/CFlow.scala deleted file mode 100644 index 13a47135..00000000 --- a/otel/src/main/scala/ox/flow/otel/CFlow.scala +++ /dev/null @@ -1,74 +0,0 @@ -package ox.flow.otel - -import ox.flow.Flow - - -//opaque type CFlow[T <: (?, ?)] = Flow[T] -// -//case class Ctx[C, T](c: C, t: T) -// -//trait CtxMaker[C, T]: -// type C1 -// type T1 -// -// def make(c: C, t: T): Ctx[C1, T1] -// -//object CtxMaker extends LowPrio: -// type Aux[C, T, CC, TT] = CtxMaker[C, T] {type C1 = CC; type T1 = TT} -// -// given [C, T, C2]: CtxMaker.Aux[C, Ctx[C2, T], C2, T] = new CtxMaker[C, Ctx[C2, T]] { -// override type C1 = C2 -// override type T1 = T -// -// override def make(c: C1, t: Ctx[C2, T]): Ctx[C2, T] = Ctx(t.c, t.t) -// } -// -// -//trait LowPrio: -// given [C, T]: CtxMaker.Aux[C, T, C, T] = new CtxMaker[C, T] { -// override type C1 = C -// override type T1 = T -// -// override def make(c: C1, t: T): Ctx[C1, T] = Ctx(c, t) -// } -// -//trait CtxAccess[C](c: C): -// def get: C -// -// extension [T](t: T) -// def withContext[CC](cc: CC): Ctx[CC, T] = Ctx(cc, t) -// -// def use[T](f: C => T): T -// -//extension (c: CtxAccess.type) -// def use[T, C, U](f: C => T => U): CtxAccess[C] => T => U = ??? -// -// -//trait ContextPush[U] -// -////object ContextPush: -//// given [C, T]: ContextPush[(Ctx[C], T)] = ??? -// -//object CtxAccess: -// def apply[C](c: C) = new CtxAccess[C](c) {} -// -// -//object CFlow: -// extension [C, T](cflow: CFlow[(C, T)]) -// def value: Flow[(C, T)] = cflow -// inline def map[U, R](inline f: CtxAccess[C] ?=> T => U)(using cm: CtxMaker[C, U]): CFlow[(cm.C1, cm.T1)] = ??? -// -// -// def fromFlow[CT <: (?, ?)](flow: Flow[CT]): CFlow[CT] = flow -// -// given t[C, T](using C): (C, T) = ??? -// -//trait Foo: -// def pp: Unit = ??? -// -//object T: -// val x: CFlow[(Foo, Int)] = ??? -// val xx = x.map: ctx ?=> -// ctx.use: c => -// c.pp -// tt => tt + 1 diff --git a/otel/src/main/scala/ox/flow/otel/OtelFlow.scala b/otel/src/main/scala/ox/flow/otel/OtelFlow.scala deleted file mode 100644 index 6fb78f1f..00000000 --- a/otel/src/main/scala/ox/flow/otel/OtelFlow.scala +++ /dev/null @@ -1,448 +0,0 @@ -package ox.flow.otel - -import io.opentelemetry.api.common.Attributes -import io.opentelemetry.api.trace.* -import io.opentelemetry.context.Context -import ox.flow.Flow -import ox.otel.context.PropagatingVirtualThreadFactory - -import scala.compiletime.erasedValue - - -//class TracedFlow[-Env, +T](env: ForkLocal[Env], last: FlowStage[T]) extends Flow[T](last): -// override def flatMap[Env1 <: Env, U](f: T => TracedFlow[Env1, U]): TracedFlow[Env1, U] = TracedFlow(env, super.flatMap[U](f).last) -// -// def tapEnv(f: Env => Unit): TracedFlow[Env, T] = TracedFlow(env, super.tap(_ => f(env.get())).last) -// -//object TracedFlow: -// def apply[Env, T](env: Env)(flow: Flow[T]) = TracedFlow(ForkLocal.apply(env), flow.last) -//trait StartedSpan[T]: -// def endSpan(f: Span => Unit): T - -//trait OxTelemetry: -// protected def contextHolder: ContextHolder - - - - -trait OxMetrics - - -//trait TracingOps: -// self: Tracing => - -// def traceWith(fun: Flow[T] => Flow[T]): Flow[T] = Flow.usingEmitInline: emit => -// fun(flow).runToEmit(emit) - - -//trait FlowTracingOps extends TracingOps - - -// def mapSpanned[U](f: T => U)(startSpan: Tracer => Span, endSpan: Span => Unit): Flow[U] = -// Flow.usingEmitInline: emit => -// flow.runToEmit(FlowEmit.fromInline { t => -// val span = startSpan(tracer) -// emit(f(t)) -// endSpan(span) -// }) - - -// -//sealed class TracingAccess(): -// def foo = ??? -// -// extension [T](f: T) -// def prependSpan: T = -// f -// -//// extension (o:Opened[T]) -//// def close(f: Span => Unit) : T -// -//extension [T](flow: Flow[T]) -// def usingCtx[C, U](c: C)(f: C ?=> Flow[T] => Flow[U]): Flow[U] = Flow.usingEmitInline: emit => -// f(using c)(flow).runToEmit(emit) -// -//val flow: Flow[Int] = ??? - -//class FlowTracing(tracing: Tracer): -// extension [T](flow: Flow[T]) : - -//val flowTracing: FlowTracing -//(???) -//given TracingAccess = ??? -//val tt = 1.prependSpan -//val x = foo - -// -//class traced(val tr: Tracer): -// def apply[T](usage: traced ?=> T): T = usage(using this) -// -// extension [T](flow: Flow[T]) -// def traceWith(fun: TracingAccess ?=> Flow[T] => Flow[T]): Flow[T] = Flow.usingEmitInline: emit => -// val ta = new TracingAccess() -// fun(using ta)(flow).runToEmit(emit) -// -// -//def t(using traced) = -// val f: Flow[Int] = ??? -// f.traceWith() - -// extension [T](extended: Flow[T]) -//extension[T](f:Flow[T]) -// def spanInterface(using traced)(usage: SpanInterface[Flow[T]] => Flow[T]):Flow[T] = -// usage(new SpanInterface[Flow[T]](f) { -// override def prependWith(f: Tracer => Span): Opened[Flow[Flow[T]]] = ??? -// }) -// extension (o: Opened[T]) -// override def close(f: Span => Unit): T = ??? -// } - -// def currentSpan: Span = Span.current() -// def spanBuilder(name:String) = tracer.spanBuilder(name) - - -// extension [T](flow: Flow[T]) -// def spanAccess(usage: SpanAccess[flow.type] ?=> Flow[T]): Flow[T] = usage(using new SpanAccess[flow.type] { -// extension (t: flow.type) -// override def prependWith(start: Tracer => Span):Flow[T] = Flow.usingEmit[T](emit => -// val span = start(tracer) -// flow.runToEmit(emit) -// ) -// } -// ) -// def prependSpan(start: Tracer => Span): SpanAccess[flow.type] ?=> StartedSpan[Flow[T]] = (s: SpanAccess[flow.type]) ?=> new StartedSpan: -// def endSpan(endSpan: Span => Unit): Flow[T] = Flow.usingEmit[T](emit => -// val span = start(tracer) -// flow.runToEmit(emit) -// endSpan(span) -// ) - -// extension [T](t: T) -// def startSpan(startSpan:Tracer => Span, endSpan:Span => Unit): T = ??? - - -//object Testing: -// val tracer2: Tracer = ??? -// val tracer: Tracer = ??? -// val tracedWithMyTracer: Tracing = Tracing(tracer) -// val f: Flow[Int] = ??? -// val f2 = tracedWithMyTracer: -// val fff = f.map(x => (x + 1)).traceWith: c1 ?=> -// flow => -// traced(tracer2): -// flow.traceWith: c ?=> -// f => -// f.prependSpan -// -// Flow.fromValues(3, 4, 3) ++ fff - - -// st.endSpan(_.end()) - - -// def attachContext[C](contextInit: => C): Flow[C ?=> T] = -// Flow.usingEmitInline[C ?=> T]: emit => -// val f: C ?=> T = -// flow.runToEmit(FlowEmit.fromInline { t => -// val f = emit.apply(using ctx) -// -// -// }) - - -// -//opaque type ContextFlow[+C, +T] = Flow[(C, T)] -// -//object ContextFlow: -// def fromFlow[C, T](flow: Flow[(C, T)]): ContextFlow[C, T] = flow -// -// extension [C, T](c: ContextFlow[C, T]) -// def toFlow: Flow[(C, T)] = c -// -// def map[U](f: T => U): ContextFlow[C, U] = fromFlow(toFlow.map((c, x) => (c, f(x)))) -// -// def filter(f: T => Boolean): ContextFlow[C, T] = fromFlow(toFlow.filter((c, x) => f(x))) -// -// def mapContext[C1](f: C => C1): ContextFlow[C1, T] = fromFlow(toFlow.map((c, x) => (f(c), x))) -// -// def tapContext(f: C => Unit): ContextFlow[C, T] = fromFlow(toFlow.tap(x => f(x._1))) -// -// /** Todo - this is questionable, because we are using Flow in mapping fun, yet scala for-comprehension is working*/ -// def flatMap[B](f: T => Flow[B]): ContextFlow[C, B] = fromFlow(toFlow.flatMap((c, t) => f(t).map(c -> _))) -// -// -//extension [T](flow: Flow[T]) -// def withContext[C](context: C): ContextFlow[C, T] = ContextFlow.fromFlow(flow.map(context -> _)) -// def extractContext[C](f: T => C): ContextFlow[C, T] = ContextFlow.fromFlow(flow.map(x => f(x) -> x)) -// -// -//object Usage: -// val flow: Flow[Int] = Flow.fromValues(1, 2, 3) -// val tracer: Tracer = ??? -// val meter:Meter = ??? -// val flowWithContext: ContextFlow[Tracer, Int] = flow.withContext(tracer) -// val withStartedSpan: ContextFlow[(Tracer, Span), Int] = flowWithContext.mapContext(t => (t, t.spanBuilder("").startSpan())) -// val operatingOnElements: ContextFlow[(Tracer, Span), Int] = withStartedSpan.map(_ + 1) -// -// val endedSpan: ContextFlow[Tracer, Int] = operatingOnElements.mapContext { (t, s) => s.end(); t } -// -// val moreSpans: ContextFlow[(Tracer, List[Span]), Int] = withStartedSpan.mapContext { (t, s) => (t, t.spanBuilder("newSpan").startSpan() :: s :: Nil) } -// -// val flow2: Flow[String] = Flow.fromValues("one", "two") -// -// val questionable: ContextFlow[(Tracer, List[Span]), Int] = for { -// s <- moreSpans -// s2 <- flow -// s3 <- flow2 -// } yield s + s3.length -// -// val backToFlow: Flow[((Tracer, List[Span]), Int)] = moreSpans.toFlow -// -// val withMeter: ContextFlow[(Meter, Tracer, List[Span]), Int] = moreSpans.mapContext((t, s) => (meter, t, s)) -// -// val withCounter: ContextFlow[(Meter, Tracer, List[Span], LongCounter), Int] = withMeter.mapContext((m, t, s) => (m, t, s, m.counterBuilder("s").build())) -// -// val counted: ContextFlow[(Meter, Tracer, List[Span], LongCounter), Int] = withCounter.tapContext(_._4.add(1)) -// -// -// val sink = Channel.rendezvous[ObservableLongMeasurement] -// -// val subscribed: ObservableLongCounter = meter.counterBuilder("i will subscribe to this").buildWithCallback(sink.send) -// -// val flowConected: Flow[ObservableLongMeasurement] = Flow.usingEmit[ObservableLongMeasurement]: emit => -// FlowEmit.channelToEmit(sink, emit) -// -// -// -// - - -// -//type MeteredFlow[T] = ContextFlow[Meter, T] -////type TracedFlow[S<:TracingStack[S],T] = ContextFlow[TracingStack[S], T] -// -//opaque type TracedF[T] = Tracer => Flow[T] -//opaque type MeteredF[T] = Meter => Flow[T] -// -//class EnvFlow[-E, +T](val wrapped: E ?=> Flow[T]) -// -//object EnvFlow: -// def apply[E, T](f: E ?=> Flow[T]): EnvFlow[E, T] = new EnvFlow(f) -// -//val x: EnvFlow[Tracer ?=> Meter, Int] = ??? -// -////extension [G <: ContextFunction1[?, ?]](tupled: G) -//// def untuple[F](using tf: TupledFunction[F, G]): F = tf.untupled(tupled) -// -//extension [A, T](f: ContextFunction1[A, T]) -// def toFun: (A) => T = (a: A) => f(using a) -// -// -//extension [E, T](ef: EnvFlow[E, T]) -// def map[U](f: T => U): EnvFlow[E, U] = { -// EnvFlow(E ?=> ef.wrapped.map(f)) -// def requireEnv[Env]: EnvFlow[(Env, E), T] = -// type CT = ContextFunction1[ContextFunction1[E, Env], T] - -// val inner: ContextFunction1[Env, E ?=> Flow[T]] = (e1: Env) ?=> (e: E) ?=> ef.wrapped(using e) -// val innerrr = toFun(inner) -// val inner2: ContextFunction2[Env, E, Flow[T]] = inner -// val inner2Tupled: ContextFunction1[(Env, E), Flow[T]] = (t: (Env, E)) ?=> inner2(using t._1, t._2) -// val inner2UnTupled: ContextFunction1[Env, E ?=> Flow[T]] = (e: Env) ?=> (e1: E) ?=> inner2Tupled(using (e, e1)) -//// val inner2UnTupledGen = untuple(inner2Tupled) -// val inner2UnTupled2: ContextFunction2[Env, E, Flow[T]] = inner2UnTupled - -// val inner3: ContextFunction1[Env ?=> E, Flow[T]] = (e1: Env) ?=> (e2: E) ?=> inner2(using e1, e2) -// -// ??? -// } -// -// -//case class Enved[EnvedT](e: EnvedT) -// -//trait LeafMapper[From, A]: -// type To[_] -// -// def map[B](from: From)(f: A => B): To[B] -// -//object LeafMapper extends LowPrio: -// type Aux[From, A, ToT[_]] = LeafMapper[From, A] {type To[t] = ToT[t]} -// -// def map[From, A, B](from: From)(f: A => B)(using m: LeafMapper[From, A]): m.To[B] = m.map(from)(f) -// // extension [From,A](from:From) -// // def map[B](f:A => B)(using m:LeafMapper.Aux[From,A,B]):m.To[B] = m.map(from)(f) -// -// given forEnved[From <: ContextFunction1[?, ?], A, ToT[_]](using delegate: LeafMapper.Aux[From, A, ToT]): LeafMapper[Enved[From], A] with -// type To[b] = Enved[ToT[b]] -// -// override def map[B](from: Enved[From])(f: A => B): To[B] = Enved(delegate.map(from.e)(f)) -// -// -// given rec[Env, Inner, A, ToT[_]](using rec: LeafMapper.Aux[Inner, A, ToT]): LeafMapper[ContextFunction1[Env, Inner], A] with -// -// override type To[b] = Env ?=> ToT[b] -// -// override def map[B](from: Env ?=> Inner)(f: A => B): To[B] = (env: Env) ?=> rec.map(from)(f) -// -//trait LowPrio: -// given last[Env, A](using NotGiven[A <:< ContextFunction1[?, ?]]): LeafMapper[ContextFunction1[Env, A], A] with -// -// override type To[b] = Env ?=> b -// -// override def map[B](from: Env ?=> A)(f: A => B): To[B] = Env ?=> f(from) -// -//import ox.otel.LeafMapper.map -// -//val mapped: Int ?=> String ?=> Int = LeafMapper.map((i: Int) ?=> (s: String) ?=> List(2, 3, 4))(_.size) -//val e: Enved[Int ?=> String ?=> List[Int]] = ??? -//val res = LeafMapper.map(e)(_.size) -// -//// -//// override def map[B](f: Elem => B): Ret = -// -//trait EnvWrapper[Repr, T, LiftedRepr[+_]]: -// type LiftedReprT[+t] = LiftedRepr[t] -// -// def wrap(x: Repr): Enved[T, LiftedRepr] -// -//object EnvWrapper extends LowPrio: -// given rec[A, B, IT, IW[+_]](using inner: EnvWrapper[B, IT, IW]): EnvWrapper[ContextFunction1[A, B], IT, [t] =>> A ?=> IW[t]] with -// self => -// override def wrap(x: A ?=> B): Enved[IT, this.LiftedReprT] = new Enved[IT, self.LiftedReprT]: -// override def unwrap: self.LiftedReprT[IT] = A ?=> inner.wrap(x).unwrap -// -//trait LowPrio: -// inline given last[A, B](using NotGiven[B <:< ContextFunction1[?, ?]]): EnvWrapper[ContextFunction1[A, B],B,[t] =>> A ?=> t] with -// self => -// override def wrap(x: A ?=> B): Enved[B, self.LiftedReprT] = new Enved[B, self.LiftedReprT]: -// override def unwrap: self.LiftedReprT[B] = A ?=> x -// -////trait EnvUnwrapper[From, To]: -//// def unwrap(x: From): To -//// -////object EnvUnwrapper extends LowPrio: -//// given rec[A, B, IT, IW[+_]](using inner: EnvUnwrapper[B, IT, IW]): EnvUnwrapper[ContextFunction1[A, B], IT, [t] =>> A ?=> IW[t]] with -//// self => -//// override def unwrap(x: A ?=> B): Enved[IT, this.LiftedReprT] = new Enved[IT, self.LiftedReprT]: -//// override def unwrap: self.LiftedReprT[IT] = A ?=> inner.wrap(x).unwrap -//// -////trait LowPrio: -//// inline given last[A, B](using NotGiven[B <:< ContextFunction1[?, ?]]): EnvUnwrapper[ContextFunction1[A, B],B,[t] =>> A ?=> t] with -//// self => -//// override def unwrap(x: A ?=> B): Enved[B, self.LiftedReprT] = new Enved[B, self.LiftedReprT]: -//// override def unwrap: self.LiftedReprT[B] = A ?=> x -// -// -//object Enved: -// def wrap[A, B, T, W[+_]](f: ContextFunction1[A, B])(using e: EnvWrapper[ContextFunction1[A, B], T, W]): Enved[T, W] = e.wrap(f) -// -// extension [T](t: T) -// def requireEnv[Env]: Enved[T, [t] =>> Env ?=> t] = new Enved: -// override def unwrap: Env ?=> T = (e: Env) ?=> t -// -// extension [T, WO[+_] <: ContextFunction1[?, ?]](tt: Enved[T, WO]) -// def requireEnv[Env]: Enved[T, [t] =>> Env ?=> WO[t]] = new Enved[T, [t] =>> Env ?=> WO[t]]: -// override def unwrap: Env ?=> WO[T] = (e: Env) ?=> tt.unwrap -// -//// extension [T,Repr[+_]](enved:Enved[T,Repr]) -//// def useEnv[Env,U](f: Env ?=> T ?=> U):U = -// -//// extension [T, W[+_]](enved: Enved[T, W]) -//// def map[U](t: T => U)(using Unwrap) = -//// enved.unwrap -// -// -//trait EnvProvider[Env, T]: -// type Ret -// -// def provide(env: Env, enved: T): Ret -// -////[t] =>> ContextFunction1[Env,t]] -//object EnvProvider: -// given last[T, Env, W[+t] <: ContextFunction1[Env, t]]: EnvProvider[Env, Enved[T, W]] with { -// type Ret = T -// -// override def provide(env: Env, enved: Enved[T, W]): T = -// enved.unwrap(using env) -// } -// -// extension [T, WO[+_] <: ContextFunction1[?, ?]](tt: Enved[T, WO]) -// def provide[Env](env: Env)(using provider: EnvProvider[Env, Enved[T, WO]]): provider.Ret = provider.provide(env, tt) -// -// -//import ox.otel.Enved.requireEnv -// -////val r = 1.enved -//val ss: String ?=> Tracer ?=> Int = 1.requireEnv[Tracer].unwrap -//val wrp: Enved[String, [T] =>> Int ?=> String ?=> T] = Enved.wrap((x: Int) ?=> (y: String) ?=> "") -////wrp.using: -// -// -//def fun[T,C](enved:Enved[T,C]) = ??? -// -////def insideOut[A, B, C](f: A => B => C): B => A => C = (b: B) => (a: A) => f(a)(b) -// -////val e2 = x.map(_ + 1) -// -////given ttt:Tracer = ??? -////given iii:Meter = ??? -////val r: Flow[Int] = e2.wrapped -//// -////val sss: Flow[Int] = ??? -// -// -//// -// -////trait SpanStack[Depth<:Int]: -//// def pop:Span -//// -////class SpanStack -//// -////extension (last: SpanStack[1]) -//// def end(): = last.pop.end() -//// -//// -// -// -//// override def map[U](f: T => U): TracingStack[U] = ??? -// -// -////extension [T<:TracingStack[T]](stack:TracingStack[T]) -//// def elem:T = stack.elem -////extension [T](stack:TracingStack[T]) -//// def elem:T = stack.elem -// -// -//// def elem:T -// -// -////sealed trait TracingStack[T <: TracingStack[T]]: -//// def tracer: Tracer -//// protected def scope:Scope -// -// -////object TracingStack: -//// private final class Root(val tracer: Tracer) extends TracingStack[Nothing]: -//// override protected def scope: Scope = ??? -// -// -////, T -////] -////// -//// -// -// - -// -//////extension (span: SpanBuilder) -////// def start(): Span = -//// println("starting") -// span.startSpan() -// -//extension [T](flow: Flow[(Tracer, T)]) -// def startSpan(name: String): Flow[(Span, T)] = flow.map { (tracer, elem) => -// tracer.spanBuilder(name).startSpan() -> elem -// } -// def map[U](f: T => U): Flow[(Tracer, U)] = flow.map((t, x) => (t, (f(x)))) - - diff --git a/otel/src/main/scala/ox/flow/otel/Tarcing.scala b/otel/src/main/scala/ox/flow/otel/Tarcing.scala deleted file mode 100644 index 40e0614d..00000000 --- a/otel/src/main/scala/ox/flow/otel/Tarcing.scala +++ /dev/null @@ -1,78 +0,0 @@ -//package ox.flow.otel -// -//import ox.flow.Flow -// -////object Ctxing extends App: -// - -// given cFromCtx [C](using x:Ctx[C]):C = x.get - -// def usingCtx[C,T,U](using c:CtxAccess[C])(f: C => T => U): T => U = f(c) -// -// -// val fl:Flow[String ?=> Int] = null -// -// val cf : CFlow[String ?=> Int]= CFlow.fromFlow(fl) -// import CFlow.map -// val cfm:CFlow[String ?=> Int] = cf.map( _ + 1) -// -// -// -// val cfm2:CFlow[String ?=> List[Int]] = cf.map: -// usingCtx: ctx => -// ctx.charAt(0) -// List.apply(_) - - -//package ox.otel -// -//import io.opentelemetry.api.trace.Tracer -// -//object Tarcing { -// trait TracingStack[T]: -// def tracer: Tracer -// -// def elem: T -// -// def startSpan(): TracingStack[TracingStack[T]] -// -// def endSpan(): T -// -// object TracingStack: -// trait ElemObtain[T]: -// type Ret -// -// def obtain(x: T): Ret -// -// object ElemObtain extends ElemObtainLowPrio: -// extension[T](t:TracingStack[T]) -// def elemObtain(using ob:ElemObtain[TracingStack[T]]):ob.Ret = ob.obtain(t) -// -// given [R,T <: TracingStack[R]](using rec: ElemObtain[T] {type Ret = R}): ElemObtain[TracingStack[T]] with -// type Ret = R -// -// override def obtain(x: TracingStack[T]): R = rec.obtain(x.elem) -// trait ElemObtainLowPrio: -// given last[T]: ElemObtain[TracingStack[T]] with -// type Ret = T -// -// override def obtain(x: TracingStack[T]): T = x.elem -// -// -// -// class Root[T](val tracer: Tracer, val elem: T) extends TracingStack[T]: -// self => -// def startSpan(): TracingStack[TracingStack[T]] = new TracingStack[TracingStack[T]] { -// override def tracer: Tracer = self.tracer -// -// override def elem: T = self.elem -// } -// -// override def endSpan(): T = self.elem -// -// import ox.otel.Tarcing.TracingStack.ElemObtain.elemObtain -// @main -// def m = -// val root = TracingStack.Root(null, 2) -// val elem = root.startSpan().startSpan().startSpan().startSpan().endSpan().endSpan().endSpan().endSpan().endSpan() -//} From 98233e3bacfc404df983d2fede34fa1023847709 Mon Sep 17 00:00:00 2001 From: Pawel Sadlo Date: Fri, 9 Oct 2026 08:48:51 +0200 Subject: [PATCH 06/38] ... --- otel/src/main/scala/ox/telemetry/tracing/TracingStrategy.scala | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 otel/src/main/scala/ox/telemetry/tracing/TracingStrategy.scala diff --git a/otel/src/main/scala/ox/telemetry/tracing/TracingStrategy.scala b/otel/src/main/scala/ox/telemetry/tracing/TracingStrategy.scala deleted file mode 100644 index e69de29b..00000000 From fe58a7248836e51ae0fd0592b6e419f7f9231881 Mon Sep 17 00:00:00 2001 From: Pawel Sadlo Date: Fri, 9 Oct 2026 08:51:48 +0200 Subject: [PATCH 07/38] ... --- build.sbt | 5 +- core/src/main/scala/ox/computeIntensive.scala | 109 +- core/src/main/scala/ox/flow/Flow.scala | 2 + core/src/main/scala/ox/flow/FlowOps.scala | 1087 ++++++++--------- core/src/main/scala/ox/oxThreadFactory.scala | 4 +- generated-doc/out/utils/oxapp.md | 3 +- .../ox/telemetry/tracing/OxTracing.scala | 118 +- .../{OtelTest.scala => OxTelemetryTest.scala} | 4 +- otel/src/test/scala/ox/kafka/AppTest.scala | 33 - 9 files changed, 700 insertions(+), 665 deletions(-) rename otel/src/test/scala/{OtelTest.scala => OxTelemetryTest.scala} (99%) delete mode 100644 otel/src/test/scala/ox/kafka/AppTest.scala diff --git a/build.sbt b/build.sbt index 39752907..5532b390 100644 --- a/build.sbt +++ b/build.sbt @@ -1,5 +1,5 @@ -import com.softwaremill.Publish.{ossPublishSettings, updateDocs} import com.softwaremill.SbtSoftwareMillCommon.commonSmlBuildSettings +import com.softwaremill.Publish.{ossPublishSettings, updateDocs} import com.softwaremill.UpdateVersionInDocs import com.typesafe.tools.mima.core.{MissingClassProblem, ProblemFilters} @@ -136,9 +136,8 @@ lazy val otelContext: Project = (project in file("otel-context")) .dependsOn(core % "test->test;compile->compile") lazy val otel: Project = (project in file("otel")) - .settings(commonSettings) .settings( - name := "otel", + name := "ox-telemetry", Test / fork := true, Test / javaOptions += "-Dio.opentelemetry.context.enableStrictContext=true", libraryDependencies ++= Seq( diff --git a/core/src/main/scala/ox/computeIntensive.scala b/core/src/main/scala/ox/computeIntensive.scala index d9a482a2..356496dc 100644 --- a/core/src/main/scala/ox/computeIntensive.scala +++ b/core/src/main/scala/ox/computeIntensive.scala @@ -1,6 +1,10 @@ package ox -import java.util.concurrent.* +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ExecutionException +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.ThreadFactory import java.util.concurrent.atomic.AtomicInteger // @@ -10,11 +14,11 @@ import java.util.concurrent.atomic.AtomicInteger private var customComputeExecutor: ExecutorService = _ /** Sets the executor used to run computations passed to [[computeIntensive]]. Should be called once, at the start of the application, - * before any [[computeIntensive]] calls; the executor's lifecycle (shutdown) is then the responsibility of the caller. - * - * @see - * [[oxComputeExecutor]] - */ + * before any [[computeIntensive]] calls; the executor's lifecycle (shutdown) is then the responsibility of the caller. + * + * @see + * [[oxComputeExecutor]] + */ def setOxComputeExecutor(executor: ExecutorService): Unit = customComputeExecutor = executor // forcing the lazy val: if it wasn't yet initialized, it is now initialized to the executor just set above (so the check passes); @@ -23,28 +27,25 @@ def setOxComputeExecutor(executor: ExecutorService): Unit = throw new RuntimeException("The compute executor was already used before setting a custom one!") /** The executor which is used to run computations passed to [[computeIntensive]]. By default, a fixed pool of - * `Runtime.getRuntime.availableProcessors()` daemon platform threads, named `ox-compute-N`, created lazily on first use. Platform threads - * are preempted by the OS, hence CPU-intensive computations running on this executor can't monopolize the virtual thread scheduler's - * carrier threads. - * - * A custom executor should be set once at the start of the application, before any [[computeIntensive]] calls, using - * [[setOxComputeExecutor]]; its lifecycle (shutdown) is then the responsibility of the caller. - * - * @see - * [[OxApp.Settings]] - */ - -private def defaultComputeIntensiveExecutor = - val counter = new AtomicInteger(0) - val threadFactory: ThreadFactory = r => - val t = new Thread(r, s"ox-compute-${counter.getAndIncrement()}") - t.setDaemon(true) - t - Executors.newFixedThreadPool(Runtime.getRuntime.availableProcessors(), threadFactory) - + * `Runtime.getRuntime.availableProcessors()` daemon platform threads, named `ox-compute-N`, created lazily on first use. Platform threads + * are preempted by the OS, hence CPU-intensive computations running on this executor can't monopolize the virtual thread scheduler's + * carrier threads. + * + * A custom executor should be set once at the start of the application, before any [[computeIntensive]] calls, using + * [[setOxComputeExecutor]]; its lifecycle (shutdown) is then the responsibility of the caller. + * + * @see + * [[OxApp.Settings]] + */ lazy val oxComputeExecutor: ExecutorService = val custom = customComputeExecutor - if custom == null then defaultComputeIntensiveExecutor + if custom == null then + val counter = new AtomicInteger(0) + val threadFactory: ThreadFactory = r => + val t = new Thread(r, s"ox-compute-${counter.getAndIncrement()}") + t.setDaemon(true) + t + Executors.newFixedThreadPool(Runtime.getRuntime.availableProcessors(), threadFactory) else custom end if end oxComputeExecutor @@ -63,35 +64,35 @@ private val currentComputeExecutor = new ThreadLocal[ExecutorService]() // /** Runs `f` on the compute-intensive executor ([[oxComputeExecutor]]), blocking the calling (virtual) thread until it completes. Returns - * the result of `f`, or rethrows the exception with which it failed. - * - * Use for long-running, CPU-intensive computations: virtual threads are not preempted, so such a computation, if run directly in a fork, - * would monopolize a carrier thread of the virtual thread scheduler, potentially starving other virtual threads. The computation is - * instead run on a pool of platform threads (by default sized to the number of available processors), which the OS schedules preemptively. - * For short computation bursts, which can be instrumented with periodic yields, see [[cede]] as a lighter-weight alternative. - * - * As the calling thread blocks until the computation completes, the computation never outlives the enclosing concurrency scope (if any): - * usage remains structured. To evaluate `f` in parallel with other code, combine with a fork, e.g. `fork(computeIntensive(f))`. - * - * If the calling thread is interrupted (e.g. because the enclosing scope ends), the thread running the computation becomes interrupted as - * well, and the call keeps waiting until the computation completes. The computation can co-operate in the cancellation protocol using - * [[checkInterrupt]] or [[cede]]. If the computation hasn't yet started when the interruption occurs, it will never run. - * - * The scope context is not propagated to the computation: [[ForkLocal]]s read their default values, and forks can't be created within `f` - * (this fails with an [[IllegalStateException]]). - * - * @throws InterruptedException - * if the current thread is interrupted, either on entry, or while waiting for the computation to complete. - */ + * the result of `f`, or rethrows the exception with which it failed. + * + * Use for long-running, CPU-intensive computations: virtual threads are not preempted, so such a computation, if run directly in a fork, + * would monopolize a carrier thread of the virtual thread scheduler, potentially starving other virtual threads. The computation is + * instead run on a pool of platform threads (by default sized to the number of available processors), which the OS schedules preemptively. + * For short computation bursts, which can be instrumented with periodic yields, see [[cede]] as a lighter-weight alternative. + * + * As the calling thread blocks until the computation completes, the computation never outlives the enclosing concurrency scope (if any): + * usage remains structured. To evaluate `f` in parallel with other code, combine with a fork, e.g. `fork(computeIntensive(f))`. + * + * If the calling thread is interrupted (e.g. because the enclosing scope ends), the thread running the computation becomes interrupted as + * well, and the call keeps waiting until the computation completes. The computation can co-operate in the cancellation protocol using + * [[checkInterrupt]] or [[cede]]. If the computation hasn't yet started when the interruption occurs, it will never run. + * + * The scope context is not propagated to the computation: [[ForkLocal]]s read their default values, and forks can't be created within `f` + * (this fails with an [[IllegalStateException]]). + * + * @throws InterruptedException + * if the current thread is interrupted, either on entry, or while waiting for the computation to complete. + */ def computeIntensive[T](f: => T): T = computeIntensive(oxComputeExecutor)(f) /** As [[computeIntensive]], but runs `f` on the given `executor`, instead of the default [[oxComputeExecutor]]. - * - * Nested `computeIntensive` calls targeting the same executor (compared by reference) run inline, avoiding a deadlock, which could - * otherwise occur when all threads of a fixed-size pool block on nested tasks, queued behind them. Note that wrapping/decorating an - * executor defeats this detection: nested calls through a wrapper of the current executor are submitted normally, and can deadlock a - * fixed-size pool. - */ + * + * Nested `computeIntensive` calls targeting the same executor (compared by reference) run inline, avoiding a deadlock, which could + * otherwise occur when all threads of a fixed-size pool block on nested tasks, queued behind them. Note that wrapping/decorating an + * executor defeats this detection: nested calls through a wrapper of the current executor are submitted normally, and can deadlock a + * fixed-size pool. + */ def computeIntensive[T](executor: ExecutorService)(f: => T): T = checkInterrupt() if currentComputeExecutor.get() eq executor then f @@ -117,7 +118,7 @@ private class ComputeIntensiveTask[T](executor: ExecutorService, f: () => T): // a direct InterruptedException means the caller was interrupted while waiting; a task-thrown exception // (including a task-thrown InterruptedException) arrives wrapped in an ExecutionException, and is unwrapped below case e: InterruptedException => onCallerInterrupted(e) - case e: ExecutionException => throw causeWithSelfAsSuppressed(e) + case e: ExecutionException => throw causeWithSelfAsSuppressed(e) end submitAndAwait private def run(): Unit = @@ -170,7 +171,7 @@ private class ComputeIntensiveTask[T](executor: ExecutorService, f: () => T): done = true catch case e2: InterruptedException => e.addSuppressed(e2) - case e2: ExecutionException => + case e2: ExecutionException => e.addSuppressed(e2.getCause) // the task's exception; never `e` itself, as that comes from the caller's `get()` done = true end while diff --git a/core/src/main/scala/ox/flow/Flow.scala b/core/src/main/scala/ox/flow/Flow.scala index 762610f7..0591ac53 100644 --- a/core/src/main/scala/ox/flow/Flow.scala +++ b/core/src/main/scala/ox/flow/Flow.scala @@ -26,6 +26,8 @@ class Flow[+T](protected val last: FlowStage[T]) object Flow extends FlowCompanionOps with FlowCompanionIOOps with FlowCompanionReactiveOps +// + /** Contains the logic for running a single flow stage. As part of `run`s implementation, previous flow stages might be run, either * synchronously or asynchronously. */ diff --git a/core/src/main/scala/ox/flow/FlowOps.scala b/core/src/main/scala/ox/flow/FlowOps.scala index db6fba20..23de8a7e 100644 --- a/core/src/main/scala/ox/flow/FlowOps.scala +++ b/core/src/main/scala/ox/flow/FlowOps.scala @@ -34,12 +34,12 @@ class FlowOps[+T]: outer: Flow[T] => /** When run, the current pipeline is run asynchornously in the background, emitting elements to a buffer. The elements of the buffer are - * then emitted by the returned flow. - * - * The size of the buffer is determined by the [[BufferCapacity]] that is in scope. - * - * Any exceptions are propagated by the returned flow. - */ + * then emitted by the returned flow. + * + * The size of the buffer is determined by the [[BufferCapacity]] that is in scope. + * + * Any exceptions are propagated by the returned flow. + */ def buffer()(using BufferCapacity): Flow[T] = Flow.usingEmitInline: emit => val ch = BufferCapacity.newChannel[T] unsupervised: @@ -47,25 +47,25 @@ class FlowOps[+T]: FlowEmit.channelToEmit(ch, emit) /** Batches elements based on a weighted cost function. When downstream is slower than upstream, elements are eagerly pulled and - * aggregated. - * - * The first upstream element creates the initial aggregate via `seed`. Subsequent elements are aggregated using `aggregate` as long as - * their cumulative cost (determined by `costFn`) does not exceed `maxWeight`. When the cost would be exceeded, the current aggregate is - * emitted downstream and a new aggregate is started from the element that exceeded the budget. - * - * A single element whose cost exceeds `maxWeight` is still emitted (via `seed`), ensuring no elements are dropped. - * - * Creates an asynchronous boundary. - * - * @param maxWeight - * The maximum cumulative cost before emitting the current aggregate. - * @param costFn - * A function computing the cost of each element. Must return non-negative values. - * @param seed - * A function creating the initial aggregate from the first element. - * @param aggregate - * A function combining the current aggregate with a new element. - */ + * aggregated. + * + * The first upstream element creates the initial aggregate via `seed`. Subsequent elements are aggregated using `aggregate` as long as + * their cumulative cost (determined by `costFn`) does not exceed `maxWeight`. When the cost would be exceeded, the current aggregate is + * emitted downstream and a new aggregate is started from the element that exceeded the budget. + * + * A single element whose cost exceeds `maxWeight` is still emitted (via `seed`), ensuring no elements are dropped. + * + * Creates an asynchronous boundary. + * + * @param maxWeight + * The maximum cumulative cost before emitting the current aggregate. + * @param costFn + * A function computing the cost of each element. Must return non-negative values. + * @param seed + * A function creating the initial aggregate from the first element. + * @param aggregate + * A function combining the current aggregate with a new element. + */ def batchWeighted[S](maxWeight: Long, costFn: T => Long, seed: T => S)(aggregate: (S, T) => S)(using BufferCapacity): Flow[S] = require(maxWeight > 0, "maxWeight must be > 0") Flow.usingEmitInline: emit => @@ -149,7 +149,7 @@ class FlowOps[+T]: flushAgg(a) agg match case Some(_) => true // still have data from pending - case None => + case None => output.done() false @@ -161,66 +161,66 @@ class FlowOps[+T]: end batchWeighted /** Batches elements into groups of up to `max` elements. When downstream is slower than upstream, elements are eagerly pulled and - * aggregated using the provided `seed` and `aggregate` functions. - * - * Equivalent to `batchWeighted(max, _ => 1, seed)(aggregate)`. - * - * Creates an asynchronous boundary. - * - * @param max - * The maximum number of elements per batch. - * @param seed - * A function creating the initial aggregate from the first element. - * @param aggregate - * A function combining the current aggregate with a new element. - */ + * aggregated using the provided `seed` and `aggregate` functions. + * + * Equivalent to `batchWeighted(max, _ => 1, seed)(aggregate)`. + * + * Creates an asynchronous boundary. + * + * @param max + * The maximum number of elements per batch. + * @param seed + * A function creating the initial aggregate from the first element. + * @param aggregate + * A function combining the current aggregate with a new element. + */ def batch[S](max: Long, seed: T => S)(aggregate: (S, T) => S)(using BufferCapacity): Flow[S] = require(max > 0, "max must be > 0") batchWeighted(max, _ => 1L, seed)(aggregate) /** Conflates elements when downstream is slower than upstream, using the provided `seed` to initialize the aggregate and `aggregate` to - * combine elements. - * - * Equivalent to `batchWeighted(1, _ => 0, seed)(aggregate)` — cost is always 0 so the budget is never exceeded, resulting in unbounded - * aggregation while downstream is busy. - * - * Creates an asynchronous boundary. - * - * @param seed - * A function creating the initial aggregate from the first element. - * @param aggregate - * A function combining the current aggregate with a new element. - */ + * combine elements. + * + * Equivalent to `batchWeighted(1, _ => 0, seed)(aggregate)` — cost is always 0 so the budget is never exceeded, resulting in unbounded + * aggregation while downstream is busy. + * + * Creates an asynchronous boundary. + * + * @param seed + * A function creating the initial aggregate from the first element. + * @param aggregate + * A function combining the current aggregate with a new element. + */ def conflateWithSeed[S](seed: T => S)(aggregate: (S, T) => S)(using BufferCapacity): Flow[S] = batchWeighted(1L, _ => 0L, seed)(aggregate) /** Conflates elements when downstream is slower than upstream, using the provided `aggregate` function to combine elements. - * - * Equivalent to `conflateWithSeed(identity)(aggregate)`. - * - * Creates an asynchronous boundary. - * - * @param aggregate - * A function combining two elements. - */ + * + * Equivalent to `conflateWithSeed(identity)(aggregate)`. + * + * Creates an asynchronous boundary. + * + * @param aggregate + * A function combining two elements. + */ def conflate[T2 >: T](aggregate: (T2, T2) => T2)(using BufferCapacity): Flow[T2] = conflateWithSeed[T2](identity)(aggregate) /** Expands elements by applying the `expander` function to each upstream element, producing an iterator. When downstream is faster than - * upstream, elements from the iterator are emitted. When a new upstream element arrives, it replaces the current iterator. - * - * Note that upstream elements are not emitted directly — only the elements produced by the `expander` iterator are. To also emit the - * original element, include it in the iterator (e.g., `Iterator.single(elem) ++ ...`), or use [[extrapolate]] which does this - * automatically. - * - * When a new upstream element arrives while a send from the current iterator is pending, the pending value is discarded in favor of the - * new element's iterator. This favors freshness over completeness. - * - * Creates an asynchronous boundary. - * - * @param expander - * A function that creates an iterator from an upstream element. - */ + * upstream, elements from the iterator are emitted. When a new upstream element arrives, it replaces the current iterator. + * + * Note that upstream elements are not emitted directly — only the elements produced by the `expander` iterator are. To also emit the + * original element, include it in the iterator (e.g., `Iterator.single(elem) ++ ...`), or use [[extrapolate]] which does this + * automatically. + * + * When a new upstream element arrives while a send from the current iterator is pending, the pending value is discarded in favor of the + * new element's iterator. This favors freshness over completeness. + * + * Creates an asynchronous boundary. + * + * @param expander + * A function that creates an iterator from an upstream element. + */ def expand[U](expander: T => Iterator[U])(using BufferCapacity): Flow[U] = Flow.usingEmitInline: emit => val output = Channel.rendezvous[U] @@ -278,17 +278,17 @@ class FlowOps[+T]: end expand /** Extrapolates elements when downstream is faster than upstream. Each upstream element is first emitted as-is, then the `extrapolator` - * function is used to generate additional elements. - * - * Optionally, an `initial` element can be provided which will be emitted before the first upstream element arrives. - * - * Creates an asynchronous boundary. - * - * @param extrapolator - * A function generating extra elements from the last upstream element. - * @param initial - * An optional element to emit before any upstream element. - */ + * function is used to generate additional elements. + * + * Optionally, an `initial` element can be provided which will be emitted before the first upstream element arrives. + * + * Creates an asynchronous boundary. + * + * @param extrapolator + * A function generating extra elements from the last upstream element. + * @param initial + * An optional element to emit before any upstream element. + */ def extrapolate[U >: T](extrapolator: U => Iterator[U], initial: Option[U] = None)(using BufferCapacity): Flow[U] = val base: Flow[U] = expand[U](u => Iterator.single(u) ++ extrapolator(u)) initial.fold(base)(e => Flow.fromValues(e).concat(base)) @@ -296,38 +296,38 @@ class FlowOps[+T]: // /** Applies the given mapping function `f` to each element emitted by this flow. The returned flow then emits the results. - * - * @param f - * The mapping function. - */ + * + * @param f + * The mapping function. + */ def map[U](f: T => U): Flow[U] = Flow.usingEmitInline: emit => last.run(FlowEmit.fromInline(t => emit(f(t)))) /** Applies the given mapping function `f` to each element emitted by this flow, in sequence. The given [[FlowSink]] can be used to emit - * an arbirary number of elements. - * - * The `FlowEmit` instance provided to the `f` callback should only be used on the calling thread. That is, `FlowEmit` is thread-unsafe. - * Moreover, the instance should not be stored or captured in closures, which outlive the invocation of `f`. - * - * @param f - * The mapping function. - */ + * an arbirary number of elements. + * + * The `FlowEmit` instance provided to the `f` callback should only be used on the calling thread. That is, `FlowEmit` is thread-unsafe. + * Moreover, the instance should not be stored or captured in closures, which outlive the invocation of `f`. + * + * @param f + * The mapping function. + */ def mapUsingEmit[U](f: T => FlowEmit[U] => Unit): Flow[U] = Flow.usingEmitInline: emit => last.run(FlowEmit.fromInline(t => f(t)(emit))) /** Emits only those elements emitted by this flow, for which `f` returns `true`. - * - * @param f - * The filtering function. - */ + * + * @param f + * The filtering function. + */ def filter(f: T => Boolean): Flow[T] = Flow.usingEmitInline: emit => last.run(FlowEmit.fromInline(t => if f(t) then emit.apply(t))) /** Emits only every nth element emitted by this flow. - * - * @param n - * The interval between two emitted elements. - */ + * + * @param n + * The interval between two emitted elements. + */ def sample(n: Int): Flow[T] = Flow.usingEmitInline: emit => var sampleCounter = 0 last.run( @@ -337,15 +337,15 @@ class FlowOps[+T]: ) /** Remove subsequent, repeating elements - */ + */ def debounce: Flow[T] = debounceBy(identity) /** Remove subsequent, repeating elements matching 'f' - * - * @param f - * The function used to compare the previous and current elements - */ + * + * @param f + * The function used to compare the previous and current elements + */ def debounceBy[U](f: T => U): Flow[T] = Flow.usingEmitInline: emit => var previousElement: Option[U] = None last.run( @@ -356,22 +356,22 @@ class FlowOps[+T]: ) /** Applies the given mapping function `f` to each element emitted by this flow, for which the function is defined, and emits the result. - * If `f` is not defined at an element, the element will be skipped. - * - * @param f - * The mapping function. - */ + * If `f` is not defined at an element, the element will be skipped. + * + * @param f + * The mapping function. + */ def collect[U](f: PartialFunction[T, U]): Flow[U] = Flow.usingEmitInline: emit => last.run(FlowEmit.fromInline(t => if f.isDefinedAt(t) then emit.apply(f(t)))) /** Transforms the elements of the flow by applying an accumulation function to each element, producing a new value at each step. The - * resulting flow contains the accumulated values at each point in the original flow. - * - * @param initial - * The initial value to start the accumulation. - * @param f - * The accumulation function that is applied to each element of the flow. - */ + * resulting flow contains the accumulated values at each point in the original flow. + * + * @param initial + * The initial value to start the accumulation. + * @param f + * The accumulation function that is applied to each element of the flow. + */ def scan[V](initial: V)(f: (V, T) => V): Flow[V] = Flow.usingEmitInline: emit => emit(initial) var accumulator = initial @@ -382,21 +382,20 @@ class FlowOps[+T]: ) /** Applies the given effectful function `f` to each element emitted by this flow. The returned flow emits the elements unchanged. If `f` - * throws an exceptions, the flow fails and propagates the exception. - */ + * throws an exceptions, the flow fails and propagates the exception. + */ def tap(f: T => Unit): Flow[T] = map(t => - f(t); - t + f(t); t ) /** Applies the given mapping function `f` to each element emitted by this flow, obtaining a nested flow to run. The elements emitted by - * the nested flow are then emitted by the returned flow. - * - * The nested flows are run in sequence, that is, the next nested flow is started only after the previous one completes. - * - * @param f - * The mapping function. - */ + * the nested flow are then emitted by the returned flow. + * + * The nested flows are run in sequence, that is, the next nested flow is started only after the previous one completes. + * + * @param f + * The mapping function. + */ def flatMap[U](f: T => Flow[U]): Flow[U] = Flow.usingEmitInline: emit => last.run( FlowEmit.fromInline: t => @@ -407,15 +406,15 @@ class FlowOps[+T]: def intersperse[U >: T](inject: U): Flow[U] = intersperse(None, inject, None) /** Intersperses elements emitted by this flow with `inject` elements. The `start` element is emitted at the beginning; `end` is emitted - * after the current flow emits the last element. - * - * @param start - * An element to be emitted at the beginning. - * @param inject - * An element to be injected between the flow elements. - * @param end - * An element to be emitted at the end. - */ + * after the current flow emits the last element. + * + * @param start + * An element to be emitted at the beginning. + * @param inject + * An element to be injected between the flow elements. + * @param end + * An element to be emitted at the end. + */ def intersperse[U >: T](start: U, inject: U, end: U): Flow[U] = intersperse(Some(start), inject, Some(end)) private def intersperse[U >: T](start: Option[U], inject: U, end: Option[U]): Flow[U] = Flow.usingEmitInline: emit => @@ -430,15 +429,15 @@ class FlowOps[+T]: end.foreach(emit.apply) /** Applies the given mapping function `f` to each element emitted by this flow. At most `parallelism` invocations of `f` are run in - * parallel. - * - * The mapped results are emitted in the same order, in which inputs are received. In other words, ordering is preserved. - * - * @param parallelism - * An upper bound on the number of forks that run in parallel. Each fork runs the function `f` on a single element from the flow. - * @param f - * The mapping function. - */ + * parallel. + * + * The mapped results are emitted in the same order, in which inputs are received. In other words, ordering is preserved. + * + * @param parallelism + * An upper bound on the number of forks that run in parallel. Each fork runs the function `f` on a single element from the flow. + * @param f + * The mapping function. + */ def mapPar[U](parallelism: Int)(f: T => U)(using BufferCapacity): Flow[U] = Flow.usingEmitInline: emit => val s = new Semaphore(parallelism) // providing extra capacity in the `inProgress` channel (but still limiting it so that processing is bounded): @@ -478,8 +477,8 @@ class FlowOps[+T]: inProgress.receiveOrClosed() match // in the fork's result is a `None`, the error is already propagated to the `results` channel case f: Fork[Option[U]] @unchecked => f.join().map(results.sendOrClosed).isDefined - case ChannelClosed.Done => results.done(); false - case ChannelClosed.Error(e) => throw new IllegalStateException("inProgress should never be closed with an error", e) + case ChannelClosed.Done => results.done(); false + case ChannelClosed.Error(e) => throw new IllegalStateException("inProgress should never be closed with an error", e) .discard // in the main body, we call the `emit` methods using the (sequentially received) results; when an error occurs, @@ -488,15 +487,15 @@ class FlowOps[+T]: end mapPar /** Applies the given mapping function `f` to each element emitted by this flow. At most `parallelism` invocations of `f` are run in - * parallel. - * - * The mapped results **might** be emitted out-of-order, depending on the order in which the mapping function completes. - * - * @param parallelism - * An upper bound on the number of forks that run in parallel. Each fork runs the function `f` on a single element from the flow. - * @param f - * The mapping function. - */ + * parallel. + * + * The mapped results **might** be emitted out-of-order, depending on the order in which the mapping function completes. + * + * @param parallelism + * An upper bound on the number of forks that run in parallel. Each fork runs the function `f` on a single element from the flow. + * @param f + * The mapping function. + */ def mapParUnordered[U](parallelism: Int)(f: T => U)(using BufferCapacity): Flow[U] = Flow.usingEmitInline: emit => val results = BufferCapacity.newChannel[U] val s = new Semaphore(parallelism) @@ -523,8 +522,8 @@ class FlowOps[+T]: private val abortTake = new ControlThrowable("abort take") {} /** Takes the first `n` elements from this flow and emits them. If the flow completes before emitting `n` elements, the returned flow - * completes as well. - */ + * completes as well. + */ def take(n: Int): Flow[T] = Flow.usingEmitInline: emit => var taken = 0 try @@ -540,13 +539,13 @@ class FlowOps[+T]: end try /** Transform the flow so that it emits elements as long as predicate `f` is satisfied (returns `true`). If `includeFirstFailing` is - * `true`, the flow will additionally emit the first element that failed the predicate. After that, the flow will complete as done. - * - * @param f - * A predicate function called on incoming elements. - * @param includeFirstFailing - * Whether the flow should also emit the first element that failed the predicate (`false` by default). - */ + * `true`, the flow will additionally emit the first element that failed the predicate. After that, the flow will complete as done. + * + * @param f + * A predicate function called on incoming elements. + * @param includeFirstFailing + * Whether the flow should also emit the first element that failed the predicate (`false` by default). + */ def takeWhile(f: T => Boolean, includeFirstFailing: Boolean = false): Flow[T] = Flow.usingEmitInline: emit => try last.run( @@ -559,10 +558,10 @@ class FlowOps[+T]: catch case `abortTake` => () // done /** Drops `n` elements from this flow and emits subsequent elements. - * - * @param n - * Number of elements to be dropped. - */ + * + * @param n + * Number of elements to be dropped. + */ def drop(n: Int): Flow[T] = Flow.usingEmitInline: emit => var dropped = 0 last.run( @@ -572,23 +571,22 @@ class FlowOps[+T]: ) /** Merges two flows into a single flow. The resulting flow emits elements from both flows in the order they are emitted. If one of the - * flows completes before the other, the remaining elements from the other flow are emitted by the returned flow. This can be changed - * with the `propagateDoneLeft` and `propagateDoneRight` flags. - * - * Both flows are run concurrently in the background. The size of the buffers is determined by the [[BufferCapacity]] that is in scope. - * - * @param other - * The flow to be merged with this flow. - * @param propagateDoneLeft - * Should the resulting flow complete when the left flow (`this`) completes, before the `other` flow. By default `false`, that is any - * remaining elements from the `other` flow are emitted. - * - * @param propagateDoneRight - * Should the resulting flow complete when the right flow (`outer`) completes, before `this` flow. By default `false`, that is any - * remaining elements from `this` flow are emitted. - */ + * flows completes before the other, the remaining elements from the other flow are emitted by the returned flow. This can be changed + * with the `propagateDoneLeft` and `propagateDoneRight` flags. + * + * Both flows are run concurrently in the background. The size of the buffers is determined by the [[BufferCapacity]] that is in scope. + * + * @param other + * The flow to be merged with this flow. + * @param propagateDoneLeft + * Should the resulting flow complete when the left flow (`this`) completes, before the `other` flow. By default `false`, that is any + * remaining elements from the `other` flow are emitted. + * @param propagateDoneRight + * Should the resulting flow complete when the right flow (`outer`) completes, before `this` flow. By default `false`, that is any + * remaining elements from `this` flow are emitted. + */ def merge[U >: T](other: Flow[U], propagateDoneLeft: Boolean = false, propagateDoneRight: Boolean = false)(using - BufferCapacity + BufferCapacity ): Flow[U] = Flow.usingEmitInline: emit => unsupervised: @@ -603,27 +601,27 @@ class FlowOps[+T]: else if !propagateDoneRight then FlowEmit.channelToEmit(c1, emit) false case e: ChannelClosed.Error => throw e.toThrowable - case r: U @unchecked => emit(r); true + case r: U @unchecked => emit(r); true /** Given that this flow emits other flows, flattens the nested flows into a single flow. The resulting flow emits elements from the - * nested flows in the order they are emitted. - * - * The nested flows are run in sequence, that is, the next nested flow is started only after the previous one completes. - */ + * nested flows in the order they are emitted. + * + * The nested flows are run in sequence, that is, the next nested flow is started only after the previous one completes. + */ def flatten[U](using T <:< Flow[U]): Flow[U] = this.flatMap(identity) /** Pipes the elements of child flows into the returned flow. - * - * If the this flow or any of the child flows emit an error, the pulling stops and the output flow propagates the error. - * - * Up to [[parallelism]] child flows are run concurrently in the background. When the limit is reached, until a child flow completes, no - * more child flows are run. - * - * The size of the buffers for the elements emitted by the child flows is determined by the [[BufferCapacity]] that is in scope. - * - * @param parallelism - * An upper bound on the number of child flows that run in parallel. - */ + * + * If the this flow or any of the child flows emit an error, the pulling stops and the output flow propagates the error. + * + * Up to [[parallelism]] child flows are run concurrently in the background. When the limit is reached, until a child flow completes, no + * more child flows are run. + * + * The size of the buffers for the elements emitted by the child flows is determined by the [[BufferCapacity]] that is in scope. + * + * @param parallelism + * An upper bound on the number of child flows that run in parallel. + */ def flattenPar[U](parallelism: Int)(using T <:< Flow[U])(using BufferCapacity): Flow[U] = Flow.usingEmitInline: emit => case class Nested(child: Flow[U]) case object ChildDone @@ -673,29 +671,28 @@ class FlowOps[+T]: end flattenPar /** Concatenates this flow with the `other` flow. The resulting flow will emit elements from this flow first, and then from the `other` - * flow. - * - * @param other - * The flow to be appended to this flow. - */ + * flow. + * + * @param other + * The flow to be appended to this flow. + */ def concat[U >: T](other: Flow[U]): Flow[U] = Flow.concat(List(this, other)) /** Alias for [[concat]]. */ def ++[U >: T](other: Flow[U]): Flow[U] = concat(other) /** Prepends `other` flow to this source. The resulting flow will emit elements from `other` flow first, and then from the this flow. - * - * @param other - * The flow to be prepended to this flow. - */ + * + * @param other + * The flow to be prepended to this flow. + */ def prepend[U >: T](other: Flow[U]): Flow[U] = Flow.concat(List(other, this)) /** Combines elements from this and the other flow into tuples. Completion of either flow completes the returned flow as well. The flows - * are run concurrently. - * - * @see - * zipAll - */ + * are run concurrently. + * @see + * zipAll + */ def zip[U](other: Flow[U]): Flow[(T, U)] = Flow.usingEmitInline: emit => unsupervised: val s1 = outer.runToChannel() @@ -703,24 +700,24 @@ class FlowOps[+T]: repeatWhile: s1.receiveOrClosed() match - case ChannelClosed.Done => false + case ChannelClosed.Done => false case e: ChannelClosed.Error => throw e.toThrowable - case t: T @unchecked => + case t: T @unchecked => s2.receiveOrClosed() match - case ChannelClosed.Done => false + case ChannelClosed.Done => false case e: ChannelClosed.Error => throw e.toThrowable - case u: U @unchecked => emit((t, u)); true + case u: U @unchecked => emit((t, u)); true /** Combines elements from this and the other flow into tuples, handling early completion of either flow with defaults. The flows are run - * concurrently. - * - * @param other - * A flow of elements to be combined with. - * @param thisDefault - * A default element to be used in the result tuple when the other flow is longer. - * @param otherDefault - * A default element to be used in the result tuple when the current flow is longer. - */ + * concurrently. + * + * @param other + * A flow of elements to be combined with. + * @param thisDefault + * A default element to be used in the result tuple when the other flow is longer. + * @param otherDefault + * A default element to be used in the result tuple when the current flow is longer. + */ def zipAll[U >: T, V](other: Flow[V], thisDefault: U, otherDefault: V): Flow[(U, V)] = Flow.usingEmitInline: emit => unsupervised: val s1 = outer.runToChannel() @@ -728,9 +725,9 @@ class FlowOps[+T]: def receiveFromOther(thisElement: U, otherDoneHandler: () => Boolean): Boolean = s2.receiveOrClosed() match - case ChannelClosed.Done => otherDoneHandler() + case ChannelClosed.Done => otherDoneHandler() case e: ChannelClosed.Error => throw e.toThrowable - case v: V @unchecked => emit((thisElement, v)); true + case v: V @unchecked => emit((thisElement, v)); true repeatWhile: s1.receiveOrClosed() match @@ -740,16 +737,15 @@ class FlowOps[+T]: () => false ) case e: ChannelClosed.Error => throw e.toThrowable - case t: T @unchecked => + case t: T @unchecked => receiveFromOther( t, () => - emit((t, otherDefault)); - true + emit((t, otherDefault)); true ) /** Combines each element from this and the index of the element (starting at 0). - */ + */ def zipWithIndex: Flow[(T, Long)] = Flow.usingEmitInline: emit => var index = 0L last.run( @@ -760,42 +756,41 @@ class FlowOps[+T]: ) /** Emits a given number of elements (determined byc `segmentSize`) from this flow to the returned flow, then emits the same number of - * elements from the `other` flow and repeats. The order of elements in both flows is preserved. - * - * If one of the flows is done before the other, the behavior depends on the `eagerCancel` flag. When set to `true`, the returned flow is - * completed immediately, otherwise the remaining elements from the other flow are emitted by the returned flow. - * - * Both flows are run concurrently and asynchronously. - * - * @param other - * The flow whose elements will be interleaved with the elements of this flow. - * @param segmentSize - * The number of elements sent from each flow before switching to the other one. Default is 1. - * @param eagerComplete - * If `true`, the returned flow is completed as soon as either of the flow completes. If `false`, the remaining elements of the - * non-completed flow are sent downstream. - */ + * elements from the `other` flow and repeats. The order of elements in both flows is preserved. + * + * If one of the flows is done before the other, the behavior depends on the `eagerCancel` flag. When set to `true`, the returned flow is + * completed immediately, otherwise the remaining elements from the other flow are emitted by the returned flow. + * + * Both flows are run concurrently and asynchronously. + * + * @param other + * The flow whose elements will be interleaved with the elements of this flow. + * @param segmentSize + * The number of elements sent from each flow before switching to the other one. Default is 1. + * @param eagerComplete + * If `true`, the returned flow is completed as soon as either of the flow completes. If `false`, the remaining elements of the + * non-completed flow are sent downstream. + */ def interleave[U >: T](other: Flow[U], segmentSize: Int = 1, eagerComplete: Boolean = false)(using BufferCapacity): Flow[U] = Flow.interleaveAll(List(this, other), segmentSize, eagerComplete) /** Applies the given mapping function `f`, using additional state, to each element emitted by this flow. The results are emitted by the - * returned flow. Optionally the returned flow emits an additional element, possibly based on the final state, once this flow is done. - * - * The `initializeState` function is evaluated at the start of each run of the returned flow. - * - * The `onComplete` function is called once when this flow is done. If it returns a non-empty value, the value will be emitted by the - * flow, while an empty value will be ignored. - * - * @param initializeState - * A function that initializes the state. - * @param f - * A function that transforms the element from this flow and the state into a pair of the next state and the result which is emitted by - * the returned flow. - * - * @param onComplete - * A function that transforms the final state into an optional element emitted by the returned flow. By default the final state is - * ignored. - */ + * returned flow. Optionally the returned flow emits an additional element, possibly based on the final state, once this flow is done. + * + * The `initializeState` function is evaluated at the start of each run of the returned flow. + * + * The `onComplete` function is called once when this flow is done. If it returns a non-empty value, the value will be emitted by the + * flow, while an empty value will be ignored. + * + * @param initializeState + * A function that initializes the state. + * @param f + * A function that transforms the element from this flow and the state into a pair of the next state and the result which is emitted by + * the returned flow. + * @param onComplete + * A function that transforms the final state into an optional element emitted by the returned flow. By default the final state is + * ignored. + */ def mapStateful[S, U](initializeState: => S)(f: (S, T) => (S, U), onComplete: S => Option[U] = (_: S) => None): Flow[U] = def resultToSome(s: S, t: T) = val (newState, result) = f(s, t) @@ -805,28 +800,27 @@ class FlowOps[+T]: end mapStateful /** Applies the given mapping function `f`, using additional state, to each element emitted by this flow. The returned flow emits the - * results one by one. Optionally the returned flow emits an additional element, possibly based on the final state, once this flow is - * done. - * - * The `initializeState` function is evaluated at the start of each run of the returned flow. - * - * The `onComplete` function is called once when this flow is done. If it returns a non-empty value, the value will be emitted by the - * returned flow, while an empty value will be ignored. - * - * @param initializeState - * A function that initializes the state. - * @param f - * A function that transforms the element from this flow and the state into a pair of the next state and a - * [[scala.collection.IterableOnce]] of results which are emitted one by one by the returned flow. If the result of `f` is empty, - * nothing is emitted by the returned flow. - * - * @param onComplete - * A function that transforms the final state into an optional element emitted by the returned flow. By default the final state is - * ignored. - */ + * results one by one. Optionally the returned flow emits an additional element, possibly based on the final state, once this flow is + * done. + * + * The `initializeState` function is evaluated at the start of each run of the returned flow. + * + * The `onComplete` function is called once when this flow is done. If it returns a non-empty value, the value will be emitted by the + * returned flow, while an empty value will be ignored. + * + * @param initializeState + * A function that initializes the state. + * @param f + * A function that transforms the element from this flow and the state into a pair of the next state and a + * [[scala.collection.IterableOnce]] of results which are emitted one by one by the returned flow. If the result of `f` is empty, + * nothing is emitted by the returned flow. + * @param onComplete + * A function that transforms the final state into an optional element emitted by the returned flow. By default the final state is + * ignored. + */ def mapStatefulConcat[S, U]( - initializeState: => S - )(f: (S, T) => (S, IterableOnce[U]), onComplete: S => Option[U] = (_: S) => None): Flow[U] = Flow.usingEmitInline: emit => + initializeState: => S + )(f: (S, T) => (S, IterableOnce[U]), onComplete: S => Option[U] = (_: S) => None): Flow[U] = Flow.usingEmitInline: emit => var state = initializeState last.run( FlowEmit.fromInline: t => @@ -838,62 +832,22 @@ class FlowOps[+T]: end mapStatefulConcat /** Applies the given mapping function `f` to each element emitted by this flow, using a resource that is acquired when the flow starts - * and released when it completes (either successfully or with an error). - * - * The `create` function is called once when the flow starts to acquire the resource. The `close` function is called once when the flow - * completes. If the flow completes successfully and `close` returns a non-empty value, that value is emitted as a final element. If the - * flow fails, the return value of `close` is discarded. If `close` itself throws while the flow has already failed, the exception is - * added as suppressed to the original error. If `close` throws and there was no prior error, the exception is propagated. - * - * @param create - * A function that acquires the resource. - * @param close - * A function that releases the resource, optionally returning a final element. - * @param f - * A function that transforms each element using the resource. - * @return - * A flow that emits the transformed elements and optionally a final element from `close`. - */ - def mapWithResourceErr[R, U](create: => R, close: (R,Option[Throwable]) => Option[U])(f: (R, T) => U): Flow[U] = - Flow.usingEmitInline: emit => - val resource = create - var error: Throwable | Null = null - try - last.run( - FlowEmit.fromInline: t => - emit(f(resource, t)) - ) - catch - case e: Throwable => - error = e - throw e - finally - try - val finalElement = close(resource,Option(error)) - if error eq null then finalElement.foreach(emit.apply) - catch - case e: Throwable => - if error ne null then error.nn.addSuppressed(e) - else throw e - end try - end mapWithResourceErr - /** Applies the given mapping function `f` to each element emitted by this flow, using a resource that is acquired when the flow starts - * and released when it completes (either successfully or with an error). - * - * The `create` function is called once when the flow starts to acquire the resource. The `close` function is called once when the flow - * completes. If the flow completes successfully and `close` returns a non-empty value, that value is emitted as a final element. If the - * flow fails, the return value of `close` is discarded. If `close` itself throws while the flow has already failed, the exception is - * added as suppressed to the original error. If `close` throws and there was no prior error, the exception is propagated. - * - * @param create - * A function that acquires the resource. - * @param close - * A function that releases the resource, optionally returning a final element. - * @param f - * A function that transforms each element using the resource. - * @return - * A flow that emits the transformed elements and optionally a final element from `close`. - */ + * and released when it completes (either successfully or with an error). + * + * The `create` function is called once when the flow starts to acquire the resource. The `close` function is called once when the flow + * completes. If the flow completes successfully and `close` returns a non-empty value, that value is emitted as a final element. If the + * flow fails, the return value of `close` is discarded. If `close` itself throws while the flow has already failed, the exception is + * added as suppressed to the original error. If `close` throws and there was no prior error, the exception is propagated. + * + * @param create + * A function that acquires the resource. + * @param close + * A function that releases the resource, optionally returning a final element. + * @param f + * A function that transforms each element using the resource. + * @return + * A flow that emits the transformed elements and optionally a final element from `close`. + */ def mapWithResource[R, U](create: => R, close: R => Option[U])(f: (R, T) => U): Flow[U] = Flow.usingEmitInline: emit => val resource = create @@ -917,31 +871,31 @@ class FlowOps[+T]: else throw e end try end mapWithResource + /** Applies the given mapping function `f` to each element emitted by this flow, using an [[AutoCloseable]] resource that is acquired when - * the flow starts and closed when it completes. - * - * This is a convenience method that delegates to [[mapWithResource]] with `close` calling [[AutoCloseable.close]] and returning `None`. - * - * @param create - * A function that acquires the resource. - * @param f - * A function that transforms each element using the resource. - */ + * the flow starts and closed when it completes. + * + * This is a convenience method that delegates to [[mapWithResource]] with `close` calling [[AutoCloseable.close]] and returning `None`. + * + * @param create + * A function that acquires the resource. + * @param f + * A function that transforms each element using the resource. + */ def mapWithCloseableResource[R <: AutoCloseable, U](create: => R)(f: (R, T) => U): Flow[U] = mapWithResource( create, r => - r.close(); - None + r.close(); None )(f) /** Applies the given mapping function `f`, to each element emitted by this source, transforming it into an [[IterableOnce]] of results, - * then the returned flow emits the results one by one. Can be used to unfold incoming sequences of elements into single elements. - * - * @param f - * A function that transforms the element from this flow into an [[IterableOnce]] of results which are emitted one by one by the - * returned flow. If the result of `f` is empty, nothing is emitted by the returned channel. - */ + * then the returned flow emits the results one by one. Can be used to unfold incoming sequences of elements into single elements. + * + * @param f + * A function that transforms the element from this flow into an [[IterableOnce]] of results which are emitted one by one by the + * returned flow. If the result of `f` is empty, nothing is emitted by the returned channel. + */ def mapConcat[U](f: T => IterableOnce[U]): Flow[U] = Flow.usingEmitInline: emit => last.run( FlowEmit.fromInline: t => @@ -949,17 +903,17 @@ class FlowOps[+T]: ) /** Emits elements limiting the throughput to specific number of elements (evenly spaced) per time unit. Note that the element's - * emission-time time is included in the resulting throughput. For instance having `throttle(1, 1.second)` and emission of the next - * element taking `Xms` means that resulting flow will emit elements every `1s + Xms` time. Throttling is not applied to the empty - * source. - * - * @param elements - * Number of elements to be emitted. Must be greater than 0. - * @param per - * Per time unit. Must be greater or equal to 1 ms. - * @return - * A flow that emits at most `elements` `per` time unit. - */ + * emission-time time is included in the resulting throughput. For instance having `throttle(1, 1.second)` and emission of the next + * element taking `Xms` means that resulting flow will emit elements every `1s + Xms` time. Throttling is not applied to the empty + * source. + * + * @param elements + * Number of elements to be emitted. Must be greater than 0. + * @param per + * Per time unit. Must be greater or equal to 1 ms. + * @return + * A flow that emits at most `elements` `per` time unit. + */ def throttle(elements: Int, per: FiniteDuration): Flow[T] = require(elements > 0, "elements must be > 0") require(per.toMillis > 0, "per time must be >= 1 ms") @@ -968,11 +922,11 @@ class FlowOps[+T]: end throttle /** If this flow has no elements then elements from an `alternative` flow are emitted by the returned flow. If this flow is failed then - * the returned flow is failed as well. - * - * @param alternative - * An alternative flow to be used when this flow is empty. - */ + * the returned flow is failed as well. + * + * @param alternative + * An alternative flow to be used when this flow is empty. + */ def orElse[U >: T](alternative: Flow[U]): Flow[U] = Flow.usingEmitInline: emit => var receivedAtLeastOneElement = false last.run( @@ -984,20 +938,20 @@ class FlowOps[+T]: end orElse /** Chunks up the elements into groups of the specified size. The last group may be smaller due to the flow being complete. - * - * @param n - * The number of elements in a group. - */ + * + * @param n + * The number of elements in a group. + */ def grouped(n: Int): Flow[Seq[T]] = groupedWeighted(n)(_ => 1) /** Chunks up the elements into groups that have a cumulative weight greater or equal to the `minWeight`. The last group may be smaller - * due to the flow being complete. - * - * @param minWeight - * The minimum cumulative weight of elements in a group. - * @param costFn - * The function that calculates the weight of an element. - */ + * due to the flow being complete. + * + * @param minWeight + * The minimum cumulative weight of elements in a group. + * @param costFn + * The function that calculates the weight of an element. + */ def groupedWeighted(minWeight: Long)(costFn: T => Long): Flow[Seq[T]] = require(minWeight > 0, "minWeight must be > 0") @@ -1019,27 +973,27 @@ class FlowOps[+T]: end groupedWeighted /** Chunks up the emitted elements into groups, within a time window, or limited by the specified number of elements, whatever happens - * first. The timeout is reset after a group is emitted. If timeout expires and the buffer is empty, nothing is emitted. As soon as a new - * element is emitted, the flow will emit it as a single-element group and reset the timer. - * - * @param n - * The maximum number of elements in a group. - * @param duration - * The time window in which the elements are grouped. - */ + * first. The timeout is reset after a group is emitted. If timeout expires and the buffer is empty, nothing is emitted. As soon as a new + * element is emitted, the flow will emit it as a single-element group and reset the timer. + * + * @param n + * The maximum number of elements in a group. + * @param duration + * The time window in which the elements are grouped. + */ def groupedWithin(n: Int, duration: FiniteDuration)(using BufferCapacity): Flow[Seq[T]] = groupedWeightedWithin(n, duration)(_ => 1) /** Chunks up the emitted elements into groups, within a time window, or limited by the cumulative weight being greater or equal to the - * `minWeight`, whatever happens first. The timeout is reset after a group is emitted. If timeout expires and the buffer is empty, - * nothing is emitted. As soon as a new element is received, the flow will emit it as a single-element group and reset the timer. - * - * @param minWeight - * The minimum cumulative weight of elements in a group if no timeout happens. - * @param duration - * The time window in which the elements are grouped. - * @param costFn - * The function that calculates the weight of an element. - */ + * `minWeight`, whatever happens first. The timeout is reset after a group is emitted. If timeout expires and the buffer is empty, + * nothing is emitted. As soon as a new element is received, the flow will emit it as a single-element group and reset the timer. + * + * @param minWeight + * The minimum cumulative weight of elements in a group if no timeout happens. + * @param duration + * The time window in which the elements are grouped. + * @param costFn + * The function that calculates the weight of an element. + */ def groupedWeightedWithin(minWeight: Long, duration: FiniteDuration)(costFn: T => Long)(using BufferCapacity): Flow[Seq[T]] = case class GroupingTimeout(generation: Long) @@ -1105,13 +1059,13 @@ class FlowOps[+T]: end groupedWeightedWithin /** Creates sliding windows of elements from this flow. The window slides by `step` elements. The last window may be smaller due to flow - * being completed. - * - * @param n - * The number of elements in a window. - * @param step - * The number of elements the window slides by. - */ + * being completed. + * + * @param n + * The number of elements in a window. + * @param step + * The number of elements the window slides by. + */ def sliding(n: Int, step: Int = 1): Flow[Seq[T]] = require(n > 0, "n must be > 0") require(step > 0, "step must be > 0") @@ -1138,14 +1092,14 @@ class FlowOps[+T]: end sliding /** Breaks the input into chunks where the delimiter matches the predicate. The delimiter does not appear in the output. Two adjacent - * delimiters in the input result in an empty chunk in the output. - * - * @param delimiter - * A predicate function that identifies delimiter elements. - * @example - * {{{ scala> Flow.fromIterable(0 to 9).split(_ % 4 == 0).runToList() res0: List[Seq[Int]] = List(Seq(), Seq(1, 2, 3), Seq(5, 6, 7), - * Seq(9)) }}} - */ + * delimiters in the input result in an empty chunk in the output. + * + * @param delimiter + * A predicate function that identifies delimiter elements. + * @example + * {{{ scala> Flow.fromIterable(0 to 9).split(_ % 4 == 0).runToList() res0: List[Seq[Int]] = List(Seq(), Seq(1, 2, 3), Seq(5, 6, 7), + * Seq(9)) }}} + */ def split(delimiter: T => Boolean): Flow[Seq[T]] = Flow.usingEmitInline: emit => var buffer = Vector.empty[T] last.run( @@ -1163,13 +1117,13 @@ class FlowOps[+T]: end split /** Breaks the input into chunks delimited by the given sequence of elements. The delimiter sequence does not appear in the output. Two - * adjacent delimiter sequences in the input result in an empty chunk in the output. - * - * @param delimiter - * A sequence of elements that serves as a delimiter. If empty, the entire input is returned as a single chunk. - * @example - * {{{scala> Flow.fromValues(1, 2, 0, 0, 3, 4, 0, 0, 5).splitOn(List(0, 0)).runToList() res0: List[Seq[Int]] = List(Seq(1, 2), Seq(3, 4), Seq(5))}}} - */ + * adjacent delimiter sequences in the input result in an empty chunk in the output. + * + * @param delimiter + * A sequence of elements that serves as a delimiter. If empty, the entire input is returned as a single chunk. + * @example + * {{{scala> Flow.fromValues(1, 2, 0, 0, 3, 4, 0, 0, 5).splitOn(List(0, 0)).runToList() res0: List[Seq[Int]] = List(Seq(1, 2), Seq(3, 4), Seq(5))}}} + */ def splitOn[U >: T](delimiter: List[U]): Flow[Seq[T]] = Flow.usingEmitInline: emit => if delimiter.isEmpty then // Empty delimiter means no splitting - emit entire input as single chunk @@ -1216,17 +1170,17 @@ class FlowOps[+T]: end splitOn /** Attaches the given [[ox.channels.Sink]] to this flow, meaning elements that pass through will also be sent to the sink. If emitting an - * element, or sending to the `other` sink blocks, no elements will be processed until both are done. The elements are first emitted by - * the flow and then, only if that was successful, to the `other` sink. - * - * If this flow fails, then failure is passed to the `other` sink as well. If the `other` sink is failed or complete, this becomes a - * failure of the returned flow (contrary to [[alsoToTap]] where it's ignored). - * - * @param other - * The sink to which elements from this flow will be sent. - * @see - * [[alsoToTap]] for a version that drops elements when the `other` sink is not available for receive. - */ + * element, or sending to the `other` sink blocks, no elements will be processed until both are done. The elements are first emitted by + * the flow and then, only if that was successful, to the `other` sink. + * + * If this flow fails, then failure is passed to the `other` sink as well. If the `other` sink is failed or complete, this becomes a + * failure of the returned flow (contrary to [[alsoToTap]] where it's ignored). + * + * @param other + * The sink to which elements from this flow will be sent. + * @see + * [[alsoToTap]] for a version that drops elements when the `other` sink is not available for receive. + */ def alsoTo[U >: T](other: Sink[U]): Flow[U] = Flow.usingEmitInline: emit => { last.run( @@ -1241,60 +1195,60 @@ class FlowOps[+T]: private case object NotSent /** Attaches the given [[ox.channels.Sink]] to this flow, meaning elements that pass through will also be sent to the sink. If the `other` - * sink is not available for receive, the elements are still emitted by the returned flow, but not sent to the `other` sink. - * - * If this flow fails, then failure is passed to the `other` sink as well. If the `other` sink fails or closes, then failure or closure - * is ignored and it doesn't affect the resulting flow (contrary to [[alsoTo]] where it's propagated). - * - * @param other - * The sink to which elements from this source will be sent. - * @see - * [[alsoTo]] for a version that ensures that elements are emitted both by the returned flow and sent to the `other` sink. - */ + * sink is not available for receive, the elements are still emitted by the returned flow, but not sent to the `other` sink. + * + * If this flow fails, then failure is passed to the `other` sink as well. If the `other` sink fails or closes, then failure or closure + * is ignored and it doesn't affect the resulting flow (contrary to [[alsoTo]] where it's propagated). + * + * @param other + * The sink to which elements from this source will be sent. + * @see + * [[alsoTo]] for a version that ensures that elements are emitted both by the returned flow and sent to the `other` sink. + */ def alsoToTap[U >: T](other: Sink[U]): Flow[U] = Flow.usingEmitInline: emit => { - last.run( - FlowEmit.fromInline: t => - emit(t).tapException(e => other.errorOrClosed(e).discard) - selectOrClosed(other.sendClause(t), Default(NotSent)).discard - ) - other.doneOrClosed().discard - }.tapException(other.errorOrClosed(_).discard) + last.run( + FlowEmit.fromInline: t => + emit(t).tapException(e => other.errorOrClosed(e).discard) + selectOrClosed(other.sendClause(t), Default(NotSent)).discard + ) + other.doneOrClosed().discard + }.tapException(other.errorOrClosed(_).discard) end alsoToTap /** Groups elements emitted by this flow into child flows. Elements for which [[predicate]] returns the same value (of type `V`) end up in - * the same child flow. [[childFlowTransform]] is applied to each created child flow, and the resulting flow is run in the background. - * Finally, the child flows are merged back, that is any elements that they emit are emitted by the returned flow. - * - * Up to [[parallelism]] child flows are run concurrently in the background. When the limit is reached, the child flow which didn't - * receive a new element the longest is completed as done. - * - * Child flows for `V` values might be created multiple times (if, after completing a child flow because of parallelism limit, new - * elements arrive, mapped to a given `V` value). However, it is guaranteed that for a given `V` value, there will be at most one child - * flow running at any time. - * - * Child flows should only complete as done when the flow of received `T` elements completes. Otherwise, the entire stream will fail with - * an error. - * - * Errors that occur in this flow, or in any child flows, become errors of the returned flow (exceptions are wrapped in - * [[ChannelClosedException]]). - * - * The size of the buffers for the elements emitted by this flow (which is also run in the background) and the child flows are determined - * by the [[BufferCapacity]] that is in scope. - * - * @param parallelism - * An upper bound on the number of child flows that run in parallel at any time. - * @param predicate - * Function used to determine the group for an element of type `T`. Each group is represented by a value of type `V`. - * @param childFlowTransform - * The function that is used to create a child flow, which is later in the background. The arguments are the group value, for which the - * flow is created, and a flow of `T` elements in that group (each such element has the same group value `V` returned by `predicated`). - */ + * the same child flow. [[childFlowTransform]] is applied to each created child flow, and the resulting flow is run in the background. + * Finally, the child flows are merged back, that is any elements that they emit are emitted by the returned flow. + * + * Up to [[parallelism]] child flows are run concurrently in the background. When the limit is reached, the child flow which didn't + * receive a new element the longest is completed as done. + * + * Child flows for `V` values might be created multiple times (if, after completing a child flow because of parallelism limit, new + * elements arrive, mapped to a given `V` value). However, it is guaranteed that for a given `V` value, there will be at most one child + * flow running at any time. + * + * Child flows should only complete as done when the flow of received `T` elements completes. Otherwise, the entire stream will fail with + * an error. + * + * Errors that occur in this flow, or in any child flows, become errors of the returned flow (exceptions are wrapped in + * [[ChannelClosedException]]). + * + * The size of the buffers for the elements emitted by this flow (which is also run in the background) and the child flows are determined + * by the [[BufferCapacity]] that is in scope. + * + * @param parallelism + * An upper bound on the number of child flows that run in parallel at any time. + * @param predicate + * Function used to determine the group for an element of type `T`. Each group is represented by a value of type `V`. + * @param childFlowTransform + * The function that is used to create a child flow, which is later in the background. The arguments are the group value, for which the + * flow is created, and a flow of `T` elements in that group (each such element has the same group value `V` returned by `predicated`). + */ def groupBy[V, U](parallelism: Int, predicate: T => V)(childFlowTransform: V => Flow[T] => Flow[U])(using BufferCapacity): Flow[U] = groupByImpl(outer, parallelism, predicate)(childFlowTransform) /** Discard all elements emitted by this flow. The returned flow completes only when this flow completes (successfully or with an error). - */ + */ def drain(): Flow[Nothing] = Flow.usingEmitInline: emit => last.run(FlowEmit.fromInline(_ => ())) @@ -1313,25 +1267,25 @@ class FlowOps[+T]: last.run(emit).tapException(f) /** Retries the upstream flow execution using the provided retry configuration. If the flow fails with an exception, it will be retried - * according to the schedule defined in the retry config until it succeeds or the retry policy decides to stop. - * - * Each retry attempt will run the complete upstream flow, from start up to this point. The retry behavior is controlled by the - * [[RetryConfig]]. - * - * Note that this retries the flow execution itself, not individual elements within the flow. If you need to retry individual operations - * within the flow, consider using retry logic inside methods such as [[map]]. - * - * Creates an asynchronous boundary (see [[buffer]]) to isolate failures when running the upstream flow. - * - * @param config - * The retry configuration that specifies the retry schedule and success/failure conditions. - * @return - * A new flow that will retry execution according to the provided configuration. - * @throws anything - * The exception from the last retry attempt if all retries are exhausted. - * @see - * [[ox.resilience.retry]] - */ + * according to the schedule defined in the retry config until it succeeds or the retry policy decides to stop. + * + * Each retry attempt will run the complete upstream flow, from start up to this point. The retry behavior is controlled by the + * [[RetryConfig]]. + * + * Note that this retries the flow execution itself, not individual elements within the flow. If you need to retry individual operations + * within the flow, consider using retry logic inside methods such as [[map]]. + * + * Creates an asynchronous boundary (see [[buffer]]) to isolate failures when running the upstream flow. + * + * @param config + * The retry configuration that specifies the retry schedule and success/failure conditions. + * @return + * A new flow that will retry execution according to the provided configuration. + * @throws anything + * The exception from the last retry attempt if all retries are exhausted. + * @see + * [[ox.resilience.retry]] + */ def retry(config: RetryConfig[Throwable, Unit])(using BufferCapacity): Flow[T] = Flow.usingEmitInline: emit => val ch = BufferCapacity.newChannel[T] unsupervised: @@ -1342,20 +1296,20 @@ class FlowOps[+T]: FlowEmit.channelToEmit(ch, emit) /** @see - * [[retry(RetryConfig)]] - */ + * [[retry(RetryConfig)]] + */ def retry(schedule: Schedule): Flow[T] = retry(RetryConfig(schedule)) /** Recovers from errors in the upstream flow by emitting a recovery value when the error is handled by the partial function. If the - * partial function is not defined for the error, the original error is propagated. - * - * Creates an asynchronous boundary (see [[buffer]]) to isolate failures when running the upstream flow. - * - * @param pf - * A partial function that handles specific exceptions and returns a recovery value to emit. - * @return - * A flow that emits elements from the upstream flow, and emits a recovery value if the upstream fails with a handled exception. - */ + * partial function is not defined for the error, the original error is propagated. + * + * Creates an asynchronous boundary (see [[buffer]]) to isolate failures when running the upstream flow. + * + * @param pf + * A partial function that handles specific exceptions and returns a recovery value to emit. + * @return + * A flow that emits elements from the upstream flow, and emits a recovery value if the upstream fails with a handled exception. + */ def recover[U >: T](pf: PartialFunction[Throwable, U])(using BufferCapacity): Flow[U] = Flow.usingEmitInline: emit => val ch = BufferCapacity.newChannel[U] unsupervised: @@ -1367,41 +1321,41 @@ class FlowOps[+T]: FlowEmit.channelToEmit(ch, emit) /** Recovers from errors in the upstream flow by switching to an alternative flow produced by the partial function. Elements already - * emitted by the upstream flow before the error are preserved. If the partial function is not defined for the error, the original error - * is propagated. - * - * Creates an asynchronous boundary (see [[buffer]]) to isolate failures when running the upstream flow. - * - * @param pf - * A partial function that handles specific exceptions and returns an alternative flow to switch to. - * @return - * A flow that emits elements from the upstream flow, and switches to the recovery flow if the upstream fails with a handled exception. - */ + * emitted by the upstream flow before the error are preserved. If the partial function is not defined for the error, the original error + * is propagated. + * + * Creates an asynchronous boundary (see [[buffer]]) to isolate failures when running the upstream flow. + * + * @param pf + * A partial function that handles specific exceptions and returns an alternative flow to switch to. + * @return + * A flow that emits elements from the upstream flow, and switches to the recovery flow if the upstream fails with a handled exception. + */ def recoverWith[U >: T](pf: PartialFunction[Throwable, Flow[U]])(using BufferCapacity): Flow[U] = Flow.usingEmitInline: emit => val ch = BufferCapacity.newChannel[U] unsupervised: forkPropagate(ch) { try last.run(FlowEmit.fromInline(t => ch.send(t))) - catch case e: Throwable if pf.isDefinedAt(e) => pf(e).runToEmit(FlowEmit.fromInline(t => ch.send(t))) // todo - shouldnt we use applyOrElse? + catch case e: Throwable if pf.isDefinedAt(e) => pf(e).runToEmit(FlowEmit.fromInline(t => ch.send(t))) ch.done() }.discard FlowEmit.channelToEmit(ch, emit) /** Recovers from errors in the upstream flow by switching to an alternative flow, with retry support. On each failure matching the - * partial function, the recovery flow is materialized and run. If the recovery flow also fails and retries remain (as specified by the - * retry config), the recovery is attempted again. After exhausting all retries, the error is propagated. - * - * Creates an asynchronous boundary (see [[buffer]]) to isolate failures when running the upstream flow. - * - * @param config - * The retry configuration that specifies the schedule and policy for recovery attempts. - * @param pf - * A partial function that handles specific exceptions and returns an alternative flow to switch to. - * @return - * A flow that emits elements from the upstream flow, and switches to recovery flows on failure, retrying according to the config. - */ + * partial function, the recovery flow is materialized and run. If the recovery flow also fails and retries remain (as specified by the + * retry config), the recovery is attempted again. After exhausting all retries, the error is propagated. + * + * Creates an asynchronous boundary (see [[buffer]]) to isolate failures when running the upstream flow. + * + * @param config + * The retry configuration that specifies the schedule and policy for recovery attempts. + * @param pf + * A partial function that handles specific exceptions and returns an alternative flow to switch to. + * @return + * A flow that emits elements from the upstream flow, and switches to recovery flows on failure, retrying according to the config. + */ def recoverWithRetry[U >: T](config: RetryConfig[Throwable, Unit])(pf: PartialFunction[Throwable, Flow[U]])(using - BufferCapacity + BufferCapacity ): Flow[U] = Flow.usingEmitInline: emit => val ch = BufferCapacity.newChannel[U] @@ -1420,16 +1374,15 @@ class FlowOps[+T]: recoverWithRetry(RetryConfig(schedule))(pf) /** Completes the flow normally (without error) when the upstream fails with an exception matching the partial function. Elements already - * emitted before the error are preserved. If the partial function is not defined for the error, the original error is propagated. - * Exceptions thrown by downstream operators are not suppressed. - * - * @param pf - * A partial function that determines which exceptions should cause the flow to complete. The function should return `true` if the - * exception should be suppressed. - * - * @return - * A flow that completes normally when an error matching `pf` occurs. - */ + * emitted before the error are preserved. If the partial function is not defined for the error, the original error is propagated. + * Exceptions thrown by downstream operators are not suppressed. + * + * @param pf + * A partial function that determines which exceptions should cause the flow to complete. The function should return `true` if the + * exception should be suppressed. + * @return + * A flow that completes normally when an error matching `pf` occurs. + */ def onErrorComplete(pf: PartialFunction[Throwable, Boolean]): Flow[T] = Flow.usingEmitInline: emit => var downstreamFailure: Throwable | Null = null val guardedEmit = FlowEmit.fromInline[T]: t => @@ -1442,12 +1395,12 @@ class FlowOps[+T]: catch case e: Throwable if (downstreamFailure ne e) && pf.applyOrElse(e, (_: Throwable) => false) => () /** Completes the flow normally (without error) when the upstream fails with any exception. Elements already emitted before the error are - * preserved. Does not catch non-exception throwables such as fatal errors or control throwables. Exceptions thrown by downstream - * operators are not suppressed. - * - * @return - * A flow that completes normally when any exception occurs. - */ + * preserved. Does not catch non-exception throwables such as fatal errors or control throwables. Exceptions thrown by downstream + * operators are not suppressed. + * + * @return + * A flow that completes normally when any exception occurs. + */ def onErrorComplete: Flow[T] = Flow.usingEmitInline: emit => var downstreamFailure: Throwable | Null = null val guardedEmit = FlowEmit.fromInline[T]: t => @@ -1460,15 +1413,15 @@ class FlowOps[+T]: catch case e: Exception if downstreamFailure ne e => () /** Recovers from any error in the upstream flow by emitting a value produced by `f`. Unlike [[recover]], this takes a total function and - * handles all throwables (including control throwables and fatal errors). - * - * Creates an asynchronous boundary (see [[buffer]]) to isolate failures when running the upstream flow. - * - * @param f - * A function that maps an exception to a recovery value to emit. - * @return - * A flow that emits elements from the upstream flow, and emits a recovery value if the upstream fails. - */ + * handles all throwables (including control throwables and fatal errors). + * + * Creates an asynchronous boundary (see [[buffer]]) to isolate failures when running the upstream flow. + * + * @param f + * A function that maps an exception to a recovery value to emit. + * @return + * A flow that emits elements from the upstream flow, and emits a recovery value if the upstream fails. + */ def onErrorRecover[U >: T](f: Throwable => U)(using BufferCapacity): Flow[U] = recover { case e => f(e) } // diff --git a/core/src/main/scala/ox/oxThreadFactory.scala b/core/src/main/scala/ox/oxThreadFactory.scala index b91e7434..68f20fc0 100644 --- a/core/src/main/scala/ox/oxThreadFactory.scala +++ b/core/src/main/scala/ox/oxThreadFactory.scala @@ -2,7 +2,6 @@ package ox import java.util.concurrent.ThreadFactory -private def defaultThreadFactory = Thread.ofVirtual().factory() private var customThreadFactory: ThreadFactory = _ /** @see [[oxThreadFactory]] */ @@ -19,5 +18,4 @@ def setOxThreadFactory(tf: ThreadFactory): Unit = */ lazy val oxThreadFactory: ThreadFactory = val custom = customThreadFactory - if custom == null then defaultThreadFactory else custom - + if custom == null then Thread.ofVirtual().factory() else custom diff --git a/generated-doc/out/utils/oxapp.md b/generated-doc/out/utils/oxapp.md index 5acc5d99..99b3a0ae 100644 --- a/generated-doc/out/utils/oxapp.md +++ b/generated-doc/out/utils/oxapp.md @@ -48,9 +48,8 @@ has to return an `ox.ExitCode` value which translates to the exit code returned defined as: ```scala - enum ExitCode(val code: Int): - case Success extends ox.ExitCode(0) + case Success extends ExitCode(0) case Failure(exitCode: Int = 1) extends ExitCode(exitCode) ``` diff --git a/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala b/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala index 2676fdde..024436c2 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala @@ -19,4 +19,120 @@ object OxTracing: private class Impl(base: OxTracingBase, flow: OxFlowTracing) extends OxTracing: export flow.* - export base.* \ No newline at end of file + export base.* + + + + + +// +// override def currentSpanUnsafe(): Span = Span.fromContext(contextHolder.get()) +// +// +// def spanUnsafe(spanName: String, +// spanKind: SpanKind = SpanKind.INTERNAL, +// attributes: Attributes = Attributes.empty(), +// links: Seq[SpanContext] = Seq.empty, +// ): AutoCloseableSpan = { +// val previousContext = contextHolder.get() +// val builder = tracer.spanBuilder(spanName).setSpanKind(spanKind).setAllAttributes(attributes).setParent(previousContext) +// val withLinks = links.foldLeft(builder)((b, l) => b.addLink(l)) +// val started = withLinks.startSpan() +// contextHolder.set(previousContext.`with`(started)) +// +// AutoCloseableSpan.ContextHolderBased(started, contextHolder, previousContext) +// } +// +// +// def byNameSpan[T](t: T)(spanName: String, +// spanKind: SpanKind, +// attributes: Attributes, +// links: Seq[SpanContext], +// beforeClose: ((Span, Option[Throwable])) => Span +// ) = { +// var error: Throwable | Null = null +// var finalizer: AutoCloseableSpan | Null = null +// try +// val finalize = spanUnsafe(spanName, spanKind, attributes, links) +// finalizer = finalize +// t +// catch +// case e: Throwable => +// error = e +// throw e +// finally +// try +// if finalizer ne null then beforeClose(finalizer.nn.span, Option(error)): Unit +// catch +// case e: Throwable => +// if error ne null then error.nn.addSuppressed(e) +// else throw e +// finally +// if finalizer ne null then finalizer.nn.close() +// end try +// end try +// } +// +// def spanFlow[T](flow: Flow[T])(spanName: String, spanKind: SpanKind, attributes: Attributes, links: Seq[SpanContext], beforeClose: ((Span, Option[Throwable])) => Span): Flow[T] = +// Flow.usingEmit: emit => +// byNameSpan(flow.runToEmit( +// emit +// ))(spanName, spanKind, attributes, links, beforeClose) +//} +// +// +////trait TracingStrategy[T]: +//// def span(t: => T)(spanName: String, spanKind: SpanKind, attributes: Attributes, links: Seq[SpanContext], beforeClose: ((Span, Option[Throwable])) => Span): T +//// +////object TracingStrategy extends LowPrio: +//// given [T]: TracingStrategy[Flow[T]] with { +//// override def span(flow: => Flow[T])(spanName: String, spanKind: SpanKind, attributes: Attributes, links: Seq[SpanContext], beforeClose: ((Span, Option[Throwable])) => Span): Flow[T] = +//// val preEvaluate = flow +//// Flow.usingEmit: emit => +//// byName.span(preEvaluate.runToEmit( +//// emit +//// ))(spanName, spanKind, attributes, links, beforeClose) +//// +//// } +//// +////trait LowPrio: +//// inline def isAbstract[T](using q: quoted.Quotes, t: quoted.Type[T]): Boolean = +//// import q.reflect.{*, given} +//// import q.{*, given} +//// val tRefSym = TypeRepr.of[T].typeSymbol +//// tRefSym.isAbstractType +//// +//// +//// inline given byName[T](using q: quoted.Quotes, t: quoted.Type[T]): TracingStrategy[T] = +//// if isAbstract[T] then +//// summonInline[TracingStrategy[T]] +//// else +//// byName[T] +//// +//// +//// def byName[T]: TracingStrategy[T] = new { +//// extension (t: => T) +//// override def span(spanName: String, spanKind: SpanKind, attributes: Attributes, links: Seq[SpanContext], beforeClose: ((Span, Option[Throwable])) => Span): T = +//// var error: Throwable | Null = null +//// var finalizer: AutoCloseableSpan | Null = null +//// try +//// val finalize = ??? //spanUnsafe(spanName, spanKind, attributes, links) +//// finalizer = finalize +//// t +//// catch +//// case e: Throwable => +//// error = e +//// throw e +//// finally +//// try +//// if finalizer ne null then beforeClose(finalizer.nn.span, Option(error)): Unit +//// catch +//// case e: Throwable => +//// if error ne null then error.nn.addSuppressed(e) +//// else throw e +//// finally +//// if finalizer ne null then finalizer.nn.close() +//// end try +//// end try +//// } +// diff --git a/otel/src/test/scala/OtelTest.scala b/otel/src/test/scala/OxTelemetryTest.scala similarity index 99% rename from otel/src/test/scala/OtelTest.scala rename to otel/src/test/scala/OxTelemetryTest.scala index 84115060..7b0f644d 100644 --- a/otel/src/test/scala/OtelTest.scala +++ b/otel/src/test/scala/OxTelemetryTest.scala @@ -17,7 +17,7 @@ import java.util.UUID import scala.collection.JavaConverters.asScalaBufferConverter -class OtelTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: +class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: setOxThreadFactory(new PropagatingVirtualThreadFactory) class My extends OxApp.WithEitherErrors[String] /* , OtelOxApp.WithOtelSupport */: @@ -443,4 +443,4 @@ class OtelTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: } -end OtelTest +end OxTelemetryTest diff --git a/otel/src/test/scala/ox/kafka/AppTest.scala b/otel/src/test/scala/ox/kafka/AppTest.scala deleted file mode 100644 index 77e9c25d..00000000 --- a/otel/src/test/scala/ox/kafka/AppTest.scala +++ /dev/null @@ -1,33 +0,0 @@ -package ox.kafka - -import org.scalatest.BeforeAndAfterAll -import org.scalatest.flatspec.AnyFlatSpec -import org.scalatest.matchers.should.Matchers -import ox.* -import ox.ExitCode.Success -import ox.otel.context.PropagatingVirtualThreadFactory - - -class AppTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll//: - -// "flow.spanned" should "not open span if not runned" in new Fixture { -// -// assert(this.settings.threadFactory.get.isInstanceOf[PropagatingVirtualThreadFactory]) -// } -// "flow.spanned" should "not open span if not runnefssfd" in new Fixture2 { -// -// assert(this.settings.threadFactory.get.isInstanceOf[PropagatingVirtualThreadFactory]) -// } - -// class Fixture extends OxApp.WithEitherErrors[String] with OtelOxApp.WithOtelSupport: -// override def run(args: Vector[String])(using Ox, EitherError[String]): ExitCode = ??? -// -// override def handleError(e: String): ExitCode = ??? -// -// -// class Fixture2 extends OxApp.Simple, OtelOxApp.WithOtelSupport: -// override protected def settings: OxApp.Settings = super.settings.copy(interruptedExitCode = Success) -// -// override def run(using Ox): Unit = ??? - -end AppTest From 9686f4bddfa32737f93f75bc8e3adedc4c875604 Mon Sep 17 00:00:00 2001 From: Pawel Sadlo Date: Fri, 9 Oct 2026 08:52:49 +0200 Subject: [PATCH 08/38] ... --- .../scala/ox/telemetry/ContextHolder.scala | 1 - .../telemetry/tracing/AutoCloseableSpan.scala | 2 +- .../ox/telemetry/tracing/OxTracing.scala | 116 ------------------ .../tracing/flow/OxFlowTracing.scala | 1 - 4 files changed, 1 insertion(+), 119 deletions(-) diff --git a/otel/src/main/scala/ox/telemetry/ContextHolder.scala b/otel/src/main/scala/ox/telemetry/ContextHolder.scala index 5524f1bd..e93dc067 100644 --- a/otel/src/main/scala/ox/telemetry/ContextHolder.scala +++ b/otel/src/main/scala/ox/telemetry/ContextHolder.scala @@ -25,4 +25,3 @@ object ContextHolder: if !checked then if !ox.oxThreadFactory.isInstanceOf[PropagatingVirtualThreadFactory] then System.err.println(s"Otel should use ${classOf[PropagatingVirtualThreadFactory].getName}") checked = true - diff --git a/otel/src/main/scala/ox/telemetry/tracing/AutoCloseableSpan.scala b/otel/src/main/scala/ox/telemetry/tracing/AutoCloseableSpan.scala index 00ac9dac..3ca8cd72 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/AutoCloseableSpan.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/AutoCloseableSpan.scala @@ -11,4 +11,4 @@ private object AutoCloseableSpan: private [ox] final class ContextHolderBased(val span: Span, contextHolder: ContextHolder, previousContext: Context) extends AutoCloseableSpan: def close(): Unit = span.end() - contextHolder.set(previousContext) \ No newline at end of file + contextHolder.set(previousContext) diff --git a/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala b/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala index 024436c2..db9edf7e 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala @@ -20,119 +20,3 @@ object OxTracing: private class Impl(base: OxTracingBase, flow: OxFlowTracing) extends OxTracing: export flow.* export base.* - - - - - -// -// override def currentSpanUnsafe(): Span = Span.fromContext(contextHolder.get()) -// -// -// def spanUnsafe(spanName: String, -// spanKind: SpanKind = SpanKind.INTERNAL, -// attributes: Attributes = Attributes.empty(), -// links: Seq[SpanContext] = Seq.empty, -// ): AutoCloseableSpan = { -// val previousContext = contextHolder.get() -// val builder = tracer.spanBuilder(spanName).setSpanKind(spanKind).setAllAttributes(attributes).setParent(previousContext) -// val withLinks = links.foldLeft(builder)((b, l) => b.addLink(l)) -// val started = withLinks.startSpan() -// contextHolder.set(previousContext.`with`(started)) -// -// AutoCloseableSpan.ContextHolderBased(started, contextHolder, previousContext) -// } -// -// -// def byNameSpan[T](t: T)(spanName: String, -// spanKind: SpanKind, -// attributes: Attributes, -// links: Seq[SpanContext], -// beforeClose: ((Span, Option[Throwable])) => Span -// ) = { -// var error: Throwable | Null = null -// var finalizer: AutoCloseableSpan | Null = null -// try -// val finalize = spanUnsafe(spanName, spanKind, attributes, links) -// finalizer = finalize -// t -// catch -// case e: Throwable => -// error = e -// throw e -// finally -// try -// if finalizer ne null then beforeClose(finalizer.nn.span, Option(error)): Unit -// catch -// case e: Throwable => -// if error ne null then error.nn.addSuppressed(e) -// else throw e -// finally -// if finalizer ne null then finalizer.nn.close() -// end try -// end try -// } -// -// def spanFlow[T](flow: Flow[T])(spanName: String, spanKind: SpanKind, attributes: Attributes, links: Seq[SpanContext], beforeClose: ((Span, Option[Throwable])) => Span): Flow[T] = -// Flow.usingEmit: emit => -// byNameSpan(flow.runToEmit( -// emit -// ))(spanName, spanKind, attributes, links, beforeClose) -//} -// -// -////trait TracingStrategy[T]: -//// def span(t: => T)(spanName: String, spanKind: SpanKind, attributes: Attributes, links: Seq[SpanContext], beforeClose: ((Span, Option[Throwable])) => Span): T -//// -////object TracingStrategy extends LowPrio: -//// given [T]: TracingStrategy[Flow[T]] with { -//// override def span(flow: => Flow[T])(spanName: String, spanKind: SpanKind, attributes: Attributes, links: Seq[SpanContext], beforeClose: ((Span, Option[Throwable])) => Span): Flow[T] = -//// val preEvaluate = flow -//// Flow.usingEmit: emit => -//// byName.span(preEvaluate.runToEmit( -//// emit -//// ))(spanName, spanKind, attributes, links, beforeClose) -//// -//// } -//// -////trait LowPrio: -//// inline def isAbstract[T](using q: quoted.Quotes, t: quoted.Type[T]): Boolean = -//// import q.reflect.{*, given} -//// import q.{*, given} -//// val tRefSym = TypeRepr.of[T].typeSymbol -//// tRefSym.isAbstractType -//// -//// -//// inline given byName[T](using q: quoted.Quotes, t: quoted.Type[T]): TracingStrategy[T] = -//// if isAbstract[T] then -//// summonInline[TracingStrategy[T]] -//// else -//// byName[T] -//// -//// -//// def byName[T]: TracingStrategy[T] = new { -//// extension (t: => T) -//// override def span(spanName: String, spanKind: SpanKind, attributes: Attributes, links: Seq[SpanContext], beforeClose: ((Span, Option[Throwable])) => Span): T = -//// var error: Throwable | Null = null -//// var finalizer: AutoCloseableSpan | Null = null -//// try -//// val finalize = ??? //spanUnsafe(spanName, spanKind, attributes, links) -//// finalizer = finalize -//// t -//// catch -//// case e: Throwable => -//// error = e -//// throw e -//// finally -//// try -//// if finalizer ne null then beforeClose(finalizer.nn.span, Option(error)): Unit -//// catch -//// case e: Throwable => -//// if error ne null then error.nn.addSuppressed(e) -//// else throw e -//// finally -//// if finalizer ne null then finalizer.nn.close() -//// end try -//// end try -//// } -// diff --git a/otel/src/main/scala/ox/telemetry/tracing/flow/OxFlowTracing.scala b/otel/src/main/scala/ox/telemetry/tracing/flow/OxFlowTracing.scala index 6cbcfb7e..b199cea1 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/flow/OxFlowTracing.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/flow/OxFlowTracing.scala @@ -22,4 +22,3 @@ private[tracing] object OxFlowTracing: base.byNameSpan(flow.runToEmit( emit ))(spanName, spanKind, attributes, links, beforeClose) - From 518a39f0e1b74b7724c5be12ee3f053d6184f9f4 Mon Sep 17 00:00:00 2001 From: Pawel Sadlo Date: Fri, 9 Oct 2026 09:18:18 +0200 Subject: [PATCH 09/38] fmt --- .../scala/ox/telemetry/ContextHolder.scala | 6 +- .../telemetry/tracing/AutoCloseableSpan.scala | 3 +- .../ox/telemetry/tracing/OxTracing.scala | 1 + .../tracing/flow/OxFlowTracing.scala | 36 +++-- .../tracing/internal/OxTracingBase.scala | 57 +++++--- otel/src/test/scala/OxTelemetryTest.scala | 138 ++++++++---------- 6 files changed, 129 insertions(+), 112 deletions(-) diff --git a/otel/src/main/scala/ox/telemetry/ContextHolder.scala b/otel/src/main/scala/ox/telemetry/ContextHolder.scala index e93dc067..55b19695 100644 --- a/otel/src/main/scala/ox/telemetry/ContextHolder.scala +++ b/otel/src/main/scala/ox/telemetry/ContextHolder.scala @@ -3,7 +3,6 @@ package ox.telemetry import io.opentelemetry.context.Context import ox.otel.context.PropagatingVirtualThreadFactory - trait ContextHolder: def get(): Context @@ -23,5 +22,8 @@ object ContextHolder: private def checkThreadFactory() = if !checked then - if !ox.oxThreadFactory.isInstanceOf[PropagatingVirtualThreadFactory] then System.err.println(s"Otel should use ${classOf[PropagatingVirtualThreadFactory].getName}") + if !ox.oxThreadFactory.isInstanceOf[PropagatingVirtualThreadFactory] then + System.err.println(s"Otel should use ${classOf[PropagatingVirtualThreadFactory].getName}") checked = true + end Native +end ContextHolder diff --git a/otel/src/main/scala/ox/telemetry/tracing/AutoCloseableSpan.scala b/otel/src/main/scala/ox/telemetry/tracing/AutoCloseableSpan.scala index 3ca8cd72..d7c3e590 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/AutoCloseableSpan.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/AutoCloseableSpan.scala @@ -8,7 +8,8 @@ sealed trait AutoCloseableSpan extends AutoCloseable: def span: Span private object AutoCloseableSpan: - private [ox] final class ContextHolderBased(val span: Span, contextHolder: ContextHolder, previousContext: Context) extends AutoCloseableSpan: + private[ox] final class ContextHolderBased(val span: Span, contextHolder: ContextHolder, previousContext: Context) + extends AutoCloseableSpan: def close(): Unit = span.end() contextHolder.set(previousContext) diff --git a/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala b/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala index db9edf7e..bc7ed5f7 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala @@ -20,3 +20,4 @@ object OxTracing: private class Impl(base: OxTracingBase, flow: OxFlowTracing) extends OxTracing: export flow.* export base.* +end OxTracing diff --git a/otel/src/main/scala/ox/telemetry/tracing/flow/OxFlowTracing.scala b/otel/src/main/scala/ox/telemetry/tracing/flow/OxFlowTracing.scala index b199cea1..cc4f423e 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/flow/OxFlowTracing.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/flow/OxFlowTracing.scala @@ -7,18 +7,32 @@ import ox.telemetry.tracing.internal.OxTracingBase private[tracing] trait OxFlowTracing: extension [T](flow: Flow[T]) - def span(spanName: String, - spanKind: SpanKind = SpanKind.INTERNAL, - attributes: Attributes = Attributes.empty(), - links: Seq[SpanContext] = Seq.empty, - beforeClose: ((Span, Option[Throwable])) => Span = _._1 - ): Flow[T] + def span( + spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty, + beforeClose: ((Span, Option[Throwable])) => Span = _._1 + ): Flow[T] + end extension +end OxFlowTracing private[tracing] object OxFlowTracing: - private [tracing] final class OxFlowTracingImpl(base: OxTracingBase) extends OxFlowTracing: + private[tracing] final class OxFlowTracingImpl(base: OxTracingBase) extends OxFlowTracing: extension [T](flow: Flow[T]) - override def span(spanName: String, spanKind: SpanKind, attributes: Attributes, links: Seq[SpanContext], beforeClose: ((Span, Option[Throwable])) => Span): Flow[T] = + override def span( + spanName: String, + spanKind: SpanKind, + attributes: Attributes, + links: Seq[SpanContext], + beforeClose: ((Span, Option[Throwable])) => Span + ): Flow[T] = Flow.usingEmit: emit => - base.byNameSpan(flow.runToEmit( - emit - ))(spanName, spanKind, attributes, links, beforeClose) + base.byNameSpan( + flow.runToEmit( + emit + ) + )(spanName, spanKind, attributes, links, beforeClose) + end extension + end OxFlowTracingImpl +end OxFlowTracing diff --git a/otel/src/main/scala/ox/telemetry/tracing/internal/OxTracingBase.scala b/otel/src/main/scala/ox/telemetry/tracing/internal/OxTracingBase.scala index 1531bfb4..cb6f5822 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/internal/OxTracingBase.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/internal/OxTracingBase.scala @@ -8,19 +8,23 @@ import ox.telemetry.tracing.AutoCloseableSpan private[tracing] trait OxTracingBase: def currentSpanUnsafe(): Span - def spanUnsafe(spanName: String, - spanKind: SpanKind = SpanKind.INTERNAL, - attributes: Attributes = Attributes.empty(), - links: Seq[SpanContext] = Seq.empty, - ): AutoCloseableSpan + def spanUnsafe( + spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty + ): AutoCloseableSpan extension [T](t: => T) - def byNameSpan(spanName: String, - spanKind: SpanKind, - attributes: Attributes, - links: Seq[SpanContext], - beforeClose: ((Span, Option[Throwable])) => Span - ): T + def byNameSpan( + spanName: String, + spanKind: SpanKind, + attributes: Attributes, + links: Seq[SpanContext], + beforeClose: ((Span, Option[Throwable])) => Span + ): T + end extension +end OxTracingBase private[tracing] object OxTracingBase: def apply(tracer: Tracer, contextHolder: ContextHolder): OxTracingBase = new OxTracingBaseImpl(tracer, contextHolder) @@ -29,12 +33,12 @@ private[tracing] object OxTracingBase: override def currentSpanUnsafe(): Span = Span.fromContext(contextHolder.get()) - - def spanUnsafe(spanName: String, - spanKind: SpanKind = SpanKind.INTERNAL, - attributes: Attributes = Attributes.empty(), - links: Seq[SpanContext] = Seq.empty, - ): AutoCloseableSpan = + def spanUnsafe( + spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty + ): AutoCloseableSpan = val previousContext = contextHolder.get() val builder = tracer.spanBuilder(spanName).setSpanKind(spanKind).setAllAttributes(attributes).setParent(previousContext) val withLinks = links.foldLeft(builder)((b, l) => b.addLink(l)) @@ -42,9 +46,16 @@ private[tracing] object OxTracingBase: contextHolder.set(previousContext.`with`(started)) AutoCloseableSpan.ContextHolderBased(started, contextHolder, previousContext) + end spanUnsafe extension [T](t: => T) - override def byNameSpan(spanName: String, spanKind: SpanKind, attributes: Attributes, links: Seq[SpanContext], beforeClose: ((Span, Option[Throwable])) => Span): T = { + override def byNameSpan( + spanName: String, + spanKind: SpanKind, + attributes: Attributes, + links: Seq[SpanContext], + beforeClose: ((Span, Option[Throwable])) => Span + ): T = var error: Throwable | Null = null var finalizer: AutoCloseableSpan | Null = null try @@ -56,17 +67,15 @@ private[tracing] object OxTracingBase: error = e throw e finally - try - if finalizer ne null then beforeClose(finalizer.nn.span, Option(error)): Unit + try if finalizer ne null then beforeClose(finalizer.nn.span, Option(error)): Unit catch case e: Throwable => if error ne null then error.nn.addSuppressed(e) else throw e - finally - if finalizer ne null then finalizer.nn.close() + finally if finalizer ne null then finalizer.nn.close() end try end try - } - + end extension end OxTracingBaseImpl +end OxTracingBase diff --git a/otel/src/test/scala/OxTelemetryTest.scala b/otel/src/test/scala/OxTelemetryTest.scala index 7b0f644d..044fed78 100644 --- a/otel/src/test/scala/OxTelemetryTest.scala +++ b/otel/src/test/scala/OxTelemetryTest.scala @@ -16,7 +16,6 @@ import ox.telemetry.tracing.OxTracing import java.util.UUID import scala.collection.JavaConverters.asScalaBufferConverter - class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: setOxThreadFactory(new PropagatingVirtualThreadFactory) @@ -25,23 +24,18 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: override def handleError(e: String): ExitCode = ??? - val exporter = InMemorySpanExporter.create() val tracerProvider = - - SdkTracerProvider.builder() - + SdkTracerProvider + .builder() .addSpanProcessor(SimpleSpanProcessor.create(exporter)) - .build() val openTelemetry = - - OpenTelemetrySdk.builder() - + OpenTelemetrySdk + .builder() .setTracerProvider(tracerProvider) - .build() val tracer = openTelemetry.getTracer("test") @@ -57,7 +51,6 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val flow = Flow.fromValues(1, 2, 3) - val spans = exporter.getFinishedSpanItems val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) @@ -70,7 +63,6 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val flow = Flow.fromValues(1, 2, 3) - val spannedFlow = flow.span(s"$prefix-span") spannedFlow.runDrain() @@ -105,7 +97,6 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: assert(thisTestSpans(1).getName == s"$prefix-span2") } - "flow.spanned" should "open only one span despite parralelized map" in { val prefix = UUID.randomUUID().toString @@ -127,18 +118,18 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: assert(span.getName == s"$prefix-span") } - "flow.spanned" should "properly end span in case of error" in { val prefix = UUID.randomUUID().toString val flow = Flow.fromValues(1, 2, 3) - - try { - flow.tap { case 2 => throw new Exception("boom"); case _ => () }.span(s"$prefix-span", beforeClose = (s, thr) => s.addEvent(thr.toString)).runToList() - } catch case _ => () - + try + flow + .tap { case 2 => throw new Exception("boom"); case _ => () } + .span(s"$prefix-span", beforeClose = (s, thr) => s.addEvent(thr.toString)) + .runToList() + catch case _ => () val spans = exporter.getFinishedSpanItems @@ -158,10 +149,8 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val spanned = flow.span(s"$prefix-span", beforeClose = (s, thr) => s.addEvent(thr.toString)) - try { - spanned.map { case 2 => throw new Exception("boom"); case n => n }.runToList() - } catch case _ => () - + try spanned.map { case 2 => throw new Exception("boom"); case n => n }.runToList() + catch case _ => () val spans = exporter.getFinishedSpanItems @@ -200,11 +189,8 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: assert(childrenSpans.size == 1) val parentSpan = parentSpans.head - for { - span <- childrenSpans - } yield { - assert(span.getParentSpanId == parentSpan.getSpanId) - } + for span <- childrenSpans + yield assert(span.getParentSpanId == parentSpan.getSpanId) } "flow.spanned" should "open several spans if flatMapped" in { @@ -229,11 +215,8 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: assert(childrenSpans.size == 3) val parentSpan = parentSpans.head - for { - span <- childrenSpans - } yield { - assert(span.getParentSpanId == parentSpan.getSpanId) - } + for span <- childrenSpans + yield assert(span.getParentSpanId == parentSpan.getSpanId) } "flow.spanned" should "properly end span in case of error2" in { @@ -244,10 +227,8 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val spanned = flow.span(s"$prefix-span") - spanned.map { case 2 => throw new Exception("boom"); case x => x }.recover(_ => 5).runToList() - val spans = exporter.getFinishedSpanItems val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) @@ -266,7 +247,9 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val spanned = span(flow)(s"$prefix-span") - val _ = span(spanned.map { case 2 => throw new Exception("boom"); case x => currentSpanUnsafe().addEvent(x.toString); x }.recover { x => currentSpanUnsafe().addEvent("recovery"); 5 })(s"$prefix-recover").runToList() + val _ = span(spanned.map { case 2 => throw new Exception("boom"); case x => currentSpanUnsafe().addEvent(x.toString); x }.recover { x => + currentSpanUnsafe().addEvent("recovery"); 5 + })(s"$prefix-recover").runToList() val spans = exporter.getFinishedSpanItems @@ -288,7 +271,6 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val spanned = span(flow)(s"$prefix-span") - val finalinzer = spanUnsafe(s"$prefix-parent") supervised { @@ -298,12 +280,13 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: finalinzer.close() val spans = exporter.getFinishedSpanItems - val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)).filterNot(s => s.getSpanId == finalinzer.span.getSpanContext.getSpanId) + val thisTestSpans = + spans.asScala.filter(_.getName.startsWith(prefix)).filterNot(s => s.getSpanId == finalinzer.span.getSpanContext.getSpanId) assert(thisTestSpans.size == 10) - thisTestSpans.foreach { - s => assert(s.getParentSpanId == finalinzer.span.getSpanContext.getSpanId) + thisTestSpans.foreach { s => + assert(s.getParentSpanId == finalinzer.span.getSpanContext.getSpanId) } } @@ -327,12 +310,13 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: finalinzer.close() val spans = exporter.getFinishedSpanItems - val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)).filterNot(s => s.getSpanId == finalinzer.span.getSpanContext.getSpanId) + val thisTestSpans = + spans.asScala.filter(_.getName.startsWith(prefix)).filterNot(s => s.getSpanId == finalinzer.span.getSpanContext.getSpanId) assert(thisTestSpans.size == 10) - thisTestSpans.foreach { - s => assert(s.getParentSpanId == finalinzer.span.getSpanContext.getSpanId) + thisTestSpans.foreach { s => + assert(s.getParentSpanId == finalinzer.span.getSpanContext.getSpanId) } } @@ -350,13 +334,16 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: supervised { (1 to 10).map { case 1 => - forkUser(span(flow)(s"$prefix-1-level1").flatMap { - el => - val innerFlow = supervised { - flow.span(s"$prefix-level2", beforeClose = _._1.addEvent("boomed")) + forkUser( + span(flow)(s"$prefix-1-level1") + .flatMap { el => + val innerFlow = supervised { + flow.span(s"$prefix-level2", beforeClose = _._1.addEvent("boomed")) + } + innerFlow } - innerFlow - }.runToList()) + .runToList() + ) case _ => forkUser(spanned.runToList()) } @@ -374,12 +361,12 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val level1Span = level1Spans.head assert(level2Spans.size == 3) - level1Spans.foreach { - s => assert(s.getParentSpanId == finalinzer.span.getSpanContext.getSpanId) + level1Spans.foreach { s => + assert(s.getParentSpanId == finalinzer.span.getSpanContext.getSpanId) } - level2Spans.foreach { - s => assert(s.getParentSpanId == level1_1.getSpanContext.getSpanId) + level2Spans.foreach { s => + assert(s.getParentSpanId == level1_1.getSpanContext.getSpanId) } } @@ -399,23 +386,28 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: println: supervised { (1 to 10).map { - case 1 => forkUser { - println: - (span(flow)(s"$prefix-1-level1").flatMap { - el => { - supervisedError(em): - either: - val innerFlow = { - em.pure(flow.span(s"$prefix-level2", beforeClose = _._1.addEvent(s"boomed at $el")).map { case 1 => em.pureError("ops").ok(); case x => x }) - } - innerFlow.ok() - }.toOption.getOrElse(Flow.empty) - }.runToList()) - } + case 1 => + forkUser { + println: + (span(flow)(s"$prefix-1-level1") + .flatMap { el => + { + supervisedError(em): + either: + val innerFlow = + em.pure(flow.span(s"$prefix-level2", beforeClose = _._1.addEvent(s"boomed at $el")).map { + case 1 => em.pureError("ops").ok(); case x => x + }) + innerFlow.ok() + }.toOption.getOrElse(Flow.empty) + } + .runToList()) + } case _ => forkUser(spanned.runToList()) } } catch case _ => () + end try finalinzer.close() val spans = exporter.getFinishedSpanItems @@ -427,20 +419,18 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: assert(level1Spans.size == 10) val level1Span = level1Spans.head - assert(level2Spans.size == 1) //because flow.flatMap is evaluated sequentially and exception ends whole flow + assert(level2Spans.size == 1) // because flow.flatMap is evaluated sequentially and exception ends whole flow - level1Spans.foreach { - s => assert(s.getParentSpanId == finalinzer.span.getSpanContext.getSpanId) + level1Spans.foreach { s => + assert(s.getParentSpanId == finalinzer.span.getSpanContext.getSpanId) } - level2Spans.foreach { - s => - assert(s.getParentSpanId == level1_1.getSpanContext.getSpanId) - assert(s.getEvents().get(0).getName.startsWith("boomed")) + level2Spans.foreach { s => + assert(s.getParentSpanId == level1_1.getSpanContext.getSpanId) + assert(s.getEvents().get(0).getName.startsWith("boomed")) } - } end OxTelemetryTest From 934872128789470e98b1ee5b7c087db78b6b5644 Mon Sep 17 00:00:00 2001 From: Pawel Sadlo Date: Fri, 9 Oct 2026 09:23:47 +0200 Subject: [PATCH 10/38] fmt --- .../ox/telemetry/tracing/internal/OxTracingBase.scala | 8 ++++---- otel/src/test/scala/OxTelemetryTest.scala | 8 +++----- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/otel/src/main/scala/ox/telemetry/tracing/internal/OxTracingBase.scala b/otel/src/main/scala/ox/telemetry/tracing/internal/OxTracingBase.scala index cb6f5822..eef190dc 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/internal/OxTracingBase.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/internal/OxTracingBase.scala @@ -18,10 +18,10 @@ private[tracing] trait OxTracingBase: extension [T](t: => T) def byNameSpan( spanName: String, - spanKind: SpanKind, - attributes: Attributes, - links: Seq[SpanContext], - beforeClose: ((Span, Option[Throwable])) => Span + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty, + beforeClose: ((Span, Option[Throwable])) => Span = _._1 ): T end extension end OxTracingBase diff --git a/otel/src/test/scala/OxTelemetryTest.scala b/otel/src/test/scala/OxTelemetryTest.scala index 044fed78..b10a8d8a 100644 --- a/otel/src/test/scala/OxTelemetryTest.scala +++ b/otel/src/test/scala/OxTelemetryTest.scala @@ -329,8 +329,6 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val spanned = span(flow)(s"$prefix-level1") - val finalinzer = spanUnsafe(s"$prefix-level0") - supervised { (1 to 10).map { case 1 => @@ -347,12 +345,12 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: case _ => forkUser(spanned.runToList()) } - } + }.byNameSpan(s"$prefix-level0") - finalinzer.close() val spans = exporter.getFinishedSpanItems val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) + val level0Spans = thisTestSpans.find(s => s.getName.endsWith("level0")).get val level1Spans = thisTestSpans.filter(s => s.getName.endsWith("level1")) val level2Spans = thisTestSpans.filter(s => s.getName.endsWith("level2")) @@ -362,7 +360,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: assert(level2Spans.size == 3) level1Spans.foreach { s => - assert(s.getParentSpanId == finalinzer.span.getSpanContext.getSpanId) + assert(s.getParentSpanId == level0Spans.getSpanContext.getSpanId) } level2Spans.foreach { s => From a7af6ddb158f928c412347ace7a08a7a30df0893 Mon Sep 17 00:00:00 2001 From: Pawel Sadlo Date: Mon, 12 Oct 2026 10:19:48 +0200 Subject: [PATCH 11/38] simplify --- core/src/main/scala/ox/flow/FlowOps.scala | 2 - .../scala/ox/telemetry/ContextHolder.scala | 13 ++ .../ox/telemetry/tracing/OxTracing.scala | 8 +- .../tracing/flow/OxFlowTracing.scala | 28 +-- otel/src/test/scala/OxTelemetryTest.scala | 165 ++++++++++++++++-- 5 files changed, 169 insertions(+), 47 deletions(-) diff --git a/core/src/main/scala/ox/flow/FlowOps.scala b/core/src/main/scala/ox/flow/FlowOps.scala index 23de8a7e..92cde0c8 100644 --- a/core/src/main/scala/ox/flow/FlowOps.scala +++ b/core/src/main/scala/ox/flow/FlowOps.scala @@ -293,8 +293,6 @@ class FlowOps[+T]: val base: Flow[U] = expand[U](u => Iterator.single(u) ++ extrapolator(u)) initial.fold(base)(e => Flow.fromValues(e).concat(base)) - // - /** Applies the given mapping function `f` to each element emitted by this flow. The returned flow then emits the results. * * @param f diff --git a/otel/src/main/scala/ox/telemetry/ContextHolder.scala b/otel/src/main/scala/ox/telemetry/ContextHolder.scala index 55b19695..f2359553 100644 --- a/otel/src/main/scala/ox/telemetry/ContextHolder.scala +++ b/otel/src/main/scala/ox/telemetry/ContextHolder.scala @@ -1,6 +1,8 @@ package ox.telemetry import io.opentelemetry.context.Context +import ox.ForkLocal +import ox.internal.currentLocals import ox.otel.context.PropagatingVirtualThreadFactory trait ContextHolder: @@ -26,4 +28,15 @@ object ContextHolder: System.err.println(s"Otel should use ${classOf[PropagatingVirtualThreadFactory].getName}") checked = true end Native + + val contextForkLocal = ForkLocal(Context.current()) + + object ForkLocalBased extends ContextHolder: + + override def get(): Context = contextForkLocal.get() + + override def set(context: Context): Unit = + currentLocals.set(contextForkLocal, context): Unit + + end ForkLocalBased end ContextHolder diff --git a/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala b/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala index bc7ed5f7..6f024f82 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala @@ -2,10 +2,8 @@ package ox.telemetry.tracing import io.opentelemetry.api.common.Attributes import io.opentelemetry.api.trace.{Span, SpanKind, Tracer} -import ox.flow.Flow import ox.telemetry.ContextHolder import ox.telemetry.tracing.flow.OxFlowTracing -import ox.telemetry.tracing.flow.OxFlowTracing.OxFlowTracingImpl import ox.telemetry.tracing.internal.OxTracingBase sealed trait OxTracing extends OxTracingBase, OxFlowTracing @@ -14,10 +12,8 @@ object OxTracing: def native(tracer: Tracer): OxTracing = val ch = ContextHolder.Native val base = OxTracingBase(tracer, ch) - val oxFlowTracing = OxFlowTracingImpl(base) - Impl(base, oxFlowTracing) + Impl(base) - private class Impl(base: OxTracingBase, flow: OxFlowTracing) extends OxTracing: - export flow.* + private class Impl(base: OxTracingBase) extends OxTracing: export base.* end OxTracing diff --git a/otel/src/main/scala/ox/telemetry/tracing/flow/OxFlowTracing.scala b/otel/src/main/scala/ox/telemetry/tracing/flow/OxFlowTracing.scala index cc4f423e..e2a3dcb4 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/flow/OxFlowTracing.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/flow/OxFlowTracing.scala @@ -6,6 +6,7 @@ import ox.flow.Flow import ox.telemetry.tracing.internal.OxTracingBase private[tracing] trait OxFlowTracing: + self: OxTracingBase => extension [T](flow: Flow[T]) def span( spanName: String, @@ -13,26 +14,11 @@ private[tracing] trait OxFlowTracing: attributes: Attributes = Attributes.empty(), links: Seq[SpanContext] = Seq.empty, beforeClose: ((Span, Option[Throwable])) => Span = _._1 - ): Flow[T] + ): Flow[T] = Flow.usingEmit: emit => + self.byNameSpan( + flow.runToEmit( + emit + ) + )(spanName, spanKind, attributes, links, beforeClose) end extension end OxFlowTracing - -private[tracing] object OxFlowTracing: - private[tracing] final class OxFlowTracingImpl(base: OxTracingBase) extends OxFlowTracing: - extension [T](flow: Flow[T]) - override def span( - spanName: String, - spanKind: SpanKind, - attributes: Attributes, - links: Seq[SpanContext], - beforeClose: ((Span, Option[Throwable])) => Span - ): Flow[T] = - Flow.usingEmit: emit => - base.byNameSpan( - flow.runToEmit( - emit - ) - )(spanName, spanKind, attributes, links, beforeClose) - end extension - end OxFlowTracingImpl -end OxFlowTracing diff --git a/otel/src/test/scala/OxTelemetryTest.scala b/otel/src/test/scala/OxTelemetryTest.scala index b10a8d8a..abeba489 100644 --- a/otel/src/test/scala/OxTelemetryTest.scala +++ b/otel/src/test/scala/OxTelemetryTest.scala @@ -15,9 +15,11 @@ import ox.telemetry.tracing.OxTracing import java.util.UUID import scala.collection.JavaConverters.asScalaBufferConverter +import scala.util.boundary.Label class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: - setOxThreadFactory(new PropagatingVirtualThreadFactory) + override def beforeAll(): Unit = + setOxThreadFactory(new PropagatingVirtualThreadFactory) class My extends OxApp.WithEitherErrors[String] /* , OtelOxApp.WithOtelSupport */: override def run(args: Vector[String])(using Ox, EitherError[String]): ExitCode = ??? @@ -47,6 +49,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: import myTracing.* "flow.spanned" should "not open span if not runned" in { + val prefix = UUID.randomUUID().toString val flow = Flow.fromValues(1, 2, 3) @@ -381,29 +384,156 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val spanned = span(flow)(s"$prefix-level1") try - println: - supervised { - (1 to 10).map { - case 1 => - forkUser { - println: + supervised { + (1 to 10).map { + case 1 => + forkUser { + (span(flow)(s"$prefix-1-level1") + .flatMap { el => + { + + val inner = supervisedError(em): + either: + val innerFlow = + em.pure(flow.span(s"$prefix-level2", beforeClose = _._1.addEvent(s"boomed at $el")).map { + case 1 => em.pureError("ops").ok(); + case x => x + }) + innerFlow.ok() + println(inner) + inner + }.toOption.getOrElse(Flow.empty) + } + .runToList()) + } + case _ => forkUser(spanned.runToList()) + } + } + catch case _ => () + end try + + finalinzer.close() + val spans = exporter.getFinishedSpanItems + + val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) + val level1Spans = thisTestSpans.filter(s => s.getName.endsWith("level1")) + val level2Spans = thisTestSpans.filter(s => s.getName.endsWith("level2")) + val level1_1 = level1Spans.find(s => s.getName.endsWith("1-level1")).get + + assert(level1Spans.size == 10) + val level1Span = level1Spans.head + assert(level2Spans.size == 1) // because flow.flatMap is evaluated sequentially and exception ends whole flow + + level1Spans.foreach { s => + assert(s.getParentSpanId == finalinzer.span.getSpanContext.getSpanId) + } + + level2Spans.foreach { s => + assert(s.getParentSpanId == level1_1.getSpanContext.getSpanId) + assert(s.getEvents().get(0).getName.startsWith("boomed")) + + } + + } + + "flow.spanned" should "properly end span in case of ejknfdssrror2fdffssfss" in { + + val em = EitherMode[String] + val prefix = UUID.randomUUID().toString + + val flow = Flow.fromValues(1, 2, 3) + + val finalinzer = spanUnsafe(s"$prefix-level0") + + val spanned = span(flow)(s"$prefix-level1") + + try + supervised { + (1 to 10).map { + case 1 => + forkUser { + (span(flow)(s"$prefix-1-level1") + .flatMap { el => + { + val inner = supervisedError(em): + either: + val innerFlow = + em.pure(flow.span(s"$prefix-level2", beforeClose = _._1.addEvent(s"boomed at $el")).map { + case 1 => em.pureError("ops").ok(); + case x => x + }) + innerFlow.ok() + println(inner) + inner + }.toOption.getOrElse(Flow.empty) + } + .runToList()) + } + case _ => forkUser(spanned.runToList()) + } + } + catch case _ => () + end try + + finalinzer.close() + val spans = exporter.getFinishedSpanItems + + val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) + val level1Spans = thisTestSpans.filter(s => s.getName.endsWith("level1")) + val level2Spans = thisTestSpans.filter(s => s.getName.endsWith("level2")) + val level1_1 = level1Spans.find(s => s.getName.endsWith("1-level1")).get + + assert(level1Spans.size == 10) + val level1Span = level1Spans.head + assert(level2Spans.size == 1) // because flow.flatMap is evaluated sequentially and exception ends whole flow + + level1Spans.foreach { s => + assert(s.getParentSpanId == finalinzer.span.getSpanContext.getSpanId) + } + + level2Spans.foreach { s => + assert(s.getParentSpanId == level1_1.getSpanContext.getSpanId) + assert(s.getEvents().get(0).getName.startsWith("boomed")) + + } + + } + + "flow.spanned" should "properly end span in case of ejknrrorfdsd2fdffssfss" in { + + val em = EitherMode[String] + val prefix = UUID.randomUUID().toString + + val flow = Flow.fromValues(1, 2, 3) + + val finalinzer = spanUnsafe(s"$prefix-level0") + + val spanned = span(flow)(s"$prefix-level1") + + try + supervisedError(em) { + either: + em.pure: + (1 to 10).map { + case 1 => + forkUser { (span(flow)(s"$prefix-1-level1") .flatMap { el => { supervisedError(em): - either: - val innerFlow = - em.pure(flow.span(s"$prefix-level2", beforeClose = _._1.addEvent(s"boomed at $el")).map { - case 1 => em.pureError("ops").ok(); case x => x - }) - innerFlow.ok() + val innerFlow = + em.pure(flow.span(s"$prefix-level2", beforeClose = _._1.addEvent(s"boomed at $el")).map { + case 1 => em.pureError("ops").ok(); + case x => x + }) + innerFlow }.toOption.getOrElse(Flow.empty) } .runToList()) - } - case _ => forkUser(spanned.runToList()) - } - } + } + case _ => forkUser(spanned.runToList()) + } + } catch case _ => () end try @@ -430,5 +560,4 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: } } - end OxTelemetryTest From 2dcecb8f23d0919c312ae7d84b440bd5877776d4 Mon Sep 17 00:00:00 2001 From: Pawel Sadlo Date: Mon, 12 Oct 2026 10:30:34 +0200 Subject: [PATCH 12/38] docs --- .../main/scala/ox/telemetry/ContextHolder.scala | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/otel/src/main/scala/ox/telemetry/ContextHolder.scala b/otel/src/main/scala/ox/telemetry/ContextHolder.scala index f2359553..3f1e4d15 100644 --- a/otel/src/main/scala/ox/telemetry/ContextHolder.scala +++ b/otel/src/main/scala/ox/telemetry/ContextHolder.scala @@ -1,16 +1,18 @@ package ox.telemetry import io.opentelemetry.context.Context -import ox.ForkLocal -import ox.internal.currentLocals import ox.otel.context.PropagatingVirtualThreadFactory +/** + * Abstraction managing an OpenTelemetry [[Context]] + */ trait ContextHolder: def get(): Context def set(context: Context): Unit object ContextHolder: + object Native extends ContextHolder: private var checked = false @@ -28,15 +30,4 @@ object ContextHolder: System.err.println(s"Otel should use ${classOf[PropagatingVirtualThreadFactory].getName}") checked = true end Native - - val contextForkLocal = ForkLocal(Context.current()) - - object ForkLocalBased extends ContextHolder: - - override def get(): Context = contextForkLocal.get() - - override def set(context: Context): Unit = - currentLocals.set(contextForkLocal, context): Unit - - end ForkLocalBased end ContextHolder From 27019aa1fd63e95af57e5db38cd3c4ad20fd4414 Mon Sep 17 00:00:00 2001 From: Pawel Sadlo Date: Mon, 12 Oct 2026 10:31:52 +0200 Subject: [PATCH 13/38] docs --- otel/src/main/scala/ox/telemetry/ContextHolder.scala | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/otel/src/main/scala/ox/telemetry/ContextHolder.scala b/otel/src/main/scala/ox/telemetry/ContextHolder.scala index 3f1e4d15..dedf635f 100644 --- a/otel/src/main/scala/ox/telemetry/ContextHolder.scala +++ b/otel/src/main/scala/ox/telemetry/ContextHolder.scala @@ -3,9 +3,8 @@ package ox.telemetry import io.opentelemetry.context.Context import ox.otel.context.PropagatingVirtualThreadFactory -/** - * Abstraction managing an OpenTelemetry [[Context]] - */ +/** Abstraction managing an OpenTelemetry [[Context]] + */ trait ContextHolder: def get(): Context From 8544e278fba6dbe3a18e419c2feca02497016ed0 Mon Sep 17 00:00:00 2001 From: Pawel Sadlo Date: Mon, 12 Oct 2026 10:42:51 +0200 Subject: [PATCH 14/38] fix tests --- otel/src/test/scala/OxTelemetryTest.scala | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/otel/src/test/scala/OxTelemetryTest.scala b/otel/src/test/scala/OxTelemetryTest.scala index abeba489..a0f72623 100644 --- a/otel/src/test/scala/OxTelemetryTest.scala +++ b/otel/src/test/scala/OxTelemetryTest.scala @@ -303,13 +303,15 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val finalinzer = spanUnsafe(s"$prefix-parent") - supervised { - (1 to 10).foreach { - case 1 => fork(spanned.tap(_ => throw new Exception("fds")).runToList()) - case _ => fork(spanned.runToList()) + try + supervised { + (1 to 10).foreach { + case 1 => forkUser(spanned.tap(_ => throw new Exception("fds")).runToList()) + case _ => forkUser(spanned.runToList()) + } } - } - + catch case _ => () + end try finalinzer.close() val spans = exporter.getFinishedSpanItems From 2d8aba3932688a72cd14b16b199a8cfa9255cc33 Mon Sep 17 00:00:00 2001 From: Pawel Sadlo Date: Mon, 12 Oct 2026 10:51:21 +0200 Subject: [PATCH 15/38] fix tests --- otel/src/test/scala/OxTelemetryTest.scala | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/otel/src/test/scala/OxTelemetryTest.scala b/otel/src/test/scala/OxTelemetryTest.scala index a0f72623..b5c4bd97 100644 --- a/otel/src/test/scala/OxTelemetryTest.scala +++ b/otel/src/test/scala/OxTelemetryTest.scala @@ -390,10 +390,9 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: (1 to 10).map { case 1 => forkUser { - (span(flow)(s"$prefix-1-level1") + span(flow)(s"$prefix-1-level1") .flatMap { el => { - val inner = supervisedError(em): either: val innerFlow = @@ -402,11 +401,10 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: case x => x }) innerFlow.ok() - println(inner) inner }.toOption.getOrElse(Flow.empty) } - .runToList()) + .runToList() } case _ => forkUser(spanned.runToList()) } @@ -423,15 +421,14 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val level1_1 = level1Spans.find(s => s.getName.endsWith("1-level1")).get assert(level1Spans.size == 10) - val level1Span = level1Spans.head assert(level2Spans.size == 1) // because flow.flatMap is evaluated sequentially and exception ends whole flow level1Spans.foreach { s => - assert(s.getParentSpanId == finalinzer.span.getSpanContext.getSpanId) + assert(s.getParentSpanId == finalinzer.span.getSpanContext.getSpanId, "level1 spans id") } level2Spans.foreach { s => - assert(s.getParentSpanId == level1_1.getSpanContext.getSpanId) + assert(s.getParentSpanId == level1_1.getSpanContext.getSpanId, "level2 spans id") assert(s.getEvents().get(0).getName.startsWith("boomed")) } From 06161a08bad39b242c51328cc822c87acabcc3dc Mon Sep 17 00:00:00 2001 From: Pawel Sadlo Date: Mon, 12 Oct 2026 11:04:41 +0200 Subject: [PATCH 16/38] fix tests --- otel/src/test/scala/OxTelemetryTest.scala | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/otel/src/test/scala/OxTelemetryTest.scala b/otel/src/test/scala/OxTelemetryTest.scala index b5c4bd97..a8c097d5 100644 --- a/otel/src/test/scala/OxTelemetryTest.scala +++ b/otel/src/test/scala/OxTelemetryTest.scala @@ -325,7 +325,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: } } - "flow.spanned" should "properly end span in case of ejknrror2fdsfsfdffssfss" in { + "flow.spanned" should "properly end span in case of ejknrror2fdsfsfdffssfss" in { // flaky val em = EitherMode[String] val prefix = UUID.randomUUID().toString @@ -402,7 +402,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: }) innerFlow.ok() inner - }.toOption.getOrElse(Flow.empty) + }.toOption.get } .runToList() } @@ -498,7 +498,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: } - "flow.spanned" should "properly end span in case of ejknrrorfdsd2fdffssfss" in { + "flow.spanned" should "properly end span in case of ejknrrorfdsd2fdffssfss" in { // flaky val em = EitherMode[String] val prefix = UUID.randomUUID().toString From 885125ac261ad5e66926b84a0f39656499f86cab Mon Sep 17 00:00:00 2001 From: Pawel Sadlo Date: Mon, 12 Oct 2026 11:59:11 +0200 Subject: [PATCH 17/38] fix tests --- otel/src/test/scala/OxTelemetryTest.scala | 145 +++++++++++++++------- 1 file changed, 103 insertions(+), 42 deletions(-) diff --git a/otel/src/test/scala/OxTelemetryTest.scala b/otel/src/test/scala/OxTelemetryTest.scala index a8c097d5..f4193267 100644 --- a/otel/src/test/scala/OxTelemetryTest.scala +++ b/otel/src/test/scala/OxTelemetryTest.scala @@ -6,6 +6,7 @@ import io.opentelemetry.sdk.trace.`export`.SimpleSpanProcessor import org.scalatest.BeforeAndAfterAll import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers +import org.scalatest.time.SpanSugar.convertIntToGrainOfTime import ox.* import ox.channels.BufferCapacity import ox.either.ok @@ -14,6 +15,7 @@ import ox.otel.context.PropagatingVirtualThreadFactory import ox.telemetry.tracing.OxTracing import java.util.UUID +import java.util.concurrent.Semaphore import scala.collection.JavaConverters.asScalaBufferConverter import scala.util.boundary.Label @@ -193,7 +195,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val parentSpan = parentSpans.head for span <- childrenSpans - yield assert(span.getParentSpanId == parentSpan.getSpanId) + yield assert(span.getParentSpanId == parentSpan.getSpanId) } "flow.spanned" should "open several spans if flatMapped" in { @@ -219,7 +221,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val parentSpan = parentSpans.head for span <- childrenSpans - yield assert(span.getParentSpanId == parentSpan.getSpanId) + yield assert(span.getParentSpanId == parentSpan.getSpanId) } "flow.spanned" should "properly end span in case of error2" in { @@ -318,8 +320,6 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)).filterNot(s => s.getSpanId == finalinzer.span.getSpanContext.getSpanId) - assert(thisTestSpans.size == 10) - thisTestSpans.foreach { s => assert(s.getParentSpanId == finalinzer.span.getSpanContext.getSpanId) } @@ -391,18 +391,17 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: case 1 => forkUser { span(flow)(s"$prefix-1-level1") - .flatMap { el => - { - val inner = supervisedError(em): - either: - val innerFlow = - em.pure(flow.span(s"$prefix-level2", beforeClose = _._1.addEvent(s"boomed at $el")).map { - case 1 => em.pureError("ops").ok(); - case x => x - }) - innerFlow.ok() - inner - }.toOption.get + .flatMap { el => { + val inner = supervisedError(em): + either: + val innerFlow = + em.pure(flow.span(s"$prefix-level2", beforeClose = _._1.addEvent(s"boomed at $el")).map { + case 1 => em.pureError("ops").ok(); + case x => x + }) + innerFlow.ok() + inner + }.toOption.get } .runToList() } @@ -420,7 +419,6 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val level2Spans = thisTestSpans.filter(s => s.getName.endsWith("level2")) val level1_1 = level1Spans.find(s => s.getName.endsWith("1-level1")).get - assert(level1Spans.size == 10) assert(level2Spans.size == 1) // because flow.flatMap is evaluated sequentially and exception ends whole flow level1Spans.foreach { s => @@ -452,19 +450,18 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: case 1 => forkUser { (span(flow)(s"$prefix-1-level1") - .flatMap { el => - { - val inner = supervisedError(em): - either: - val innerFlow = - em.pure(flow.span(s"$prefix-level2", beforeClose = _._1.addEvent(s"boomed at $el")).map { - case 1 => em.pureError("ops").ok(); - case x => x - }) - innerFlow.ok() - println(inner) - inner - }.toOption.getOrElse(Flow.empty) + .flatMap { el => { + val inner = supervisedError(em): + either: + val innerFlow = + em.pure(flow.span(s"$prefix-level2", beforeClose = _._1.addEvent(s"boomed at $el")).map { + case 1 => em.pureError("ops").ok(); + case x => x + }) + innerFlow.ok() + println(inner) + inner + }.toOption.getOrElse(Flow.empty) } .runToList()) } @@ -482,7 +479,6 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val level2Spans = thisTestSpans.filter(s => s.getName.endsWith("level2")) val level1_1 = level1Spans.find(s => s.getName.endsWith("1-level1")).get - assert(level1Spans.size == 10) val level1Span = level1Spans.head assert(level2Spans.size == 1) // because flow.flatMap is evaluated sequentially and exception ends whole flow @@ -517,16 +513,15 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: case 1 => forkUser { (span(flow)(s"$prefix-1-level1") - .flatMap { el => - { - supervisedError(em): - val innerFlow = - em.pure(flow.span(s"$prefix-level2", beforeClose = _._1.addEvent(s"boomed at $el")).map { - case 1 => em.pureError("ops").ok(); - case x => x - }) - innerFlow - }.toOption.getOrElse(Flow.empty) + .flatMap { el => { + supervisedError(em): + val innerFlow = + em.pure(flow.span(s"$prefix-level2", beforeClose = _._1.addEvent(s"boomed at $el")).map { + case 1 => em.pureError("ops").ok(); + case x => x + }) + innerFlow + }.toOption.get } .runToList()) } @@ -544,7 +539,6 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val level2Spans = thisTestSpans.filter(s => s.getName.endsWith("level2")) val level1_1 = level1Spans.find(s => s.getName.endsWith("1-level1")).get - assert(level1Spans.size == 10) val level1Span = level1Spans.head assert(level2Spans.size == 1) // because flow.flatMap is evaluated sequentially and exception ends whole flow @@ -559,4 +553,71 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: } } + + + "flow.spanned" should "properly end span even if enclosing supervised scope fails before stream completion" in { + + val prefix = UUID.randomUUID().toString + + val semaphore = Semaphore(0) + + + val flow = Flow.fromValues(1, 2, 3) + + try + supervised { + val failingFork = forkUser { + semaphore.acquire() + throw new Exception("oops") + + } + val forkWithSpan = forkUser { + span(flow)(s"$prefix-level1").tap { case 1 => semaphore.release(); case _ => sleep(100.seconds) }.runToList() + + } + + } + + catch case err => () + end try + + val spans = exporter.getFinishedSpanItems + + val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) + + + assert(thisTestSpans.size == 1) + + + } + + + "flow.spanned" should "properly end span even if cancelled" in { + + val prefix = UUID.randomUUID().toString + + val semaphore = Semaphore(0) + + + val flow = Flow.fromValues(1, 2, 3) + + supervised { + val forkWithSpan = forkCancellable { + span(flow)(s"$prefix-level1").tap { case 1 => semaphore.release(); case _ => sleep(100.seconds) }.runToList() + + } + semaphore.acquire() + forkWithSpan.cancelNow() + + } + + val spans = exporter.getFinishedSpanItems + + val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) + + + assert(thisTestSpans.size == 1) + + + } end OxTelemetryTest From bd812aa06a36d74fcf35a5104a9fadac1a898a2e Mon Sep 17 00:00:00 2001 From: Pawel Sadlo Date: Mon, 12 Oct 2026 13:08:15 +0200 Subject: [PATCH 18/38] fix tests --- otel/src/test/scala/OxTelemetryTest.scala | 93 +++++++++++------------ 1 file changed, 44 insertions(+), 49 deletions(-) diff --git a/otel/src/test/scala/OxTelemetryTest.scala b/otel/src/test/scala/OxTelemetryTest.scala index f4193267..12dcd6d9 100644 --- a/otel/src/test/scala/OxTelemetryTest.scala +++ b/otel/src/test/scala/OxTelemetryTest.scala @@ -195,7 +195,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val parentSpan = parentSpans.head for span <- childrenSpans - yield assert(span.getParentSpanId == parentSpan.getSpanId) + yield assert(span.getParentSpanId == parentSpan.getSpanId) } "flow.spanned" should "open several spans if flatMapped" in { @@ -221,7 +221,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val parentSpan = parentSpans.head for span <- childrenSpans - yield assert(span.getParentSpanId == parentSpan.getSpanId) + yield assert(span.getParentSpanId == parentSpan.getSpanId) } "flow.spanned" should "properly end span in case of error2" in { @@ -391,17 +391,18 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: case 1 => forkUser { span(flow)(s"$prefix-1-level1") - .flatMap { el => { - val inner = supervisedError(em): - either: - val innerFlow = - em.pure(flow.span(s"$prefix-level2", beforeClose = _._1.addEvent(s"boomed at $el")).map { - case 1 => em.pureError("ops").ok(); - case x => x - }) - innerFlow.ok() - inner - }.toOption.get + .flatMap { el => + { + val inner = supervisedError(em): + either: + val innerFlow = + em.pure(flow.span(s"$prefix-level2", beforeClose = _._1.addEvent(s"boomed at $el")).map { + case 1 => em.pureError("ops").ok(); + case x => x + }) + innerFlow.ok() + inner + }.toOption.get } .runToList() } @@ -450,18 +451,19 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: case 1 => forkUser { (span(flow)(s"$prefix-1-level1") - .flatMap { el => { - val inner = supervisedError(em): - either: - val innerFlow = - em.pure(flow.span(s"$prefix-level2", beforeClose = _._1.addEvent(s"boomed at $el")).map { - case 1 => em.pureError("ops").ok(); - case x => x - }) - innerFlow.ok() - println(inner) - inner - }.toOption.getOrElse(Flow.empty) + .flatMap { el => + { + val inner = supervisedError(em): + either: + val innerFlow = + em.pure(flow.span(s"$prefix-level2", beforeClose = _._1.addEvent(s"boomed at $el")).map { + case 1 => em.pureError("ops").ok(); + case x => x + }) + innerFlow.ok() + println(inner) + inner + }.toOption.getOrElse(Flow.empty) } .runToList()) } @@ -513,15 +515,16 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: case 1 => forkUser { (span(flow)(s"$prefix-1-level1") - .flatMap { el => { - supervisedError(em): - val innerFlow = - em.pure(flow.span(s"$prefix-level2", beforeClose = _._1.addEvent(s"boomed at $el")).map { - case 1 => em.pureError("ops").ok(); - case x => x - }) - innerFlow - }.toOption.get + .flatMap { el => + { + supervisedError(em): + val innerFlow = + em.pure(flow.span(s"$prefix-level2", beforeClose = _._1.addEvent(s"boomed at $el")).map { + case 1 => em.pureError("ops").ok(); + case x => x + }) + innerFlow + }.toOption.get } .runToList()) } @@ -554,14 +557,12 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: } - "flow.spanned" should "properly end span even if enclosing supervised scope fails before stream completion" in { val prefix = UUID.randomUUID().toString val semaphore = Semaphore(0) - val flow = Flow.fromValues(1, 2, 3) try @@ -585,39 +586,33 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) - assert(thisTestSpans.size == 1) - } - "flow.spanned" should "properly end span even if cancelled" in { val prefix = UUID.randomUUID().toString val semaphore = Semaphore(0) - val flow = Flow.fromValues(1, 2, 3) - supervised { - val forkWithSpan = forkCancellable { - span(flow)(s"$prefix-level1").tap { case 1 => semaphore.release(); case _ => sleep(100.seconds) }.runToList() - - } - semaphore.acquire() - forkWithSpan.cancelNow() + supervised { + val forkWithSpan = forkCancellable { + span(flow)(s"$prefix-level1").tap { case 1 => semaphore.release(); case _ => sleep(100.seconds) }.runToList() } + semaphore.acquire() + forkWithSpan.cancelNow() + + } val spans = exporter.getFinishedSpanItems val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) - assert(thisTestSpans.size == 1) - } end OxTelemetryTest From dabd0dfc8b2cc392a38e76c86a88efcec35a9198 Mon Sep 17 00:00:00 2001 From: Pawel Sadlo Date: Mon, 12 Oct 2026 14:19:28 +0200 Subject: [PATCH 19/38] de-flake tests --- otel/src/test/scala/OxTelemetryTest.scala | 38 +++++++++++------------ 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/otel/src/test/scala/OxTelemetryTest.scala b/otel/src/test/scala/OxTelemetryTest.scala index 12dcd6d9..9bcba131 100644 --- a/otel/src/test/scala/OxTelemetryTest.scala +++ b/otel/src/test/scala/OxTelemetryTest.scala @@ -52,7 +52,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: "flow.spanned" should "not open span if not runned" in { - val prefix = UUID.randomUUID().toString + val prefix = s"[${UUID.randomUUID()}]" val flow = Flow.fromValues(1, 2, 3) @@ -64,7 +64,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: } "flow.spanned" should "open one span even if flow has multiple elements" in { - val prefix = UUID.randomUUID().toString + val prefix = s"[${UUID.randomUUID()}]" val flow = Flow.fromValues(1, 2, 3) @@ -83,7 +83,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: } "flow.spanned" should "open two spans for two invocations" in { - val prefix = UUID.randomUUID().toString + val prefix = s"[${UUID.randomUUID()}]" val flow = Flow.fromValues(1, 2, 3) @@ -104,7 +104,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: "flow.spanned" should "open only one span despite parralelized map" in { - val prefix = UUID.randomUUID().toString + val prefix = s"[${UUID.randomUUID()}]" val flow = Flow.fromValues(1, 2, 3) @@ -125,7 +125,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: "flow.spanned" should "properly end span in case of error" in { - val prefix = UUID.randomUUID().toString + val prefix = s"[${UUID.randomUUID()}]" val flow = Flow.fromValues(1, 2, 3) @@ -148,7 +148,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: } "flow.spanned" should "properly end span in case of error if mapped after span" in { - val prefix = UUID.randomUUID().toString + val prefix = s"[${UUID.randomUUID()}]" val flow = Flow.fromValues(1, 2, 3) @@ -170,7 +170,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: "flow.spanned" should "correctly picks parent span" in { - val prefix = UUID.randomUUID().toString + val prefix = s"[${UUID.randomUUID()}]" val flow = Flow.fromValues(1, 2, 3) @@ -200,7 +200,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: "flow.spanned" should "open several spans if flatMapped" in { - val prefix = UUID.randomUUID().toString + val prefix = s"[${UUID.randomUUID()}]" val flow = Flow.fromValues(1, 2, 3) @@ -226,7 +226,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: "flow.spanned" should "properly end span in case of error2" in { - val prefix = UUID.randomUUID().toString + val prefix = s"[${UUID.randomUUID()}]" val flow = Flow.fromValues(1, 2, 3) @@ -246,7 +246,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: } "flow.spanned" should "properly end span in case of error2fd" in { - val prefix = UUID.randomUUID().toString + val prefix = s"[${UUID.randomUUID()}]" val flow = Flow.fromValues(1, 2, 3) @@ -270,7 +270,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: "flow.spanned" should "properly end span in case of error2fdfs" in { - val prefix = UUID.randomUUID().toString + val prefix = s"[${UUID.randomUUID()}]" val flow = Flow.fromValues(1, 2, 3) @@ -297,7 +297,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: "flow.spanned" should "properly end span in case of ejknrror2fdfs" in { - val prefix = UUID.randomUUID().toString + val prefix = s"[${UUID.randomUUID()}]" val flow = Flow.fromValues(1, 2, 3) @@ -328,7 +328,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: "flow.spanned" should "properly end span in case of ejknrror2fdsfsfdffssfss" in { // flaky val em = EitherMode[String] - val prefix = UUID.randomUUID().toString + val prefix = s"[${UUID.randomUUID()}]" val flow = Flow.fromValues(1, 2, 3) @@ -377,7 +377,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: "flow.spanned" should "properly end span in case of ejknrror2fdffssfss" in { val em = EitherMode[String] - val prefix = UUID.randomUUID().toString + val prefix = s"[${UUID.randomUUID()}]" val flow = Flow.fromValues(1, 2, 3) @@ -437,7 +437,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: "flow.spanned" should "properly end span in case of ejknfdssrror2fdffssfss" in { val em = EitherMode[String] - val prefix = UUID.randomUUID().toString + val prefix = s"[${UUID.randomUUID()}]" val flow = Flow.fromValues(1, 2, 3) @@ -481,7 +481,6 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val level2Spans = thisTestSpans.filter(s => s.getName.endsWith("level2")) val level1_1 = level1Spans.find(s => s.getName.endsWith("1-level1")).get - val level1Span = level1Spans.head assert(level2Spans.size == 1) // because flow.flatMap is evaluated sequentially and exception ends whole flow level1Spans.foreach { s => @@ -489,6 +488,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: } level2Spans.foreach { s => + if s.getParentSpanId != level1_1.getSpanContext.getSpanId then println("fsfd") assert(s.getParentSpanId == level1_1.getSpanContext.getSpanId) assert(s.getEvents().get(0).getName.startsWith("boomed")) @@ -499,7 +499,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: "flow.spanned" should "properly end span in case of ejknrrorfdsd2fdffssfss" in { // flaky val em = EitherMode[String] - val prefix = UUID.randomUUID().toString + val prefix = s"[${UUID.randomUUID()}]" val flow = Flow.fromValues(1, 2, 3) @@ -559,7 +559,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: "flow.spanned" should "properly end span even if enclosing supervised scope fails before stream completion" in { - val prefix = UUID.randomUUID().toString + val prefix = s"[${UUID.randomUUID()}]" val semaphore = Semaphore(0) @@ -592,7 +592,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: "flow.spanned" should "properly end span even if cancelled" in { - val prefix = UUID.randomUUID().toString + val prefix = s"[${UUID.randomUUID()}]" val semaphore = Semaphore(0) From 9fafdac26e6d78be4b40713623373d3381128f0a Mon Sep 17 00:00:00 2001 From: Pawel Sadlo Date: Tue, 13 Oct 2026 11:12:16 +0200 Subject: [PATCH 20/38] some docs, close scope properly to reduce warnings when Test / javaOptions += "-Dio.opentelemetry.context.enableStrictContext=true", is on --- .../scala/ox/telemetry/ContextHolder.scala | 30 +++++--- .../telemetry/tracing/AutoCloseableSpan.scala | 13 ++-- .../ox/telemetry/tracing/OxTracing.scala | 15 +++- .../tracing/internal/OxTracingBase.scala | 77 ++++++++++++------- .../scala/ox/telemetry/tracing/tracing.scala | 26 +++++++ otel/src/test/scala/OxTelemetryTest.scala | 50 +++++++++--- 6 files changed, 154 insertions(+), 57 deletions(-) create mode 100644 otel/src/main/scala/ox/telemetry/tracing/tracing.scala diff --git a/otel/src/main/scala/ox/telemetry/ContextHolder.scala b/otel/src/main/scala/ox/telemetry/ContextHolder.scala index dedf635f..37d85186 100644 --- a/otel/src/main/scala/ox/telemetry/ContextHolder.scala +++ b/otel/src/main/scala/ox/telemetry/ContextHolder.scala @@ -1,32 +1,40 @@ package ox.telemetry -import io.opentelemetry.context.Context +import io.opentelemetry.context.{Context, Scope} import ox.otel.context.PropagatingVirtualThreadFactory /** Abstraction managing an OpenTelemetry [[Context]] - */ -trait ContextHolder: + */ +sealed trait ContextHolder: def get(): Context - def set(context: Context): Unit + def set(context: Context): Scope -object ContextHolder: +private[telemetry] object ContextHolder: - object Native extends ContextHolder: + private[telemetry] object Delegating extends ContextHolder: + + override def get(): Context = Context.current() + + override def set(context: Context): Scope = context.makeCurrent() + + + private[telemetry] object CheckingThreadFactory extends ContextHolder: private var checked = false + private val wrapped: ContextHolder = Delegating override def get(): Context = checkThreadFactory() - Context.current() + wrapped.get() - override def set(context: Context): Unit = + override def set(context: Context): Scope = checkThreadFactory() - context.makeCurrent(): Unit + wrapped.set(context) private def checkThreadFactory() = if !checked then if !ox.oxThreadFactory.isInstanceOf[PropagatingVirtualThreadFactory] then - System.err.println(s"Otel should use ${classOf[PropagatingVirtualThreadFactory].getName}") + System.err.println(s"Default OpenTelemetry integration should use ${classOf[PropagatingVirtualThreadFactory].getName}, if you want to use OpenTelemetry with your own oxThreadFactory, you need to take care of propagation of OpenTelemetry context to newly created VirtualThreads") checked = true - end Native + end CheckingThreadFactory end ContextHolder diff --git a/otel/src/main/scala/ox/telemetry/tracing/AutoCloseableSpan.scala b/otel/src/main/scala/ox/telemetry/tracing/AutoCloseableSpan.scala index d7c3e590..82af1333 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/AutoCloseableSpan.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/AutoCloseableSpan.scala @@ -1,15 +1,18 @@ package ox.telemetry.tracing import io.opentelemetry.api.trace.Span -import io.opentelemetry.context.Context -import ox.telemetry.ContextHolder +import io.opentelemetry.context.Scope +/** [[AutoCloseable]] wrapper over a [[Span]] + * + * calling [[close]] will end wrapped [[Span]] and [[Scope]] created when opening a [[Span]], restoring previous current [[Span]] + * */ sealed trait AutoCloseableSpan extends AutoCloseable: def span: Span private object AutoCloseableSpan: - private[ox] final class ContextHolderBased(val span: Span, contextHolder: ContextHolder, previousContext: Context) - extends AutoCloseableSpan: + private[ox] final class Impl(val span: Span, openedScope: Scope) + extends AutoCloseableSpan: def close(): Unit = span.end() - contextHolder.set(previousContext) + openedScope.close() diff --git a/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala b/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala index 6f024f82..f05fe59f 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala @@ -2,6 +2,8 @@ package ox.telemetry.tracing import io.opentelemetry.api.common.Attributes import io.opentelemetry.api.trace.{Span, SpanKind, Tracer} +import io.opentelemetry.context.{Context, ContextStorage, Scope} +import ox.otel.context.PropagatingVirtualThreadFactory import ox.telemetry.ContextHolder import ox.telemetry.tracing.flow.OxFlowTracing import ox.telemetry.tracing.internal.OxTracingBase @@ -9,11 +11,20 @@ import ox.telemetry.tracing.internal.OxTracingBase sealed trait OxTracing extends OxTracingBase, OxFlowTracing object OxTracing: + + /** Tracing that relies on [[PropagatingVirtualThreadFactory]] and checks if its properly set + * */ def native(tracer: Tracer): OxTracing = - val ch = ContextHolder.Native - val base = OxTracingBase(tracer, ch) + val base = OxTracingBase(tracer, ContextHolder.CheckingThreadFactory) + Impl(base) + + /** Custom tracing that leaves propagation of [[Context]] to be implemented by user, for out of the box integration use [[native]] + * */ + def custom(tracer: Tracer): OxTracing = + val base = OxTracingBase(tracer, ContextHolder.Delegating) Impl(base) private class Impl(base: OxTracingBase) extends OxTracing: export base.* end OxTracing + diff --git a/otel/src/main/scala/ox/telemetry/tracing/internal/OxTracingBase.scala b/otel/src/main/scala/ox/telemetry/tracing/internal/OxTracingBase.scala index eef190dc..9e760eeb 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/internal/OxTracingBase.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/internal/OxTracingBase.scala @@ -2,60 +2,79 @@ package ox.telemetry.tracing.internal import io.opentelemetry.api.common.Attributes import io.opentelemetry.api.trace.* -import ox.telemetry.ContextHolder import ox.telemetry.tracing.AutoCloseableSpan +import ox.telemetry.{ContextHolder} private[tracing] trait OxTracingBase: + /** Gets current span, not unsafe per se, but bears user with responsibility of proper usage of obtained span + * */ def currentSpanUnsafe(): Span + /** Opens a new [[Span]] and make it child of current [[Span]], created [[Span]] becomes new current + * + * Unsafe because caller is responsible for properly closing returned [[AutoCloseableSpan]], yet resource operations like [[useCloseableInScope]] can proper finalization + * + * @see [[useCloseableInScope]] + * @return [[AutoCloseableSpan]] which can be used to finalize opened span, this is close it and restore previous current + * */ def spanUnsafe( - spanName: String, - spanKind: SpanKind = SpanKind.INTERNAL, - attributes: Attributes = Attributes.empty(), - links: Seq[SpanContext] = Seq.empty - ): AutoCloseableSpan + spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty + ): AutoCloseableSpan + extension [T](t: => T) + /** Span around execution of arbitrary code + * starts span before execution and ends it right after or in case of exception + * opened span is made a child of current [[Span]] and becomes new current + * on close span is closed, and previous current is restored + * + * for [[Flow]] specific spanning use [[OxFlowTracing.span]] + * + * @param beforeClose - operation to be executed right before closing opened span, in case exception was thrown, second argument will contain thrown exception, if execution of beforeClose itself throws, then exception is added as supressed to original exception and span is closed + */ def byNameSpan( - spanName: String, - spanKind: SpanKind = SpanKind.INTERNAL, - attributes: Attributes = Attributes.empty(), - links: Seq[SpanContext] = Seq.empty, - beforeClose: ((Span, Option[Throwable])) => Span = _._1 - ): T + spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty, + beforeClose: ((Span, Option[Throwable])) => Span = _._1 + ): T end extension end OxTracingBase private[tracing] object OxTracingBase: - def apply(tracer: Tracer, contextHolder: ContextHolder): OxTracingBase = new OxTracingBaseImpl(tracer, contextHolder) + def apply(tracer: Tracer, contextStorage: ContextHolder): OxTracingBase = new OxTracingBaseImpl(tracer, contextStorage) - private final class OxTracingBaseImpl(tracer: Tracer, contextHolder: ContextHolder) extends OxTracingBase: + private final class OxTracingBaseImpl(tracer: Tracer, contextStorage: ContextHolder) extends OxTracingBase: - override def currentSpanUnsafe(): Span = Span.fromContext(contextHolder.get()) + override def currentSpanUnsafe(): Span = Span.fromContext(contextStorage.get()) def spanUnsafe( - spanName: String, - spanKind: SpanKind = SpanKind.INTERNAL, - attributes: Attributes = Attributes.empty(), - links: Seq[SpanContext] = Seq.empty - ): AutoCloseableSpan = - val previousContext = contextHolder.get() + spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty + ): AutoCloseableSpan = + val previousContext = contextStorage.get() val builder = tracer.spanBuilder(spanName).setSpanKind(spanKind).setAllAttributes(attributes).setParent(previousContext) val withLinks = links.foldLeft(builder)((b, l) => b.addLink(l)) val started = withLinks.startSpan() - contextHolder.set(previousContext.`with`(started)) + val scope = contextStorage.set(previousContext.`with`(started)) - AutoCloseableSpan.ContextHolderBased(started, contextHolder, previousContext) + AutoCloseableSpan.Impl(started, scope) end spanUnsafe extension [T](t: => T) override def byNameSpan( - spanName: String, - spanKind: SpanKind, - attributes: Attributes, - links: Seq[SpanContext], - beforeClose: ((Span, Option[Throwable])) => Span - ): T = + spanName: String, + spanKind: SpanKind, + attributes: Attributes, + links: Seq[SpanContext], + beforeClose: ((Span, Option[Throwable])) => Span + ): T = var error: Throwable | Null = null var finalizer: AutoCloseableSpan | Null = null try diff --git a/otel/src/main/scala/ox/telemetry/tracing/tracing.scala b/otel/src/main/scala/ox/telemetry/tracing/tracing.scala new file mode 100644 index 00000000..240eea61 --- /dev/null +++ b/otel/src/main/scala/ox/telemetry/tracing/tracing.scala @@ -0,0 +1,26 @@ +package ox.telemetry.tracing; + + +import io.opentelemetry.api.common.Attributes +import io.opentelemetry.api.trace.{Span, SpanContext, SpanKind} +import ox.flow.Flow + +extension [T](flow: Flow[T]) + def span(oxTracing: OxTracing)( + spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty, + beforeClose: ((Span, Option[Throwable])) => Span = _._1 + ): Flow[T] = ??? + +extension [T](t: => T) + def byNameSpan(oxTracing: OxTracing)( + spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty, + beforeClose: ((Span, Option[Throwable])) => Span = _._1 + ): T = ??? + + \ No newline at end of file diff --git a/otel/src/test/scala/OxTelemetryTest.scala b/otel/src/test/scala/OxTelemetryTest.scala index 9bcba131..7f2afb91 100644 --- a/otel/src/test/scala/OxTelemetryTest.scala +++ b/otel/src/test/scala/OxTelemetryTest.scala @@ -1,4 +1,5 @@ import io.opentelemetry.api.metrics.Meter +import io.opentelemetry.context.Context import io.opentelemetry.sdk.OpenTelemetrySdk import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter import io.opentelemetry.sdk.trace.SdkTracerProvider @@ -59,6 +60,8 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val spans = exporter.getFinishedSpanItems val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) + assert(Context.current() == Context.root) + assert(thisTestSpans.isEmpty) } @@ -78,28 +81,33 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: assert(thisTestSpans.size == 1) val span = spans.get(0) + assert(Context.current() == Context.root) assert(span.getName == s"$prefix-span") } "flow.spanned" should "open two spans for two invocations" in { - val prefix = s"[${UUID.randomUUID()}]" + supervised { + val prefix = s"[${UUID.randomUUID()}]" - val flow = Flow.fromValues(1, 2, 3) + val flow = Flow.fromValues(1, 2, 3) - val spanned1 = flow.span(s"$prefix-span") ++ Flow.fromValues(4, 2) + val spanned1 = flow.span(s"$prefix-span") ++ Flow.fromValues(4, 2) - val spanned2 = spanned1.span(s"$prefix-span2") + val spanned2 = spanned1.span(s"$prefix-span2") - spanned2.runDrain() + spanned2.runDrain() - val spans = exporter.getFinishedSpanItems - val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) + val spans = exporter.getFinishedSpanItems + val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) - assert(thisTestSpans.size == 2) + assert(thisTestSpans.size == 2) + + assert(Context.current() == Context.root) + assert(thisTestSpans.head.getName == s"$prefix-span") + assert(thisTestSpans(1).getName == s"$prefix-span2") + } - assert(thisTestSpans.head.getName == s"$prefix-span") - assert(thisTestSpans(1).getName == s"$prefix-span2") } "flow.spanned" should "open only one span despite parralelized map" in { @@ -120,6 +128,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val span = thisTestSpans(0) + assert(Context.current() == Context.root) assert(span.getName == s"$prefix-span") } @@ -143,6 +152,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: assert(thisTestSpans.size == 1) val span = thisTestSpans(0) + assert(Context.current() == Context.root) assert(span.getName == s"$prefix-span") } @@ -164,6 +174,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: assert(thisTestSpans.size == 1) val span = thisTestSpans(0) + assert(Context.current() == Context.root) assert(span.getName == s"$prefix-span") } @@ -196,6 +207,8 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: for span <- childrenSpans yield assert(span.getParentSpanId == parentSpan.getSpanId) + assert(Context.current() == Context.root) + } "flow.spanned" should "open several spans if flatMapped" in { @@ -222,6 +235,8 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: for span <- childrenSpans yield assert(span.getParentSpanId == parentSpan.getSpanId) + assert(Context.current() == Context.root) + } "flow.spanned" should "properly end span in case of error2" in { @@ -243,6 +258,8 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val span = thisTestSpans(0) assert(span.getName == s"$prefix-span") + assert(Context.current() == Context.root) + } "flow.spanned" should "properly end span in case of error2fd" in { @@ -266,6 +283,8 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val flowSpan = thisTestSpans.find(_.getName.endsWith("span")).get assert(flowSpan.getParentSpanId == recoverySpan.getSpanId) + assert(Context.current() == Context.root) + } "flow.spanned" should "properly end span in case of error2fdfs" in { @@ -293,6 +312,8 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: thisTestSpans.foreach { s => assert(s.getParentSpanId == finalinzer.span.getSpanContext.getSpanId) } + assert(Context.current() == Context.root) + } "flow.spanned" should "properly end span in case of ejknrror2fdfs" in { @@ -323,6 +344,8 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: thisTestSpans.foreach { s => assert(s.getParentSpanId == finalinzer.span.getSpanContext.getSpanId) } + assert(Context.current() == Context.root) + } "flow.spanned" should "properly end span in case of ejknrror2fdsfsfdffssfss" in { // flaky @@ -371,6 +394,8 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: level2Spans.foreach { s => assert(s.getParentSpanId == level1_1.getSpanContext.getSpanId) } + assert(Context.current() == Context.root) + } @@ -431,6 +456,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: assert(s.getEvents().get(0).getName.startsWith("boomed")) } + assert(Context.current() == Context.root) } @@ -493,6 +519,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: assert(s.getEvents().get(0).getName.startsWith("boomed")) } + assert(Context.current() == Context.root) } @@ -554,6 +581,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: assert(s.getEvents().get(0).getName.startsWith("boomed")) } + assert(Context.current() == Context.root) } @@ -587,6 +615,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) assert(thisTestSpans.size == 1) + assert(Context.current() == Context.root) } @@ -613,6 +642,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) assert(thisTestSpans.size == 1) + assert(Context.current() == Context.root) } end OxTelemetryTest From 5a2c9de8bbaca047ba7788a18ab8ebfc625c3951 Mon Sep 17 00:00:00 2001 From: Pawel Sadlo Date: Tue, 13 Oct 2026 11:13:02 +0200 Subject: [PATCH 21/38] fmt --- .../scala/ox/telemetry/ContextHolder.scala | 7 +- .../telemetry/tracing/AutoCloseableSpan.scala | 9 +-- .../ox/telemetry/tracing/OxTracing.scala | 5 +- .../tracing/internal/OxTracingBase.scala | 80 ++++++++++--------- .../scala/ox/telemetry/tracing/tracing.scala | 27 +++---- otel/src/test/scala/OxTelemetryTest.scala | 1 - 6 files changed, 64 insertions(+), 65 deletions(-) diff --git a/otel/src/main/scala/ox/telemetry/ContextHolder.scala b/otel/src/main/scala/ox/telemetry/ContextHolder.scala index 37d85186..3b8d05d9 100644 --- a/otel/src/main/scala/ox/telemetry/ContextHolder.scala +++ b/otel/src/main/scala/ox/telemetry/ContextHolder.scala @@ -4,7 +4,7 @@ import io.opentelemetry.context.{Context, Scope} import ox.otel.context.PropagatingVirtualThreadFactory /** Abstraction managing an OpenTelemetry [[Context]] - */ + */ sealed trait ContextHolder: def get(): Context @@ -18,7 +18,6 @@ private[telemetry] object ContextHolder: override def set(context: Context): Scope = context.makeCurrent() - private[telemetry] object CheckingThreadFactory extends ContextHolder: private var checked = false private val wrapped: ContextHolder = Delegating @@ -34,7 +33,9 @@ private[telemetry] object ContextHolder: private def checkThreadFactory() = if !checked then if !ox.oxThreadFactory.isInstanceOf[PropagatingVirtualThreadFactory] then - System.err.println(s"Default OpenTelemetry integration should use ${classOf[PropagatingVirtualThreadFactory].getName}, if you want to use OpenTelemetry with your own oxThreadFactory, you need to take care of propagation of OpenTelemetry context to newly created VirtualThreads") + System.err.println( + s"Default OpenTelemetry integration should use ${classOf[PropagatingVirtualThreadFactory].getName}, if you want to use OpenTelemetry with your own oxThreadFactory, you need to take care of propagation of OpenTelemetry context to newly created VirtualThreads" + ) checked = true end CheckingThreadFactory end ContextHolder diff --git a/otel/src/main/scala/ox/telemetry/tracing/AutoCloseableSpan.scala b/otel/src/main/scala/ox/telemetry/tracing/AutoCloseableSpan.scala index 82af1333..9ae61030 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/AutoCloseableSpan.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/AutoCloseableSpan.scala @@ -4,15 +4,14 @@ import io.opentelemetry.api.trace.Span import io.opentelemetry.context.Scope /** [[AutoCloseable]] wrapper over a [[Span]] - * - * calling [[close]] will end wrapped [[Span]] and [[Scope]] created when opening a [[Span]], restoring previous current [[Span]] - * */ + * + * calling [[close]] will end wrapped [[Span]] and [[Scope]] created when opening a [[Span]], restoring previous current [[Span]] + */ sealed trait AutoCloseableSpan extends AutoCloseable: def span: Span private object AutoCloseableSpan: - private[ox] final class Impl(val span: Span, openedScope: Scope) - extends AutoCloseableSpan: + private[ox] final class Impl(val span: Span, openedScope: Scope) extends AutoCloseableSpan: def close(): Unit = span.end() openedScope.close() diff --git a/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala b/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala index f05fe59f..00ee100a 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala @@ -13,13 +13,13 @@ sealed trait OxTracing extends OxTracingBase, OxFlowTracing object OxTracing: /** Tracing that relies on [[PropagatingVirtualThreadFactory]] and checks if its properly set - * */ + */ def native(tracer: Tracer): OxTracing = val base = OxTracingBase(tracer, ContextHolder.CheckingThreadFactory) Impl(base) /** Custom tracing that leaves propagation of [[Context]] to be implemented by user, for out of the box integration use [[native]] - * */ + */ def custom(tracer: Tracer): OxTracing = val base = OxTracingBase(tracer, ContextHolder.Delegating) Impl(base) @@ -27,4 +27,3 @@ object OxTracing: private class Impl(base: OxTracingBase) extends OxTracing: export base.* end OxTracing - diff --git a/otel/src/main/scala/ox/telemetry/tracing/internal/OxTracingBase.scala b/otel/src/main/scala/ox/telemetry/tracing/internal/OxTracingBase.scala index 9e760eeb..9e9849c3 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/internal/OxTracingBase.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/internal/OxTracingBase.scala @@ -2,46 +2,48 @@ package ox.telemetry.tracing.internal import io.opentelemetry.api.common.Attributes import io.opentelemetry.api.trace.* +import ox.telemetry.ContextHolder import ox.telemetry.tracing.AutoCloseableSpan -import ox.telemetry.{ContextHolder} private[tracing] trait OxTracingBase: /** Gets current span, not unsafe per se, but bears user with responsibility of proper usage of obtained span - * */ + */ def currentSpanUnsafe(): Span /** Opens a new [[Span]] and make it child of current [[Span]], created [[Span]] becomes new current - * - * Unsafe because caller is responsible for properly closing returned [[AutoCloseableSpan]], yet resource operations like [[useCloseableInScope]] can proper finalization - * - * @see [[useCloseableInScope]] - * @return [[AutoCloseableSpan]] which can be used to finalize opened span, this is close it and restore previous current - * */ + * + * Unsafe because caller is responsible for properly closing returned [[AutoCloseableSpan]], yet resource operations like + * [[useCloseableInScope]] can proper finalization + * + * @see + * [[useCloseableInScope]] + * @return + * [[AutoCloseableSpan]] which can be used to finalize opened span, this is close it and restore previous current + */ def spanUnsafe( - spanName: String, - spanKind: SpanKind = SpanKind.INTERNAL, - attributes: Attributes = Attributes.empty(), - links: Seq[SpanContext] = Seq.empty - ): AutoCloseableSpan - + spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty + ): AutoCloseableSpan extension [T](t: => T) /** Span around execution of arbitrary code - * starts span before execution and ends it right after or in case of exception - * opened span is made a child of current [[Span]] and becomes new current - * on close span is closed, and previous current is restored - * - * for [[Flow]] specific spanning use [[OxFlowTracing.span]] - * - * @param beforeClose - operation to be executed right before closing opened span, in case exception was thrown, second argument will contain thrown exception, if execution of beforeClose itself throws, then exception is added as supressed to original exception and span is closed - */ + * starts span before execution and ends it right after or in case of exception + * opened span is made a child of current [[Span]] and becomes new current + * on close span is closed, and previous current is restored + * + * for [[Flow]] specific spanning use [[OxFlowTracing.span]] + * + * @param beforeClose - operation to be executed right before closing opened span, in case exception was thrown, second argument will contain thrown exception, if execution of beforeClose itself throws, then exception is added as supressed to original exception and span is closed + */ def byNameSpan( - spanName: String, - spanKind: SpanKind = SpanKind.INTERNAL, - attributes: Attributes = Attributes.empty(), - links: Seq[SpanContext] = Seq.empty, - beforeClose: ((Span, Option[Throwable])) => Span = _._1 - ): T + spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty, + beforeClose: ((Span, Option[Throwable])) => Span = _._1 + ): T end extension end OxTracingBase @@ -53,11 +55,11 @@ private[tracing] object OxTracingBase: override def currentSpanUnsafe(): Span = Span.fromContext(contextStorage.get()) def spanUnsafe( - spanName: String, - spanKind: SpanKind = SpanKind.INTERNAL, - attributes: Attributes = Attributes.empty(), - links: Seq[SpanContext] = Seq.empty - ): AutoCloseableSpan = + spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty + ): AutoCloseableSpan = val previousContext = contextStorage.get() val builder = tracer.spanBuilder(spanName).setSpanKind(spanKind).setAllAttributes(attributes).setParent(previousContext) val withLinks = links.foldLeft(builder)((b, l) => b.addLink(l)) @@ -69,12 +71,12 @@ private[tracing] object OxTracingBase: extension [T](t: => T) override def byNameSpan( - spanName: String, - spanKind: SpanKind, - attributes: Attributes, - links: Seq[SpanContext], - beforeClose: ((Span, Option[Throwable])) => Span - ): T = + spanName: String, + spanKind: SpanKind, + attributes: Attributes, + links: Seq[SpanContext], + beforeClose: ((Span, Option[Throwable])) => Span + ): T = var error: Throwable | Null = null var finalizer: AutoCloseableSpan | Null = null try diff --git a/otel/src/main/scala/ox/telemetry/tracing/tracing.scala b/otel/src/main/scala/ox/telemetry/tracing/tracing.scala index 240eea61..60bc76b9 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/tracing.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/tracing.scala @@ -1,26 +1,25 @@ package ox.telemetry.tracing; - import io.opentelemetry.api.common.Attributes import io.opentelemetry.api.trace.{Span, SpanContext, SpanKind} import ox.flow.Flow extension [T](flow: Flow[T]) def span(oxTracing: OxTracing)( - spanName: String, - spanKind: SpanKind = SpanKind.INTERNAL, - attributes: Attributes = Attributes.empty(), - links: Seq[SpanContext] = Seq.empty, - beforeClose: ((Span, Option[Throwable])) => Span = _._1 + spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty, + beforeClose: ((Span, Option[Throwable])) => Span = _._1 ): Flow[T] = ??? +end extension extension [T](t: => T) def byNameSpan(oxTracing: OxTracing)( - spanName: String, - spanKind: SpanKind = SpanKind.INTERNAL, - attributes: Attributes = Attributes.empty(), - links: Seq[SpanContext] = Seq.empty, - beforeClose: ((Span, Option[Throwable])) => Span = _._1 - ): T = ??? - - \ No newline at end of file + spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty, + beforeClose: ((Span, Option[Throwable])) => Span = _._1 + ): T = ??? +end extension diff --git a/otel/src/test/scala/OxTelemetryTest.scala b/otel/src/test/scala/OxTelemetryTest.scala index 7f2afb91..966ed2de 100644 --- a/otel/src/test/scala/OxTelemetryTest.scala +++ b/otel/src/test/scala/OxTelemetryTest.scala @@ -396,7 +396,6 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: } assert(Context.current() == Context.root) - } "flow.spanned" should "properly end span in case of ejknrror2fdffssfss" in { From b1de70f34e828cc236b68888b70d4a230190b4a9 Mon Sep 17 00:00:00 2001 From: Pawel Sadlo Date: Tue, 13 Oct 2026 12:16:10 +0200 Subject: [PATCH 22/38] changed api, simplify interfaces, make implementation easier to navigate --- .../ox/telemetry/tracing/OxTracing.scala | 94 +++++++++++++--- .../tracing/flow/OxFlowTracing.scala | 31 +++--- .../telemetry/tracing/flow/flowTracing.scala | 16 +++ .../tracing/internal/OxTracingBase.scala | 102 ------------------ .../scala/ox/telemetry/tracing/tracing.scala | 27 ++--- otel/src/test/scala/OxTelemetryTest.scala | 78 +++++++------- 6 files changed, 163 insertions(+), 185 deletions(-) create mode 100644 otel/src/main/scala/ox/telemetry/tracing/flow/flowTracing.scala delete mode 100644 otel/src/main/scala/ox/telemetry/tracing/internal/OxTracingBase.scala diff --git a/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala b/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala index 00ee100a..f90104c4 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala @@ -1,29 +1,95 @@ package ox.telemetry.tracing import io.opentelemetry.api.common.Attributes -import io.opentelemetry.api.trace.{Span, SpanKind, Tracer} -import io.opentelemetry.context.{Context, ContextStorage, Scope} -import ox.otel.context.PropagatingVirtualThreadFactory +import io.opentelemetry.api.trace.{Span, SpanContext, SpanKind, Tracer} import ox.telemetry.ContextHolder import ox.telemetry.tracing.flow.OxFlowTracing -import ox.telemetry.tracing.internal.OxTracingBase -sealed trait OxTracing extends OxTracingBase, OxFlowTracing +sealed trait OxTracing extends OxFlowTracing: + + protected def contextHolder: ContextHolder + + protected def tracer: Tracer + + /** Gets current span, not unsafe per se, but bears user with responsibility of proper usage of obtained span + */ + def currentSpanUnsafe(): Span = Span.fromContext(contextHolder.get()) + + /** Opens a new [[Span]] and make it child of current [[Span]], created [[Span]] becomes new current + * + * Unsafe because caller is responsible for properly closing returned [[AutoCloseableSpan]], yet resource operations like + * [[useCloseableInScope]] can proper finalization + * + * @see + * [[useCloseableInScope]] + * @return + * [[AutoCloseableSpan]] which can be used to finalize opened span, this is close it and restore previous current + */ + def spanUnsafe( + spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty + ): AutoCloseableSpan = + val previousContext = contextHolder.get() + val builder = tracer.spanBuilder(spanName).setSpanKind(spanKind).setAllAttributes(attributes).setParent(previousContext) + val withLinks = links.foldLeft(builder)((b, l) => b.addLink(l)) + val started = withLinks.startSpan() + val scope = contextHolder.set(previousContext.`with`(started)) + + AutoCloseableSpan.Impl(started, scope) + end spanUnsafe + + /** Span around execution of arbitrary code + * starts span before execution and ends it right after or in case of exception + * opened span is made a child of current [[Span]] and becomes new current + * on close span is closed, and previous current is restored + * + * for [[Flow]] specific spanning use [[OxFlowTracing.span]] + * + * @param beforeClose - operation to be executed right before closing opened span, in case exception was thrown, second argument will contain thrown exception, if execution of [[beforeClose]] itself throws, then exception is added as suppressed to original exception and span is closed + */ + def byNameSpan[T](t: => T)( + spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty, + beforeClose: ((Span, Option[Throwable])) => Span = _._1 + ): T = + var error: Throwable | Null = null + var finalizer: AutoCloseableSpan | Null = null + try + val finalize = spanUnsafe(spanName, spanKind, attributes, links) + finalizer = finalize + t + catch + case e: Throwable => + error = e + throw e + finally + try if finalizer ne null then beforeClose(finalizer.nn.span, Option(error)): Unit + catch + case e: Throwable => + if error ne null then error.nn.addSuppressed(e) + else throw e + finally if finalizer ne null then finalizer.nn.close() + end try + end try + + end byNameSpan +end OxTracing object OxTracing: /** Tracing that relies on [[PropagatingVirtualThreadFactory]] and checks if its properly set */ - def native(tracer: Tracer): OxTracing = - val base = OxTracingBase(tracer, ContextHolder.CheckingThreadFactory) - Impl(base) + def native(tracer: Tracer): OxTracing = Impl(tracer, ContextHolder.CheckingThreadFactory) - /** Custom tracing that leaves propagation of [[Context]] to be implemented by user, for out of the box integration use [[native]] + /** Custom tracing that leaves [[Context]] propagation to be implemented by user, for out of the box integration use [[native]] together + * with [[PropagatingVirtualThreadFactory]] */ - def custom(tracer: Tracer): OxTracing = - val base = OxTracingBase(tracer, ContextHolder.Delegating) - Impl(base) + def custom(tracer: Tracer): OxTracing = Impl(tracer, ContextHolder.Delegating) + + private final class Impl(val tracer: Tracer, val contextHolder: ContextHolder) extends OxTracing - private class Impl(base: OxTracingBase) extends OxTracing: - export base.* end OxTracing diff --git a/otel/src/main/scala/ox/telemetry/tracing/flow/OxFlowTracing.scala b/otel/src/main/scala/ox/telemetry/tracing/flow/OxFlowTracing.scala index e2a3dcb4..80ccd138 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/flow/OxFlowTracing.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/flow/OxFlowTracing.scala @@ -3,22 +3,19 @@ package ox.telemetry.tracing.flow import io.opentelemetry.api.common.Attributes import io.opentelemetry.api.trace.{Span, SpanContext, SpanKind} import ox.flow.Flow -import ox.telemetry.tracing.internal.OxTracingBase +import ox.telemetry.tracing.OxTracing private[tracing] trait OxFlowTracing: - self: OxTracingBase => - extension [T](flow: Flow[T]) - def span( - spanName: String, - spanKind: SpanKind = SpanKind.INTERNAL, - attributes: Attributes = Attributes.empty(), - links: Seq[SpanContext] = Seq.empty, - beforeClose: ((Span, Option[Throwable])) => Span = _._1 - ): Flow[T] = Flow.usingEmit: emit => - self.byNameSpan( - flow.runToEmit( - emit - ) - )(spanName, spanKind, attributes, links, beforeClose) - end extension -end OxFlowTracing + self: OxTracing => + def span[T](flow: Flow[T])( + spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty, + beforeClose: ((Span, Option[Throwable])) => Span = _._1 + ): Flow[T] = Flow.usingEmit: emit => + self.byNameSpan( + flow.runToEmit( + emit + ) + )(spanName, spanKind, attributes, links, beforeClose) diff --git a/otel/src/main/scala/ox/telemetry/tracing/flow/flowTracing.scala b/otel/src/main/scala/ox/telemetry/tracing/flow/flowTracing.scala new file mode 100644 index 00000000..9054160f --- /dev/null +++ b/otel/src/main/scala/ox/telemetry/tracing/flow/flowTracing.scala @@ -0,0 +1,16 @@ +package ox.telemetry.tracing.flow + +import io.opentelemetry.api.common.Attributes +import io.opentelemetry.api.trace.{Span, SpanContext, SpanKind} +import ox.flow.Flow +import ox.telemetry.tracing.flow.OxFlowTracing + +extension [T](flow: Flow[T]) + def span(oxTracing: OxFlowTracing)( + spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty, + beforeClose: ((Span, Option[Throwable])) => Span = _._1 + ): Flow[T] = oxTracing.span(flow)(spanName, spanKind, attributes, links, beforeClose) +end extension diff --git a/otel/src/main/scala/ox/telemetry/tracing/internal/OxTracingBase.scala b/otel/src/main/scala/ox/telemetry/tracing/internal/OxTracingBase.scala deleted file mode 100644 index 9e9849c3..00000000 --- a/otel/src/main/scala/ox/telemetry/tracing/internal/OxTracingBase.scala +++ /dev/null @@ -1,102 +0,0 @@ -package ox.telemetry.tracing.internal - -import io.opentelemetry.api.common.Attributes -import io.opentelemetry.api.trace.* -import ox.telemetry.ContextHolder -import ox.telemetry.tracing.AutoCloseableSpan - -private[tracing] trait OxTracingBase: - /** Gets current span, not unsafe per se, but bears user with responsibility of proper usage of obtained span - */ - def currentSpanUnsafe(): Span - - /** Opens a new [[Span]] and make it child of current [[Span]], created [[Span]] becomes new current - * - * Unsafe because caller is responsible for properly closing returned [[AutoCloseableSpan]], yet resource operations like - * [[useCloseableInScope]] can proper finalization - * - * @see - * [[useCloseableInScope]] - * @return - * [[AutoCloseableSpan]] which can be used to finalize opened span, this is close it and restore previous current - */ - def spanUnsafe( - spanName: String, - spanKind: SpanKind = SpanKind.INTERNAL, - attributes: Attributes = Attributes.empty(), - links: Seq[SpanContext] = Seq.empty - ): AutoCloseableSpan - - extension [T](t: => T) - /** Span around execution of arbitrary code - * starts span before execution and ends it right after or in case of exception - * opened span is made a child of current [[Span]] and becomes new current - * on close span is closed, and previous current is restored - * - * for [[Flow]] specific spanning use [[OxFlowTracing.span]] - * - * @param beforeClose - operation to be executed right before closing opened span, in case exception was thrown, second argument will contain thrown exception, if execution of beforeClose itself throws, then exception is added as supressed to original exception and span is closed - */ - def byNameSpan( - spanName: String, - spanKind: SpanKind = SpanKind.INTERNAL, - attributes: Attributes = Attributes.empty(), - links: Seq[SpanContext] = Seq.empty, - beforeClose: ((Span, Option[Throwable])) => Span = _._1 - ): T - end extension -end OxTracingBase - -private[tracing] object OxTracingBase: - def apply(tracer: Tracer, contextStorage: ContextHolder): OxTracingBase = new OxTracingBaseImpl(tracer, contextStorage) - - private final class OxTracingBaseImpl(tracer: Tracer, contextStorage: ContextHolder) extends OxTracingBase: - - override def currentSpanUnsafe(): Span = Span.fromContext(contextStorage.get()) - - def spanUnsafe( - spanName: String, - spanKind: SpanKind = SpanKind.INTERNAL, - attributes: Attributes = Attributes.empty(), - links: Seq[SpanContext] = Seq.empty - ): AutoCloseableSpan = - val previousContext = contextStorage.get() - val builder = tracer.spanBuilder(spanName).setSpanKind(spanKind).setAllAttributes(attributes).setParent(previousContext) - val withLinks = links.foldLeft(builder)((b, l) => b.addLink(l)) - val started = withLinks.startSpan() - val scope = contextStorage.set(previousContext.`with`(started)) - - AutoCloseableSpan.Impl(started, scope) - end spanUnsafe - - extension [T](t: => T) - override def byNameSpan( - spanName: String, - spanKind: SpanKind, - attributes: Attributes, - links: Seq[SpanContext], - beforeClose: ((Span, Option[Throwable])) => Span - ): T = - var error: Throwable | Null = null - var finalizer: AutoCloseableSpan | Null = null - try - val finalize = spanUnsafe(spanName, spanKind, attributes, links) - finalizer = finalize - t - catch - case e: Throwable => - error = e - throw e - finally - try if finalizer ne null then beforeClose(finalizer.nn.span, Option(error)): Unit - catch - case e: Throwable => - if error ne null then error.nn.addSuppressed(e) - else throw e - finally if finalizer ne null then finalizer.nn.close() - end try - end try - end extension - - end OxTracingBaseImpl -end OxTracingBase diff --git a/otel/src/main/scala/ox/telemetry/tracing/tracing.scala b/otel/src/main/scala/ox/telemetry/tracing/tracing.scala index 60bc76b9..1bc5c9c0 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/tracing.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/tracing.scala @@ -1,18 +1,8 @@ -package ox.telemetry.tracing; +package ox.telemetry.tracing import io.opentelemetry.api.common.Attributes import io.opentelemetry.api.trace.{Span, SpanContext, SpanKind} -import ox.flow.Flow - -extension [T](flow: Flow[T]) - def span(oxTracing: OxTracing)( - spanName: String, - spanKind: SpanKind = SpanKind.INTERNAL, - attributes: Attributes = Attributes.empty(), - links: Seq[SpanContext] = Seq.empty, - beforeClose: ((Span, Option[Throwable])) => Span = _._1 - ): Flow[T] = ??? -end extension +import ox.telemetry.tracing.OxTracing extension [T](t: => T) def byNameSpan(oxTracing: OxTracing)( @@ -21,5 +11,16 @@ extension [T](t: => T) attributes: Attributes = Attributes.empty(), links: Seq[SpanContext] = Seq.empty, beforeClose: ((Span, Option[Throwable])) => Span = _._1 - ): T = ??? + ): T = oxTracing.byNameSpan(t)(spanName, spanKind, attributes, links, beforeClose) end extension + +export ox.telemetry.tracing.flow.span + +def currentSpanUnsafe(oxTracing: OxTracing) = oxTracing.currentSpanUnsafe() + +def spanUnsafe(oxTracing: OxTracing)( + spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty +): AutoCloseableSpan = oxTracing.spanUnsafe(spanName, spanKind, attributes, links) diff --git a/otel/src/test/scala/OxTelemetryTest.scala b/otel/src/test/scala/OxTelemetryTest.scala index 966ed2de..8375d3bb 100644 --- a/otel/src/test/scala/OxTelemetryTest.scala +++ b/otel/src/test/scala/OxTelemetryTest.scala @@ -14,6 +14,7 @@ import ox.either.ok import ox.flow.Flow import ox.otel.context.PropagatingVirtualThreadFactory import ox.telemetry.tracing.OxTracing +import ox.telemetry.tracing.* import java.util.UUID import java.util.concurrent.Semaphore @@ -48,9 +49,6 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val meter: Meter = openTelemetry.meterBuilder("ftest").build() val myTracing: OxTracing = OxTracing.native(tracer) - - import myTracing.* - "flow.spanned" should "not open span if not runned" in { val prefix = s"[${UUID.randomUUID()}]" @@ -71,7 +69,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val flow = Flow.fromValues(1, 2, 3) - val spannedFlow = flow.span(s"$prefix-span") + val spannedFlow = flow.span(myTracing)(s"$prefix-span") spannedFlow.runDrain() @@ -92,9 +90,9 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val flow = Flow.fromValues(1, 2, 3) - val spanned1 = flow.span(s"$prefix-span") ++ Flow.fromValues(4, 2) + val spanned1 = flow.span(myTracing)(s"$prefix-span") ++ Flow.fromValues(4, 2) - val spanned2 = spanned1.span(s"$prefix-span2") + val spanned2 = spanned1.span(myTracing)(s"$prefix-span2") spanned2.runDrain() @@ -116,7 +114,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val flow = Flow.fromValues(1, 2, 3) - val spanned = flow.span(s"$prefix-span") + val spanned = flow.span(myTracing)(s"$prefix-span") spanned.mapPar(3)(_ + 1).runDrain() @@ -141,7 +139,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: try flow .tap { case 2 => throw new Exception("boom"); case _ => () } - .span(s"$prefix-span", beforeClose = (s, thr) => s.addEvent(thr.toString)) + .span(myTracing)(s"$prefix-span", beforeClose = (s, thr) => s.addEvent(thr.toString)) .runToList() catch case _ => () @@ -162,7 +160,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val flow = Flow.fromValues(1, 2, 3) - val spanned = flow.span(s"$prefix-span", beforeClose = (s, thr) => s.addEvent(thr.toString)) + val spanned = flow.span(myTracing)(s"$prefix-span", beforeClose = (s, thr) => s.addEvent(thr.toString)) try spanned.map { case 2 => throw new Exception("boom"); case n => n }.runToList() catch case _ => () @@ -187,7 +185,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val finalizer = myTracing.spanUnsafe(s"$prefix-parentSpan") - val spanned = flow.span(s"$prefix-span") + val spanned = flow.span(myTracing)(s"$prefix-span") spanned.runToList(): Unit @@ -217,9 +215,9 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val flow = Flow.fromValues(1, 2, 3) - val spanned = flow.span(s"$prefix-span") + val spanned = flow.span(myTracing)(s"$prefix-span") - flow.flatMap { _ => spanned }.span(s"$prefix-parentSpan").runToList(): Unit + flow.flatMap { _ => spanned }.span(myTracing)(s"$prefix-parentSpan").runToList(): Unit val spans = exporter.getFinishedSpanItems @@ -245,7 +243,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val flow = Flow.fromValues(1, 2, 3) - val spanned = flow.span(s"$prefix-span") + val spanned = flow.span(myTracing)(s"$prefix-span") spanned.map { case 2 => throw new Exception("boom"); case x => x }.recover(_ => 5).runToList() @@ -267,11 +265,13 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val flow = Flow.fromValues(1, 2, 3) - val spanned = span(flow)(s"$prefix-span") + val spanned = span(flow)(myTracing)(s"$prefix-span") - val _ = span(spanned.map { case 2 => throw new Exception("boom"); case x => currentSpanUnsafe().addEvent(x.toString); x }.recover { x => - currentSpanUnsafe().addEvent("recovery"); 5 - })(s"$prefix-recover").runToList() + val _ = + span(spanned.map { case 2 => throw new Exception("boom"); case x => currentSpanUnsafe(myTracing).addEvent(x.toString); x }.recover { + x => + currentSpanUnsafe(myTracing).addEvent("recovery"); 5 + })(myTracing)(s"$prefix-recover").runToList() val spans = exporter.getFinishedSpanItems @@ -293,9 +293,9 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val flow = Flow.fromValues(1, 2, 3) - val spanned = span(flow)(s"$prefix-span") + val spanned = span(flow)(myTracing)(s"$prefix-span") - val finalinzer = spanUnsafe(s"$prefix-parent") + val finalinzer = spanUnsafe(myTracing)(s"$prefix-parent") supervised { (1 to 10).foreach(_ => fork(println(spanned.runToList()))) @@ -322,9 +322,9 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val flow = Flow.fromValues(1, 2, 3) - val spanned = span(flow)(s"$prefix-span") + val spanned = span(flow)(myTracing)(s"$prefix-span") - val finalinzer = spanUnsafe(s"$prefix-parent") + val finalinzer = spanUnsafe(myTracing)(s"$prefix-parent") try supervised { @@ -355,16 +355,16 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val flow = Flow.fromValues(1, 2, 3) - val spanned = span(flow)(s"$prefix-level1") + val spanned = span(flow)(myTracing)(s"$prefix-level1") supervised { (1 to 10).map { case 1 => forkUser( - span(flow)(s"$prefix-1-level1") + span(flow)(myTracing)(s"$prefix-1-level1") .flatMap { el => val innerFlow = supervised { - flow.span(s"$prefix-level2", beforeClose = _._1.addEvent("boomed")) + flow.span(myTracing)(s"$prefix-level2", beforeClose = _._1.addEvent("boomed")) } innerFlow } @@ -373,7 +373,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: case _ => forkUser(spanned.runToList()) } - }.byNameSpan(s"$prefix-level0") + }.byNameSpan(myTracing)(s"$prefix-level0") val spans = exporter.getFinishedSpanItems @@ -405,22 +405,22 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val flow = Flow.fromValues(1, 2, 3) - val finalinzer = spanUnsafe(s"$prefix-level0") + val finalinzer = spanUnsafe(myTracing)(s"$prefix-level0") - val spanned = span(flow)(s"$prefix-level1") + val spanned = span(flow)(myTracing)(s"$prefix-level1") try supervised { (1 to 10).map { case 1 => forkUser { - span(flow)(s"$prefix-1-level1") + span(flow)(myTracing)(s"$prefix-1-level1") .flatMap { el => { val inner = supervisedError(em): either: val innerFlow = - em.pure(flow.span(s"$prefix-level2", beforeClose = _._1.addEvent(s"boomed at $el")).map { + em.pure(flow.span(myTracing)(s"$prefix-level2", beforeClose = _._1.addEvent(s"boomed at $el")).map { case 1 => em.pureError("ops").ok(); case x => x }) @@ -466,22 +466,22 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val flow = Flow.fromValues(1, 2, 3) - val finalinzer = spanUnsafe(s"$prefix-level0") + val finalinzer = spanUnsafe(myTracing)(s"$prefix-level0") - val spanned = span(flow)(s"$prefix-level1") + val spanned = span(flow)(myTracing)(s"$prefix-level1") try supervised { (1 to 10).map { case 1 => forkUser { - (span(flow)(s"$prefix-1-level1") + (span(flow)(myTracing)(s"$prefix-1-level1") .flatMap { el => { val inner = supervisedError(em): either: val innerFlow = - em.pure(flow.span(s"$prefix-level2", beforeClose = _._1.addEvent(s"boomed at $el")).map { + em.pure(flow.span(myTracing)(s"$prefix-level2", beforeClose = _._1.addEvent(s"boomed at $el")).map { case 1 => em.pureError("ops").ok(); case x => x }) @@ -529,9 +529,9 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val flow = Flow.fromValues(1, 2, 3) - val finalinzer = spanUnsafe(s"$prefix-level0") + val finalinzer = spanUnsafe(myTracing)(s"$prefix-level0") - val spanned = span(flow)(s"$prefix-level1") + val spanned = span(flow)(myTracing)(s"$prefix-level1") try supervisedError(em) { @@ -540,12 +540,12 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: (1 to 10).map { case 1 => forkUser { - (span(flow)(s"$prefix-1-level1") + (span(flow)(myTracing)(s"$prefix-1-level1") .flatMap { el => { supervisedError(em): val innerFlow = - em.pure(flow.span(s"$prefix-level2", beforeClose = _._1.addEvent(s"boomed at $el")).map { + em.pure(flow.span(myTracing)(s"$prefix-level2", beforeClose = _._1.addEvent(s"boomed at $el")).map { case 1 => em.pureError("ops").ok(); case x => x }) @@ -600,7 +600,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: } val forkWithSpan = forkUser { - span(flow)(s"$prefix-level1").tap { case 1 => semaphore.release(); case _ => sleep(100.seconds) }.runToList() + span(flow)(myTracing)(s"$prefix-level1").tap { case 1 => semaphore.release(); case _ => sleep(100.seconds) }.runToList() } @@ -628,7 +628,7 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: supervised { val forkWithSpan = forkCancellable { - span(flow)(s"$prefix-level1").tap { case 1 => semaphore.release(); case _ => sleep(100.seconds) }.runToList() + span(flow)(myTracing)(s"$prefix-level1").tap { case 1 => semaphore.release(); case _ => sleep(100.seconds) }.runToList() } semaphore.acquire() From 0e4cdf35bf4064e50f525a7ae38ab1c70255fd90 Mon Sep 17 00:00:00 2001 From: Pawel Sadlo Date: Tue, 13 Oct 2026 12:17:48 +0200 Subject: [PATCH 23/38] fmt --- .../src/main/scala/ox/telemetry/tracing/flow/OxFlowTracing.scala | 1 + 1 file changed, 1 insertion(+) diff --git a/otel/src/main/scala/ox/telemetry/tracing/flow/OxFlowTracing.scala b/otel/src/main/scala/ox/telemetry/tracing/flow/OxFlowTracing.scala index 80ccd138..c91fcad7 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/flow/OxFlowTracing.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/flow/OxFlowTracing.scala @@ -19,3 +19,4 @@ private[tracing] trait OxFlowTracing: emit ) )(spanName, spanKind, attributes, links, beforeClose) +end OxFlowTracing From 292f71137c1461f7152979515cc46f44a1ba2306 Mon Sep 17 00:00:00 2001 From: Pawel Sadlo Date: Tue, 13 Oct 2026 13:05:22 +0200 Subject: [PATCH 24/38] set error status by default and recordException --- otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala b/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala index f90104c4..9f5e3601 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala @@ -1,7 +1,7 @@ package ox.telemetry.tracing import io.opentelemetry.api.common.Attributes -import io.opentelemetry.api.trace.{Span, SpanContext, SpanKind, Tracer} +import io.opentelemetry.api.trace.{Span, SpanContext, SpanKind, StatusCode, Tracer} import ox.telemetry.ContextHolder import ox.telemetry.tracing.flow.OxFlowTracing @@ -65,6 +65,9 @@ sealed trait OxTracing extends OxFlowTracing: catch case e: Throwable => error = e + if finalizer ne null then + finalizer.nn.span.recordException(e) + finalizer.nn.span.setStatus(StatusCode.ERROR, e.getMessage): Unit throw e finally try if finalizer ne null then beforeClose(finalizer.nn.span, Option(error)): Unit From 3daf11e9f02a8e92faba64f104a36248bd12980a Mon Sep 17 00:00:00 2001 From: Pawel Sadlo Date: Tue, 13 Oct 2026 13:07:07 +0200 Subject: [PATCH 25/38] CheckingThreadFactory.checked - volatile --- otel/src/main/scala/ox/telemetry/ContextHolder.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/otel/src/main/scala/ox/telemetry/ContextHolder.scala b/otel/src/main/scala/ox/telemetry/ContextHolder.scala index 3b8d05d9..4d852a88 100644 --- a/otel/src/main/scala/ox/telemetry/ContextHolder.scala +++ b/otel/src/main/scala/ox/telemetry/ContextHolder.scala @@ -19,7 +19,7 @@ private[telemetry] object ContextHolder: override def set(context: Context): Scope = context.makeCurrent() private[telemetry] object CheckingThreadFactory extends ContextHolder: - private var checked = false + @volatile private var checked = false private val wrapped: ContextHolder = Delegating override def get(): Context = From 5ceb41777e79cbbeab3aed3754853c694f4d65e3 Mon Sep 17 00:00:00 2001 From: Pawel Sadlo Date: Tue, 13 Oct 2026 13:19:55 +0200 Subject: [PATCH 26/38] tests --- otel/src/test/scala/OxTelemetryTest.scala | 817 +++++++++++----------- 1 file changed, 420 insertions(+), 397 deletions(-) diff --git a/otel/src/test/scala/OxTelemetryTest.scala b/otel/src/test/scala/OxTelemetryTest.scala index 8375d3bb..7b6a25d2 100644 --- a/otel/src/test/scala/OxTelemetryTest.scala +++ b/otel/src/test/scala/OxTelemetryTest.scala @@ -1,19 +1,19 @@ import io.opentelemetry.api.metrics.Meter +import io.opentelemetry.api.trace.StatusCode import io.opentelemetry.context.Context import io.opentelemetry.sdk.OpenTelemetrySdk import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter import io.opentelemetry.sdk.trace.SdkTracerProvider import io.opentelemetry.sdk.trace.`export`.SimpleSpanProcessor import org.scalatest.BeforeAndAfterAll -import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers import org.scalatest.time.SpanSugar.convertIntToGrainOfTime +import org.scalatest.wordspec.AnyWordSpec import ox.* import ox.channels.BufferCapacity import ox.either.ok import ox.flow.Flow import ox.otel.context.PropagatingVirtualThreadFactory -import ox.telemetry.tracing.OxTracing import ox.telemetry.tracing.* import java.util.UUID @@ -21,7 +21,7 @@ import java.util.concurrent.Semaphore import scala.collection.JavaConverters.asScalaBufferConverter import scala.util.boundary.Label -class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: +class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: override def beforeAll(): Unit = setOxThreadFactory(new PropagatingVirtualThreadFactory) @@ -49,599 +49,622 @@ class OxTelemetryTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll: val meter: Meter = openTelemetry.meterBuilder("ftest").build() val myTracing: OxTracing = OxTracing.native(tracer) - "flow.spanned" should "not open span if not runned" in { + "Flow.span" when { + "run normally" should { + "not open a span until the flow is run" in { - val prefix = s"[${UUID.randomUUID()}]" + val prefix = s"[${UUID.randomUUID()}]" - val flow = Flow.fromValues(1, 2, 3) + val flow = Flow.fromValues(1, 2, 3) - val spans = exporter.getFinishedSpanItems + val spans = exporter.getFinishedSpanItems - val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) - assert(Context.current() == Context.root) + val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) + assert(Context.current() == Context.root) - assert(thisTestSpans.isEmpty) + assert(thisTestSpans.isEmpty) - } + } - "flow.spanned" should "open one span even if flow has multiple elements" in { - val prefix = s"[${UUID.randomUUID()}]" + "open a single span for a multi-element flow" in { + val prefix = s"[${UUID.randomUUID()}]" - val flow = Flow.fromValues(1, 2, 3) + val flow = Flow.fromValues(1, 2, 3) - val spannedFlow = flow.span(myTracing)(s"$prefix-span") + val spannedFlow = flow.span(myTracing)(s"$prefix-span") - spannedFlow.runDrain() + spannedFlow.runDrain() - val spans = exporter.getFinishedSpanItems - val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) + val spans = exporter.getFinishedSpanItems + val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) - assert(thisTestSpans.size == 1) + assert(thisTestSpans.size == 1) - val span = spans.get(0) - assert(Context.current() == Context.root) + val span = spans.get(0) + assert(Context.current() == Context.root) - assert(span.getName == s"$prefix-span") - } + assert(span.getName == s"$prefix-span") + } - "flow.spanned" should "open two spans for two invocations" in { - supervised { - val prefix = s"[${UUID.randomUUID()}]" + "open one span per span() call when spans are nested" in { + supervised { + val prefix = s"[${UUID.randomUUID()}]" - val flow = Flow.fromValues(1, 2, 3) + val flow = Flow.fromValues(1, 2, 3) - val spanned1 = flow.span(myTracing)(s"$prefix-span") ++ Flow.fromValues(4, 2) + val spanned1 = flow.span(myTracing)(s"$prefix-span") ++ Flow.fromValues(4, 2) - val spanned2 = spanned1.span(myTracing)(s"$prefix-span2") + val spanned2 = spanned1.span(myTracing)(s"$prefix-span2") - spanned2.runDrain() + spanned2.runDrain() - val spans = exporter.getFinishedSpanItems - val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) + val spans = exporter.getFinishedSpanItems + val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) - assert(thisTestSpans.size == 2) + assert(thisTestSpans.size == 2) - assert(Context.current() == Context.root) - assert(thisTestSpans.head.getName == s"$prefix-span") - assert(thisTestSpans(1).getName == s"$prefix-span2") - } + assert(Context.current() == Context.root) + assert(thisTestSpans.head.getName == s"$prefix-span") + assert(thisTestSpans(1).getName == s"$prefix-span2") + } - } + } - "flow.spanned" should "open only one span despite parralelized map" in { + "open a single span when downstream uses mapPar" in { - val prefix = s"[${UUID.randomUUID()}]" + val prefix = s"[${UUID.randomUUID()}]" - val flow = Flow.fromValues(1, 2, 3) + val flow = Flow.fromValues(1, 2, 3) - val spanned = flow.span(myTracing)(s"$prefix-span") + val spanned = flow.span(myTracing)(s"$prefix-span") - spanned.mapPar(3)(_ + 1).runDrain() + spanned.mapPar(3)(_ + 1).runDrain() - val spans = exporter.getFinishedSpanItems + val spans = exporter.getFinishedSpanItems - val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) + val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) - assert(thisTestSpans.size == 1) + assert(thisTestSpans.size == 1) - val span = thisTestSpans(0) + val span = thisTestSpans(0) - assert(Context.current() == Context.root) - assert(span.getName == s"$prefix-span") - } + assert(Context.current() == Context.root) + assert(span.getName == s"$prefix-span") + assert(span.getStatus.getStatusCode == StatusCode.UNSET) + assert(span.getEvents.isEmpty) + } + } - "flow.spanned" should "properly end span in case of error" in { + "used under parent spans" should { + "use the current span as parent" in { - val prefix = s"[${UUID.randomUUID()}]" + val prefix = s"[${UUID.randomUUID()}]" - val flow = Flow.fromValues(1, 2, 3) + val flow = Flow.fromValues(1, 2, 3) - try - flow - .tap { case 2 => throw new Exception("boom"); case _ => () } - .span(myTracing)(s"$prefix-span", beforeClose = (s, thr) => s.addEvent(thr.toString)) - .runToList() - catch case _ => () + val finalizer = myTracing.spanUnsafe(s"$prefix-parentSpan") - val spans = exporter.getFinishedSpanItems + val spanned = flow.span(myTracing)(s"$prefix-span") - val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) + spanned.runToList(): Unit - assert(thisTestSpans.size == 1) + finalizer.close() - val span = thisTestSpans(0) - assert(Context.current() == Context.root) + val spans = exporter.getFinishedSpanItems - assert(span.getName == s"$prefix-span") - } - "flow.spanned" should "properly end span in case of error if mapped after span" in { + val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) - val prefix = s"[${UUID.randomUUID()}]" + assert(thisTestSpans.size == 2) - val flow = Flow.fromValues(1, 2, 3) + val (parentSpans, childrenSpans) = thisTestSpans.partitionMap { s => + if s.getName.endsWith("parentSpan") then Left(s) else Right(s) + } - val spanned = flow.span(myTracing)(s"$prefix-span", beforeClose = (s, thr) => s.addEvent(thr.toString)) + assert(parentSpans.size == 1) + assert(childrenSpans.size == 1) + val parentSpan = parentSpans.head - try spanned.map { case 2 => throw new Exception("boom"); case n => n }.runToList() - catch case _ => () + for span <- childrenSpans + yield assert(span.getParentSpanId == parentSpan.getSpanId) + assert(Context.current() == Context.root) - val spans = exporter.getFinishedSpanItems + } - val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) + "open one child span per element when flatMapped under a parent span" in { - assert(thisTestSpans.size == 1) + val prefix = s"[${UUID.randomUUID()}]" - val span = thisTestSpans(0) - assert(Context.current() == Context.root) + val flow = Flow.fromValues(1, 2, 3) - assert(span.getName == s"$prefix-span") - } + val spanned = flow.span(myTracing)(s"$prefix-span") - "flow.spanned" should "correctly picks parent span" in { + flow.flatMap { _ => spanned }.span(myTracing)(s"$prefix-parentSpan").runToList(): Unit - val prefix = s"[${UUID.randomUUID()}]" + val spans = exporter.getFinishedSpanItems - val flow = Flow.fromValues(1, 2, 3) + val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) - val finalizer = myTracing.spanUnsafe(s"$prefix-parentSpan") + assert(thisTestSpans.size == 4) - val spanned = flow.span(myTracing)(s"$prefix-span") + val (parentSpans, childrenSpans) = thisTestSpans.partitionMap { s => + if s.getName.endsWith("parentSpan") then Left(s) else Right(s) + } - spanned.runToList(): Unit + assert(parentSpans.size == 1) + assert(childrenSpans.size == 3) + val parentSpan = parentSpans.head - finalizer.close() + for span <- childrenSpans + yield assert(span.getParentSpanId == parentSpan.getSpanId) + assert(Context.current() == Context.root) - val spans = exporter.getFinishedSpanItems + } - val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) + "parent spans of concurrently run flows under the enclosing span" in { - assert(thisTestSpans.size == 2) + val prefix = s"[${UUID.randomUUID()}]" - val (parentSpans, childrenSpans) = thisTestSpans.partitionMap { s => if s.getName.endsWith("parentSpan") then Left(s) else Right(s) } + val flow = Flow.fromValues(1, 2, 3) - assert(parentSpans.size == 1) - assert(childrenSpans.size == 1) - val parentSpan = parentSpans.head + val spanned = span(flow)(myTracing)(s"$prefix-span") - for span <- childrenSpans - yield assert(span.getParentSpanId == parentSpan.getSpanId) - assert(Context.current() == Context.root) + val finalinzer = spanUnsafe(myTracing)(s"$prefix-parent") - } + supervised { + (1 to 10).foreach(_ => fork(spanned.runToList())) - "flow.spanned" should "open several spans if flatMapped" in { + } - val prefix = s"[${UUID.randomUUID()}]" + finalinzer.close() + val spans = exporter.getFinishedSpanItems - val flow = Flow.fromValues(1, 2, 3) + val thisTestSpans = + spans.asScala.filter(_.getName.startsWith(prefix)).filterNot(s => s.getSpanId == finalinzer.span.getSpanContext.getSpanId) - val spanned = flow.span(myTracing)(s"$prefix-span") + assert(thisTestSpans.size == 10) - flow.flatMap { _ => spanned }.span(myTracing)(s"$prefix-parentSpan").runToList(): Unit + thisTestSpans.foreach { s => + assert(s.getParentSpanId == finalinzer.span.getSpanContext.getSpanId) + } + assert(Context.current() == Context.root) - val spans = exporter.getFinishedSpanItems + } - val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) + "nest spans across forks and inner supervised scopes" in { - assert(thisTestSpans.size == 4) + val em = EitherMode[String] + val prefix = s"[${UUID.randomUUID()}]" - val (parentSpans, childrenSpans) = thisTestSpans.partitionMap { s => if s.getName.endsWith("parentSpan") then Left(s) else Right(s) } + val flow = Flow.fromValues(1, 2, 3) - assert(parentSpans.size == 1) - assert(childrenSpans.size == 3) - val parentSpan = parentSpans.head + val spanned = span(flow)(myTracing)(s"$prefix-level1") - for span <- childrenSpans - yield assert(span.getParentSpanId == parentSpan.getSpanId) - assert(Context.current() == Context.root) + supervised { + (1 to 10).map { + case 1 => + forkUser( + span(flow)(myTracing)(s"$prefix-1-level1") + .flatMap { el => + val innerFlow = supervised { + flow.span(myTracing)(s"$prefix-level2", beforeClose = _._1.addEvent("boomed")) + } + innerFlow + } + .runToList() + ) - } + case _ => forkUser(spanned.runToList()) + } + }.byNameSpan(myTracing)(s"$prefix-level0") - "flow.spanned" should "properly end span in case of error2" in { + val spans = exporter.getFinishedSpanItems - val prefix = s"[${UUID.randomUUID()}]" + val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) + val level0Spans = thisTestSpans.find(s => s.getName.endsWith("level0")).get + val level1Spans = thisTestSpans.filter(s => s.getName.endsWith("level1")) + val level2Spans = thisTestSpans.filter(s => s.getName.endsWith("level2")) - val flow = Flow.fromValues(1, 2, 3) + val level1_1 = level1Spans.find(s => s.getName.endsWith("1-level1")).get + assert(level1Spans.size == 10) + val level1Span = level1Spans.head + assert(level2Spans.size == 3) - val spanned = flow.span(myTracing)(s"$prefix-span") + level1Spans.foreach { s => + assert(s.getParentSpanId == level0Spans.getSpanContext.getSpanId) + } - spanned.map { case 2 => throw new Exception("boom"); case x => x }.recover(_ => 5).runToList() + level2Spans.foreach { s => + assert(s.getParentSpanId == level1_1.getSpanContext.getSpanId) + } + assert(Context.current() == Context.root) - val spans = exporter.getFinishedSpanItems + } + } - val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) + "a stage fails" should { + "record exception and set ERROR status when the flow fails" in { - assert(thisTestSpans.size == 1) + val prefix = s"[${UUID.randomUUID()}]" - val span = thisTestSpans(0) + val flow = Flow.fromValues(1, 2, 3) - assert(span.getName == s"$prefix-span") - assert(Context.current() == Context.root) + try + flow + .tap { case 2 => throw new Exception("boom"); case _ => () } + .span(myTracing)(s"$prefix-span", beforeClose = (s, thr) => s.addEvent(thr.toString)) + .runToList() + catch case _ => () - } - "flow.spanned" should "properly end span in case of error2fd" in { + val spans = exporter.getFinishedSpanItems - val prefix = s"[${UUID.randomUUID()}]" + val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) - val flow = Flow.fromValues(1, 2, 3) + assert(thisTestSpans.size == 1) - val spanned = span(flow)(myTracing)(s"$prefix-span") + val span = thisTestSpans(0) + assert(Context.current() == Context.root) - val _ = - span(spanned.map { case 2 => throw new Exception("boom"); case x => currentSpanUnsafe(myTracing).addEvent(x.toString); x }.recover { - x => - currentSpanUnsafe(myTracing).addEvent("recovery"); 5 - })(myTracing)(s"$prefix-recover").runToList() + assert(span.getName == s"$prefix-span") + assert(span.getStatus.getStatusCode == StatusCode.ERROR) + assert(span.getStatus.getDescription == "boom") + assert(span.getEvents.asScala.exists(_.getName == "exception")) + } - val spans = exporter.getFinishedSpanItems + "end span when a downstream stage fails after span" in { - val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) + val prefix = s"[${UUID.randomUUID()}]" - assert(thisTestSpans.size == 2) + val flow = Flow.fromValues(1, 2, 3) - val recoverySpan = thisTestSpans.find(_.getName.endsWith("recover")).get - val flowSpan = thisTestSpans.find(_.getName.endsWith("span")).get + val spanned = flow.span(myTracing)(s"$prefix-span", beforeClose = (s, thr) => s.addEvent(thr.toString)) - assert(flowSpan.getParentSpanId == recoverySpan.getSpanId) - assert(Context.current() == Context.root) + try spanned.map { case 2 => throw new Exception("boom"); case n => n }.runToList() + catch case _ => () - } + val spans = exporter.getFinishedSpanItems - "flow.spanned" should "properly end span in case of error2fdfs" in { + val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) - val prefix = s"[${UUID.randomUUID()}]" + assert(thisTestSpans.size == 1) - val flow = Flow.fromValues(1, 2, 3) + val span = thisTestSpans(0) + assert(Context.current() == Context.root) - val spanned = span(flow)(myTracing)(s"$prefix-span") + assert(span.getName == s"$prefix-span") + } - val finalinzer = spanUnsafe(myTracing)(s"$prefix-parent") + "end span when downstream failure is recovered" in { - supervised { - (1 to 10).foreach(_ => fork(println(spanned.runToList()))) - } + val prefix = s"[${UUID.randomUUID()}]" - finalinzer.close() - val spans = exporter.getFinishedSpanItems + val flow = Flow.fromValues(1, 2, 3) - val thisTestSpans = - spans.asScala.filter(_.getName.startsWith(prefix)).filterNot(s => s.getSpanId == finalinzer.span.getSpanContext.getSpanId) + val spanned = flow.span(myTracing)(s"$prefix-span") - assert(thisTestSpans.size == 10) + spanned.map { case 2 => throw new Exception("boom"); case x => x }.recover(_ => 5).runToList() - thisTestSpans.foreach { s => - assert(s.getParentSpanId == finalinzer.span.getSpanContext.getSpanId) - } - assert(Context.current() == Context.root) + val spans = exporter.getFinishedSpanItems - } + val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) - "flow.spanned" should "properly end span in case of ejknrror2fdfs" in { + assert(thisTestSpans.size == 1) - val prefix = s"[${UUID.randomUUID()}]" + val span = thisTestSpans(0) - val flow = Flow.fromValues(1, 2, 3) + assert(span.getName == s"$prefix-span") + assert(Context.current() == Context.root) - val spanned = span(flow)(myTracing)(s"$prefix-span") + } - val finalinzer = spanUnsafe(myTracing)(s"$prefix-parent") + "keep the flow span as a child of the recovery span when failure is recovered" in { - try - supervised { - (1 to 10).foreach { - case 1 => forkUser(spanned.tap(_ => throw new Exception("fds")).runToList()) - case _ => forkUser(spanned.runToList()) - } - } - catch case _ => () - end try - finalinzer.close() - val spans = exporter.getFinishedSpanItems + val prefix = s"[${UUID.randomUUID()}]" - val thisTestSpans = - spans.asScala.filter(_.getName.startsWith(prefix)).filterNot(s => s.getSpanId == finalinzer.span.getSpanContext.getSpanId) + val flow = Flow.fromValues(1, 2, 3) - thisTestSpans.foreach { s => - assert(s.getParentSpanId == finalinzer.span.getSpanContext.getSpanId) - } - assert(Context.current() == Context.root) + val spanned = span(flow)(myTracing)(s"$prefix-span") - } + val _ = + span( + spanned.map { case 2 => throw new Exception("boom"); case x => currentSpanUnsafe(myTracing).addEvent(x.toString); x }.recover { + x => + currentSpanUnsafe(myTracing).addEvent("recovery"); 5 + } + )(myTracing)(s"$prefix-recover").runToList() - "flow.spanned" should "properly end span in case of ejknrror2fdsfsfdffssfss" in { // flaky + val spans = exporter.getFinishedSpanItems - val em = EitherMode[String] - val prefix = s"[${UUID.randomUUID()}]" + val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) - val flow = Flow.fromValues(1, 2, 3) + assert(thisTestSpans.size == 2) - val spanned = span(flow)(myTracing)(s"$prefix-level1") + val recoverySpan = thisTestSpans.find(_.getName.endsWith("recover")).get + val flowSpan = thisTestSpans.find(_.getName.endsWith("span")).get - supervised { - (1 to 10).map { - case 1 => - forkUser( - span(flow)(myTracing)(s"$prefix-1-level1") - .flatMap { el => - val innerFlow = supervised { - flow.span(myTracing)(s"$prefix-level2", beforeClose = _._1.addEvent("boomed")) - } - innerFlow - } - .runToList() - ) + assert(flowSpan.getParentSpanId == recoverySpan.getSpanId) + assert(Context.current() == Context.root) - case _ => forkUser(spanned.runToList()) } - }.byNameSpan(myTracing)(s"$prefix-level0") - - val spans = exporter.getFinishedSpanItems - val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) - val level0Spans = thisTestSpans.find(s => s.getName.endsWith("level0")).get - val level1Spans = thisTestSpans.filter(s => s.getName.endsWith("level1")) - val level2Spans = thisTestSpans.filter(s => s.getName.endsWith("level2")) + "end spans of concurrent flows when one of them fails" in { - val level1_1 = level1Spans.find(s => s.getName.endsWith("1-level1")).get - assert(level1Spans.size == 10) - val level1Span = level1Spans.head - assert(level2Spans.size == 3) + val prefix = s"[${UUID.randomUUID()}]" - level1Spans.foreach { s => - assert(s.getParentSpanId == level0Spans.getSpanContext.getSpanId) - } + val flow = Flow.fromValues(1, 2, 3) - level2Spans.foreach { s => - assert(s.getParentSpanId == level1_1.getSpanContext.getSpanId) - } - assert(Context.current() == Context.root) + val spanned = span(flow)(myTracing)(s"$prefix-span") - } + val finalinzer = spanUnsafe(myTracing)(s"$prefix-parent") - "flow.spanned" should "properly end span in case of ejknrror2fdffssfss" in { - - val em = EitherMode[String] - val prefix = s"[${UUID.randomUUID()}]" - - val flow = Flow.fromValues(1, 2, 3) - - val finalinzer = spanUnsafe(myTracing)(s"$prefix-level0") - - val spanned = span(flow)(myTracing)(s"$prefix-level1") - - try - supervised { - (1 to 10).map { - case 1 => - forkUser { - span(flow)(myTracing)(s"$prefix-1-level1") - .flatMap { el => - { - val inner = supervisedError(em): - either: - val innerFlow = - em.pure(flow.span(myTracing)(s"$prefix-level2", beforeClose = _._1.addEvent(s"boomed at $el")).map { - case 1 => em.pureError("ops").ok(); - case x => x - }) - innerFlow.ok() - inner - }.toOption.get - } - .runToList() + try + supervised { + (1 to 10).foreach { + case 1 => forkUser(spanned.tap(_ => throw new Exception("fds")).runToList()) + case _ => forkUser(spanned.runToList()) } - case _ => forkUser(spanned.runToList()) - } - } - catch case _ => () - end try + } + catch case _ => () + end try + finalinzer.close() + val spans = exporter.getFinishedSpanItems + + val thisTestSpans = + spans.asScala.filter(_.getName.startsWith(prefix)).filterNot(s => s.getSpanId == finalinzer.span.getSpanContext.getSpanId) - finalinzer.close() - val spans = exporter.getFinishedSpanItems + thisTestSpans.foreach { s => + assert(s.getParentSpanId == finalinzer.span.getSpanContext.getSpanId) + } + assert(Context.current() == Context.root) - val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) - val level1Spans = thisTestSpans.filter(s => s.getName.endsWith("level1")) - val level2Spans = thisTestSpans.filter(s => s.getName.endsWith("level2")) - val level1_1 = level1Spans.find(s => s.getName.endsWith("1-level1")).get + } - assert(level2Spans.size == 1) // because flow.flatMap is evaluated sequentially and exception ends whole flow + "end inner spans with event when nested flow fails in supervisedError" in { - level1Spans.foreach { s => - assert(s.getParentSpanId == finalinzer.span.getSpanContext.getSpanId, "level1 spans id") - } + val em = EitherMode[String] + val prefix = s"[${UUID.randomUUID()}]" - level2Spans.foreach { s => - assert(s.getParentSpanId == level1_1.getSpanContext.getSpanId, "level2 spans id") - assert(s.getEvents().get(0).getName.startsWith("boomed")) + val flow = Flow.fromValues(1, 2, 3) - } - assert(Context.current() == Context.root) + val finalinzer = spanUnsafe(myTracing)(s"$prefix-level0") - } + val spanned = span(flow)(myTracing)(s"$prefix-level1") - "flow.spanned" should "properly end span in case of ejknfdssrror2fdffssfss" in { - - val em = EitherMode[String] - val prefix = s"[${UUID.randomUUID()}]" - - val flow = Flow.fromValues(1, 2, 3) - - val finalinzer = spanUnsafe(myTracing)(s"$prefix-level0") - - val spanned = span(flow)(myTracing)(s"$prefix-level1") - - try - supervised { - (1 to 10).map { - case 1 => - forkUser { - (span(flow)(myTracing)(s"$prefix-1-level1") - .flatMap { el => - { - val inner = supervisedError(em): - either: - val innerFlow = - em.pure(flow.span(myTracing)(s"$prefix-level2", beforeClose = _._1.addEvent(s"boomed at $el")).map { - case 1 => em.pureError("ops").ok(); - case x => x - }) - innerFlow.ok() - println(inner) - inner - }.toOption.getOrElse(Flow.empty) + try + supervised { + (1 to 10).map { + case 1 => + forkUser { + span(flow)(myTracing)(s"$prefix-1-level1") + .flatMap { el => + { + val inner = supervisedError(em): + either: + val innerFlow = + em.pure(flow.span(myTracing)(s"$prefix-level2", beforeClose = _._1.addEvent(s"boomed at $el")).map { + case 1 => em.pureError("ops").ok(); + case x => x + }) + innerFlow.ok() + inner + }.toOption.get + } + .runToList() } - .runToList()) + case _ => forkUser(spanned.runToList()) } - case _ => forkUser(spanned.runToList()) - } - } - catch case _ => () - end try + } + catch case _ => () + end try - finalinzer.close() - val spans = exporter.getFinishedSpanItems + finalinzer.close() + val spans = exporter.getFinishedSpanItems - val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) - val level1Spans = thisTestSpans.filter(s => s.getName.endsWith("level1")) - val level2Spans = thisTestSpans.filter(s => s.getName.endsWith("level2")) - val level1_1 = level1Spans.find(s => s.getName.endsWith("1-level1")).get + val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) + val level1Spans = thisTestSpans.filter(s => s.getName.endsWith("level1")) + val level2Spans = thisTestSpans.filter(s => s.getName.endsWith("level2")) + val level1_1 = level1Spans.find(s => s.getName.endsWith("1-level1")).get - assert(level2Spans.size == 1) // because flow.flatMap is evaluated sequentially and exception ends whole flow + assert(level2Spans.size == 1) // because flow.flatMap is evaluated sequentially and exception ends whole flow - level1Spans.foreach { s => - assert(s.getParentSpanId == finalinzer.span.getSpanContext.getSpanId) - } + level1Spans.foreach { s => + assert(s.getParentSpanId == finalinzer.span.getSpanContext.getSpanId, "level1 spans id") + } - level2Spans.foreach { s => - if s.getParentSpanId != level1_1.getSpanContext.getSpanId then println("fsfd") - assert(s.getParentSpanId == level1_1.getSpanContext.getSpanId) - assert(s.getEvents().get(0).getName.startsWith("boomed")) + level2Spans.foreach { s => + assert(s.getParentSpanId == level1_1.getSpanContext.getSpanId, "level2 spans id") + assert(s.getEvents.asScala.exists(_.getName.startsWith("boomed"))) - } - assert(Context.current() == Context.root) + } + assert(Context.current() == Context.root) - } + } - "flow.spanned" should "properly end span in case of ejknrrorfdsd2fdffssfss" in { // flaky + "end inner spans with event when nested flow fails and failure is replaced by empty flow" in { - val em = EitherMode[String] - val prefix = s"[${UUID.randomUUID()}]" + val em = EitherMode[String] + val prefix = s"[${UUID.randomUUID()}]" - val flow = Flow.fromValues(1, 2, 3) + val flow = Flow.fromValues(1, 2, 3) - val finalinzer = spanUnsafe(myTracing)(s"$prefix-level0") + val finalinzer = spanUnsafe(myTracing)(s"$prefix-level0") - val spanned = span(flow)(myTracing)(s"$prefix-level1") + val spanned = span(flow)(myTracing)(s"$prefix-level1") - try - supervisedError(em) { - either: - em.pure: + try + supervised { (1 to 10).map { case 1 => forkUser { (span(flow)(myTracing)(s"$prefix-1-level1") .flatMap { el => { - supervisedError(em): - val innerFlow = - em.pure(flow.span(myTracing)(s"$prefix-level2", beforeClose = _._1.addEvent(s"boomed at $el")).map { - case 1 => em.pureError("ops").ok(); - case x => x - }) - innerFlow - }.toOption.get + val inner = supervisedError(em): + either: + val innerFlow = + em.pure(flow.span(myTracing)(s"$prefix-level2", beforeClose = _._1.addEvent(s"boomed at $el")).map { + case 1 => em.pureError("ops").ok(); + case x => x + }) + innerFlow.ok() + inner + }.toOption.getOrElse(Flow.empty) } .runToList()) } case _ => forkUser(spanned.runToList()) } - } - catch case _ => () - end try - - finalinzer.close() - val spans = exporter.getFinishedSpanItems + } + catch case _ => () + end try - val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) - val level1Spans = thisTestSpans.filter(s => s.getName.endsWith("level1")) - val level2Spans = thisTestSpans.filter(s => s.getName.endsWith("level2")) - val level1_1 = level1Spans.find(s => s.getName.endsWith("1-level1")).get + finalinzer.close() + val spans = exporter.getFinishedSpanItems - val level1Span = level1Spans.head - assert(level2Spans.size == 1) // because flow.flatMap is evaluated sequentially and exception ends whole flow + val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) + val level1Spans = thisTestSpans.filter(s => s.getName.endsWith("level1")) + val level2Spans = thisTestSpans.filter(s => s.getName.endsWith("level2")) + val level1_1 = level1Spans.find(s => s.getName.endsWith("1-level1")).get - level1Spans.foreach { s => - assert(s.getParentSpanId == finalinzer.span.getSpanContext.getSpanId) - } + assert(level2Spans.size == 1) // because flow.flatMap is evaluated sequentially and exception ends whole flow - level2Spans.foreach { s => - assert(s.getParentSpanId == level1_1.getSpanContext.getSpanId) - assert(s.getEvents().get(0).getName.startsWith("boomed")) + level1Spans.foreach { s => + assert(s.getParentSpanId == finalinzer.span.getSpanContext.getSpanId) + } - } - assert(Context.current() == Context.root) + level2Spans.foreach { s => + assert(s.getParentSpanId == level1_1.getSpanContext.getSpanId) + assert(s.getEvents.asScala.exists(_.getName.startsWith("boomed"))) - } + } + assert(Context.current() == Context.root) - "flow.spanned" should "properly end span even if enclosing supervised scope fails before stream completion" in { + } - val prefix = s"[${UUID.randomUUID()}]" + "end inner spans with event when nested flow fails inside enclosing supervisedError" in { + + val em = EitherMode[String] + val prefix = s"[${UUID.randomUUID()}]" + + val flow = Flow.fromValues(1, 2, 3) + + val finalinzer = spanUnsafe(myTracing)(s"$prefix-level0") + + val spanned = span(flow)(myTracing)(s"$prefix-level1") + + try + supervisedError(em) { + either: + em.pure: + (1 to 10).map { + case 1 => + forkUser { + (span(flow)(myTracing)(s"$prefix-1-level1") + .flatMap { el => + { + supervisedError(em): + val innerFlow = + em.pure(flow.span(myTracing)(s"$prefix-level2", beforeClose = _._1.addEvent(s"boomed at $el")).map { + case 1 => em.pureError("ops").ok(); + case x => x + }) + innerFlow + }.toOption.get + } + .runToList()) + } + case _ => forkUser(spanned.runToList()) + } + } + catch case _ => () + end try - val semaphore = Semaphore(0) + finalinzer.close() + val spans = exporter.getFinishedSpanItems - val flow = Flow.fromValues(1, 2, 3) + val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) + val level1Spans = thisTestSpans.filter(s => s.getName.endsWith("level1")) + val level2Spans = thisTestSpans.filter(s => s.getName.endsWith("level2")) + val level1_1 = level1Spans.find(s => s.getName.endsWith("1-level1")).get - try - supervised { - val failingFork = forkUser { - semaphore.acquire() - throw new Exception("oops") + val level1Span = level1Spans.head + assert(level2Spans.size == 1) // because flow.flatMap is evaluated sequentially and exception ends whole flow + level1Spans.foreach { s => + assert(s.getParentSpanId == finalinzer.span.getSpanContext.getSpanId) } - val forkWithSpan = forkUser { - span(flow)(myTracing)(s"$prefix-level1").tap { case 1 => semaphore.release(); case _ => sleep(100.seconds) }.runToList() + + level2Spans.foreach { s => + assert(s.getParentSpanId == level1_1.getSpanContext.getSpanId) + assert(s.getEvents.asScala.exists(_.getName.startsWith("boomed"))) } + assert(Context.current() == Context.root) } + } - catch case err => () - end try + "cancelled" should { + "end span when enclosing supervised scope fails before stream completion" in { - val spans = exporter.getFinishedSpanItems + val prefix = s"[${UUID.randomUUID()}]" - val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) + val semaphore = Semaphore(0) - assert(thisTestSpans.size == 1) - assert(Context.current() == Context.root) + val flow = Flow.fromValues(1, 2, 3) - } + try + supervised { + val failingFork = forkUser { + semaphore.acquire() + throw new Exception("oops") + + } + val forkWithSpan = forkUser { + span(flow)(myTracing)(s"$prefix-level1").tap { case 1 => semaphore.release(); case _ => sleep(100.seconds) }.runToList() + + } - "flow.spanned" should "properly end span even if cancelled" in { + } - val prefix = s"[${UUID.randomUUID()}]" + catch case err => () + end try - val semaphore = Semaphore(0) + val spans = exporter.getFinishedSpanItems - val flow = Flow.fromValues(1, 2, 3) + val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) - supervised { - val forkWithSpan = forkCancellable { - span(flow)(myTracing)(s"$prefix-level1").tap { case 1 => semaphore.release(); case _ => sleep(100.seconds) }.runToList() + assert(thisTestSpans.size == 1) + assert(Context.current() == Context.root) } - semaphore.acquire() - forkWithSpan.cancelNow() - } + "end span when the enclosing fork is cancelled" in { + + val prefix = s"[${UUID.randomUUID()}]" + + val semaphore = Semaphore(0) + + val flow = Flow.fromValues(1, 2, 3) + + supervised { + val forkWithSpan = forkCancellable { + span(flow)(myTracing)(s"$prefix-level1").tap { case 1 => semaphore.release(); case _ => sleep(100.seconds) }.runToList() + + } + semaphore.acquire() + forkWithSpan.cancelNow() + + } - val spans = exporter.getFinishedSpanItems + val spans = exporter.getFinishedSpanItems - val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) + val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) - assert(thisTestSpans.size == 1) - assert(Context.current() == Context.root) + assert(thisTestSpans.size == 1) + assert(Context.current() == Context.root) + } + } } + end OxTelemetryTest From 17a84fd46bcb960f497432cc3e90ee9afee38e6e Mon Sep 17 00:00:00 2001 From: Pawel Sadlo Date: Tue, 13 Oct 2026 14:04:38 +0200 Subject: [PATCH 27/38] less code --- otel/src/test/scala/OxTelemetryTest.scala | 604 +++++++--------------- 1 file changed, 175 insertions(+), 429 deletions(-) diff --git a/otel/src/test/scala/OxTelemetryTest.scala b/otel/src/test/scala/OxTelemetryTest.scala index 7b6a25d2..579c5c87 100644 --- a/otel/src/test/scala/OxTelemetryTest.scala +++ b/otel/src/test/scala/OxTelemetryTest.scala @@ -25,11 +25,6 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: override def beforeAll(): Unit = setOxThreadFactory(new PropagatingVirtualThreadFactory) - class My extends OxApp.WithEitherErrors[String] /* , OtelOxApp.WithOtelSupport */: - override def run(args: Vector[String])(using Ox, EitherError[String]): ExitCode = ??? - - override def handleError(e: String): ExitCode = ??? - val exporter = InMemorySpanExporter.create() val tracerProvider = @@ -45,208 +40,133 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: .build() val tracer = openTelemetry.getTracer("test") - val tracer2 = openTelemetry.tracerBuilder("ftest").build() - val meter: Meter = openTelemetry.meterBuilder("ftest").build() val myTracing: OxTracing = OxTracing.native(tracer) - "Flow.span" when { - "run normally" should { - "not open a span until the flow is run" in { - - val prefix = s"[${UUID.randomUUID()}]" - - val flow = Flow.fromValues(1, 2, 3) - - val spans = exporter.getFinishedSpanItems - - val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) - assert(Context.current() == Context.root) - - assert(thisTestSpans.isEmpty) - - } - "open a single span for a multi-element flow" in { - val prefix = s"[${UUID.randomUUID()}]" + enum Level: + case Level0 + case Level1 + case Level1_First + case Level1_First_Child - val flow = Flow.fromValues(1, 2, 3) + import Level.* - val spannedFlow = flow.span(myTracing)(s"$prefix-span") + class Fixture: + val prefix = s"[${UUID.randomUUID()}]" + val flow = Flow.fromValues(1, 2, 3) - spannedFlow.runDrain() + def testSpans = exporter.getFinishedSpanItems.asScala.filter(_.getName.startsWith(prefix)) - val spans = exporter.getFinishedSpanItems - val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) + def spansOf(level: Level) = testSpans.filter(_.getName.endsWith(level.toString)) - assert(thisTestSpans.size == 1) + def assertContextIsRoot() = assert(Context.current() == Context.root) - val span = spans.get(0) - assert(Context.current() == Context.root) + def assertNesting(): Unit = + val level0 = spansOf(Level0) + val level1First = spansOf(Level1_First) - assert(span.getName == s"$prefix-span") + level0.foreach { s => + assert(!s.getParentSpanContext.isValid) } - "open one span per span() call when spans are nested" in { - supervised { - val prefix = s"[${UUID.randomUUID()}]" - - val flow = Flow.fromValues(1, 2, 3) - - val spanned1 = flow.span(myTracing)(s"$prefix-span") ++ Flow.fromValues(4, 2) - - val spanned2 = spanned1.span(myTracing)(s"$prefix-span2") - - spanned2.runDrain() - - val spans = exporter.getFinishedSpanItems - val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) - - assert(thisTestSpans.size == 2) - - assert(Context.current() == Context.root) - assert(thisTestSpans.head.getName == s"$prefix-span") - assert(thisTestSpans(1).getName == s"$prefix-span2") - } - + (spansOf(Level1) ++ level1First).foreach { s => + assert(level0.exists(_.getSpanId == s.getParentSpanId)) } - "open a single span when downstream uses mapPar" in { - - val prefix = s"[${UUID.randomUUID()}]" - - val flow = Flow.fromValues(1, 2, 3) - - val spanned = flow.span(myTracing)(s"$prefix-span") - - spanned.mapPar(3)(_ + 1).runDrain() - - val spans = exporter.getFinishedSpanItems - - val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) - - assert(thisTestSpans.size == 1) - - val span = thisTestSpans(0) - - assert(Context.current() == Context.root) - assert(span.getName == s"$prefix-span") - assert(span.getStatus.getStatusCode == StatusCode.UNSET) - assert(span.getEvents.isEmpty) + spansOf(Level1_First_Child).foreach { s => + assert(level1First.exists(_.getSpanId == s.getParentSpanId)) } - } - - "used under parent spans" should { - "use the current span as parent" in { + end assertNesting - val prefix = s"[${UUID.randomUUID()}]" + def assertNestingAndRoot(): Unit = + assertNesting() + assertContextIsRoot() + end Fixture - val flow = Flow.fromValues(1, 2, 3) - - val finalizer = myTracing.spanUnsafe(s"$prefix-parentSpan") - - val spanned = flow.span(myTracing)(s"$prefix-span") - - spanned.runToList(): Unit + "Flow.span" when { + "run normally" should { + "not open a span until the flow is run" in new Fixture: + assert(testSpans.isEmpty) + assertNestingAndRoot() - finalizer.close() + "open a single span for a multi-element flow" in new Fixture: + flow.span(myTracing)(s"$prefix-$Level0").runDrain() - val spans = exporter.getFinishedSpanItems + assert(spansOf(Level0).size == 1) + assert(testSpans.size == 1) + assertNestingAndRoot() - val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) + "open one span per span() call when spans are nested" in new Fixture: + supervised { + val spanned1 = flow.span(myTracing)(s"$prefix-$Level1") ++ Flow.fromValues(4, 2) - assert(thisTestSpans.size == 2) + spanned1.span(myTracing)(s"$prefix-$Level0").runDrain() - val (parentSpans, childrenSpans) = thisTestSpans.partitionMap { s => - if s.getName.endsWith("parentSpan") then Left(s) else Right(s) + assert(spansOf(Level0).size == 1) + assert(spansOf(Level1).size == 1) + assert(testSpans.size == 2) + assertNestingAndRoot() } - assert(parentSpans.size == 1) - assert(childrenSpans.size == 1) - val parentSpan = parentSpans.head - - for span <- childrenSpans - yield assert(span.getParentSpanId == parentSpan.getSpanId) - assert(Context.current() == Context.root) - - } - - "open one child span per element when flatMapped under a parent span" in { - - val prefix = s"[${UUID.randomUUID()}]" + "open a single span when downstream uses mapPar" in new Fixture: + flow.span(myTracing)(s"$prefix-$Level0").mapPar(3)(_ + 1).runDrain() - val flow = Flow.fromValues(1, 2, 3) + assert(testSpans.size == 1) - val spanned = flow.span(myTracing)(s"$prefix-span") - - flow.flatMap { _ => spanned }.span(myTracing)(s"$prefix-parentSpan").runToList(): Unit - - val spans = exporter.getFinishedSpanItems - - val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) - - assert(thisTestSpans.size == 4) + val only = spansOf(Level0).head + assert(only.getStatus.getStatusCode == StatusCode.UNSET) + assert(only.getEvents.isEmpty) + assertNestingAndRoot() + } - val (parentSpans, childrenSpans) = thisTestSpans.partitionMap { s => - if s.getName.endsWith("parentSpan") then Left(s) else Right(s) - } + "used under parent spans" should { + "use the current span as parent" in new Fixture: + val parent = myTracing.spanUnsafe(s"$prefix-$Level0") - assert(parentSpans.size == 1) - assert(childrenSpans.size == 3) - val parentSpan = parentSpans.head + flow.span(myTracing)(s"$prefix-$Level1").runToList(): Unit - for span <- childrenSpans - yield assert(span.getParentSpanId == parentSpan.getSpanId) - assert(Context.current() == Context.root) + parent.close() - } + assert(spansOf(Level0).size == 1) + assert(spansOf(Level1).size == 1) + assertNestingAndRoot() - "parent spans of concurrently run flows under the enclosing span" in { + "open one child span per element when flatMapped under a parent span" in new Fixture: + val spanned = flow.span(myTracing)(s"$prefix-$Level1") - val prefix = s"[${UUID.randomUUID()}]" + flow.flatMap { _ => spanned }.span(myTracing)(s"$prefix-$Level0").runToList(): Unit - val flow = Flow.fromValues(1, 2, 3) + assert(spansOf(Level0).size == 1) + assert(spansOf(Level1).size == 3) + assertNestingAndRoot() - val spanned = span(flow)(myTracing)(s"$prefix-span") + "parent spans of concurrently run flows under the enclosing span" in new Fixture: + val spanned = flow.span(myTracing)(s"$prefix-$Level1") - val finalinzer = spanUnsafe(myTracing)(s"$prefix-parent") + val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") supervised { (1 to 10).foreach(_ => fork(spanned.runToList())) - - } - - finalinzer.close() - val spans = exporter.getFinishedSpanItems - - val thisTestSpans = - spans.asScala.filter(_.getName.startsWith(prefix)).filterNot(s => s.getSpanId == finalinzer.span.getSpanContext.getSpanId) - - assert(thisTestSpans.size == 10) - - thisTestSpans.foreach { s => - assert(s.getParentSpanId == finalinzer.span.getSpanContext.getSpanId) } - assert(Context.current() == Context.root) - } - - "nest spans across forks and inner supervised scopes" in { + parent.close() - val em = EitherMode[String] - val prefix = s"[${UUID.randomUUID()}]" + assert(spansOf(Level0).size == 1) + assert(spansOf(Level1).size == 10) + assertNestingAndRoot() - val flow = Flow.fromValues(1, 2, 3) - - val spanned = span(flow)(myTracing)(s"$prefix-level1") + "nest spans across forks and inner supervised scopes" in new Fixture: + val spanned = flow.span(myTracing)(s"$prefix-$Level1") supervised { (1 to 10).map { case 1 => forkUser( - span(flow)(myTracing)(s"$prefix-1-level1") + flow + .span(myTracing)(s"$prefix-$Level1_First") .flatMap { el => val innerFlow = supervised { - flow.span(myTracing)(s"$prefix-level2", beforeClose = _._1.addEvent("boomed")) + flow.span(myTracing)(s"$prefix-$Level1_First_Child", beforeClose = _._1.addEvent("boomed")) } innerFlow } @@ -255,146 +175,68 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: case _ => forkUser(spanned.runToList()) } - }.byNameSpan(myTracing)(s"$prefix-level0") - - val spans = exporter.getFinishedSpanItems - - val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) - val level0Spans = thisTestSpans.find(s => s.getName.endsWith("level0")).get - val level1Spans = thisTestSpans.filter(s => s.getName.endsWith("level1")) - val level2Spans = thisTestSpans.filter(s => s.getName.endsWith("level2")) + }.byNameSpan(myTracing)(s"$prefix-$Level0") - val level1_1 = level1Spans.find(s => s.getName.endsWith("1-level1")).get - assert(level1Spans.size == 10) - val level1Span = level1Spans.head - assert(level2Spans.size == 3) - - level1Spans.foreach { s => - assert(s.getParentSpanId == level0Spans.getSpanContext.getSpanId) - } + assert(spansOf(Level1).size == 9) + assert(spansOf(Level1_First).size == 1) + assert(spansOf(Level1_First_Child).size == 3) - level2Spans.foreach { s => - assert(s.getParentSpanId == level1_1.getSpanContext.getSpanId) - } - assert(Context.current() == Context.root) - - } + assertNestingAndRoot() } "a stage fails" should { - "record exception and set ERROR status when the flow fails" in { - - val prefix = s"[${UUID.randomUUID()}]" - - val flow = Flow.fromValues(1, 2, 3) - + "record exception and set ERROR status when the flow fails" in new Fixture: try flow .tap { case 2 => throw new Exception("boom"); case _ => () } - .span(myTracing)(s"$prefix-span", beforeClose = (s, thr) => s.addEvent(thr.toString)) + .span(myTracing)(s"$prefix-$Level0", beforeClose = (s, thr) => s.addEvent(thr.toString)) .runToList() catch case _ => () - val spans = exporter.getFinishedSpanItems - - val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) - - assert(thisTestSpans.size == 1) - - val span = thisTestSpans(0) - assert(Context.current() == Context.root) - - assert(span.getName == s"$prefix-span") - assert(span.getStatus.getStatusCode == StatusCode.ERROR) - assert(span.getStatus.getDescription == "boom") - assert(span.getEvents.asScala.exists(_.getName == "exception")) - } + assert(testSpans.size == 1) - "end span when a downstream stage fails after span" in { + val failed = spansOf(Level0).head + assert(failed.getStatus.getStatusCode == StatusCode.ERROR) + assert(failed.getStatus.getDescription == "boom") + assert(failed.getEvents.asScala.exists(_.getName == "exception")) + assertNestingAndRoot() - val prefix = s"[${UUID.randomUUID()}]" - - val flow = Flow.fromValues(1, 2, 3) - - val spanned = flow.span(myTracing)(s"$prefix-span", beforeClose = (s, thr) => s.addEvent(thr.toString)) + "end span when a downstream stage fails after span" in new Fixture: + val spanned = flow.span(myTracing)(s"$prefix-$Level0", beforeClose = (s, thr) => s.addEvent(thr.toString)) try spanned.map { case 2 => throw new Exception("boom"); case n => n }.runToList() catch case _ => () - val spans = exporter.getFinishedSpanItems - - val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) - - assert(thisTestSpans.size == 1) - - val span = thisTestSpans(0) - assert(Context.current() == Context.root) - - assert(span.getName == s"$prefix-span") - } - - "end span when downstream failure is recovered" in { - - val prefix = s"[${UUID.randomUUID()}]" - - val flow = Flow.fromValues(1, 2, 3) + assert(spansOf(Level0).size == 1) + assert(testSpans.size == 1) + assertNestingAndRoot() - val spanned = flow.span(myTracing)(s"$prefix-span") + "end span when downstream failure is recovered" in new Fixture: + flow.span(myTracing)(s"$prefix-$Level0").map { case 2 => throw new Exception("boom"); case x => x }.recover(_ => 5).runToList() - spanned.map { case 2 => throw new Exception("boom"); case x => x }.recover(_ => 5).runToList() + assert(spansOf(Level0).size == 1) + assert(testSpans.size == 1) + assertNestingAndRoot() - val spans = exporter.getFinishedSpanItems + "keep the flow span as a child of the recovery span when failure is recovered" in new Fixture: + val spanned = flow.span(myTracing)(s"$prefix-$Level1") - val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) - - assert(thisTestSpans.size == 1) - - val span = thisTestSpans(0) - - assert(span.getName == s"$prefix-span") - assert(Context.current() == Context.root) - - } - - "keep the flow span as a child of the recovery span when failure is recovered" in { - - val prefix = s"[${UUID.randomUUID()}]" - - val flow = Flow.fromValues(1, 2, 3) - - val spanned = span(flow)(myTracing)(s"$prefix-span") - - val _ = - span( - spanned.map { case 2 => throw new Exception("boom"); case x => currentSpanUnsafe(myTracing).addEvent(x.toString); x }.recover { - x => - currentSpanUnsafe(myTracing).addEvent("recovery"); 5 - } - )(myTracing)(s"$prefix-recover").runToList() - - val spans = exporter.getFinishedSpanItems - - val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) - - assert(thisTestSpans.size == 2) - - val recoverySpan = thisTestSpans.find(_.getName.endsWith("recover")).get - val flowSpan = thisTestSpans.find(_.getName.endsWith("span")).get - - assert(flowSpan.getParentSpanId == recoverySpan.getSpanId) - assert(Context.current() == Context.root) - - } - - "end spans of concurrent flows when one of them fails" in { - - val prefix = s"[${UUID.randomUUID()}]" + spanned + .map { case 2 => throw new Exception("boom"); case x => currentSpanUnsafe(myTracing).addEvent(x.toString); x } + .recover { x => + currentSpanUnsafe(myTracing).addEvent("recovery"); 5 + } + .span(myTracing)(s"$prefix-$Level0") + .runToList(): Unit - val flow = Flow.fromValues(1, 2, 3) + assert(spansOf(Level0).size == 1) + assert(spansOf(Level1).size == 1) + assertNestingAndRoot() - val spanned = span(flow)(myTracing)(s"$prefix-span") + "end spans of concurrent flows when one of them fails" in new Fixture: + val spanned = flow.span(myTracing)(s"$prefix-$Level1") - val finalinzer = spanUnsafe(myTracing)(s"$prefix-parent") + val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") try supervised { @@ -405,45 +247,36 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: } catch case _ => () end try - finalinzer.close() - val spans = exporter.getFinishedSpanItems + parent.close() - val thisTestSpans = - spans.asScala.filter(_.getName.startsWith(prefix)).filterNot(s => s.getSpanId == finalinzer.span.getSpanContext.getSpanId) - - thisTestSpans.foreach { s => - assert(s.getParentSpanId == finalinzer.span.getSpanContext.getSpanId) - } - assert(Context.current() == Context.root) - - } - - "end inner spans with event when nested flow fails in supervisedError" in { + assert(spansOf(Level0).size == 1) + assertNestingAndRoot() + "end inner spans with event when nested flow fails in supervisedError" in new Fixture: val em = EitherMode[String] - val prefix = s"[${UUID.randomUUID()}]" - - val flow = Flow.fromValues(1, 2, 3) - val finalinzer = spanUnsafe(myTracing)(s"$prefix-level0") + val level0 = spanUnsafe(myTracing)(s"$prefix-$Level0") - val spanned = span(flow)(myTracing)(s"$prefix-level1") + val spanned = flow.span(myTracing)(s"$prefix-$Level1") try supervised { (1 to 10).map { case 1 => forkUser { - span(flow)(myTracing)(s"$prefix-1-level1") + flow + .span(myTracing)(s"$prefix-$Level1_First") .flatMap { el => { val inner = supervisedError(em): either: val innerFlow = - em.pure(flow.span(myTracing)(s"$prefix-level2", beforeClose = _._1.addEvent(s"boomed at $el")).map { - case 1 => em.pureError("ops").ok(); - case x => x - }) + em.pure( + flow.span(myTracing)(s"$prefix-$Level1_First_Child", beforeClose = _._1.addEvent(s"boomed at $el")).map { + case 1 => em.pureError("ops").ok(); + case x => x + } + ) innerFlow.ok() inner }.toOption.get @@ -456,60 +289,40 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: catch case _ => () end try - finalinzer.close() - val spans = exporter.getFinishedSpanItems - - val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) - val level1Spans = thisTestSpans.filter(s => s.getName.endsWith("level1")) - val level2Spans = thisTestSpans.filter(s => s.getName.endsWith("level2")) - val level1_1 = level1Spans.find(s => s.getName.endsWith("1-level1")).get - - assert(level2Spans.size == 1) // because flow.flatMap is evaluated sequentially and exception ends whole flow - - level1Spans.foreach { s => - assert(s.getParentSpanId == finalinzer.span.getSpanContext.getSpanId, "level1 spans id") - } - - level2Spans.foreach { s => - assert(s.getParentSpanId == level1_1.getSpanContext.getSpanId, "level2 spans id") - assert(s.getEvents.asScala.exists(_.getName.startsWith("boomed"))) - - } - assert(Context.current() == Context.root) - - } - - "end inner spans with event when nested flow fails and failure is replaced by empty flow" in { + level0.close() + assert(spansOf(Level1_First_Child).size == 1) // because flow.flatMap is evaluated sequentially and exception ends whole flow + assertNestingAndRoot() + "end inner spans with event when nested flow fails and failure is replaced by empty flow" in new Fixture: val em = EitherMode[String] - val prefix = s"[${UUID.randomUUID()}]" - - val flow = Flow.fromValues(1, 2, 3) - val finalinzer = spanUnsafe(myTracing)(s"$prefix-level0") + val level0 = spanUnsafe(myTracing)(s"$prefix-$Level0") - val spanned = span(flow)(myTracing)(s"$prefix-level1") + val spanned = flow.span(myTracing)(s"$prefix-$Level1") try supervised { (1 to 10).map { case 1 => forkUser { - (span(flow)(myTracing)(s"$prefix-1-level1") + flow + .span(myTracing)(s"$prefix-$Level1_First") .flatMap { el => { val inner = supervisedError(em): either: val innerFlow = - em.pure(flow.span(myTracing)(s"$prefix-level2", beforeClose = _._1.addEvent(s"boomed at $el")).map { - case 1 => em.pureError("ops").ok(); - case x => x - }) + em.pure( + flow.span(myTracing)(s"$prefix-$Level1_First_Child", beforeClose = _._1.addEvent(s"boomed at $el")).map { + case 1 => em.pureError("ops").ok(); + case x => x + } + ) innerFlow.ok() inner }.toOption.getOrElse(Flow.empty) } - .runToList()) + .runToList() } case _ => forkUser(spanned.runToList()) } @@ -517,39 +330,16 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: catch case _ => () end try - finalinzer.close() - val spans = exporter.getFinishedSpanItems - - val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) - val level1Spans = thisTestSpans.filter(s => s.getName.endsWith("level1")) - val level2Spans = thisTestSpans.filter(s => s.getName.endsWith("level2")) - val level1_1 = level1Spans.find(s => s.getName.endsWith("1-level1")).get - - assert(level2Spans.size == 1) // because flow.flatMap is evaluated sequentially and exception ends whole flow - - level1Spans.foreach { s => - assert(s.getParentSpanId == finalinzer.span.getSpanContext.getSpanId) - } - - level2Spans.foreach { s => - assert(s.getParentSpanId == level1_1.getSpanContext.getSpanId) - assert(s.getEvents.asScala.exists(_.getName.startsWith("boomed"))) - - } - assert(Context.current() == Context.root) - - } - - "end inner spans with event when nested flow fails inside enclosing supervisedError" in { + level0.close() + assert(spansOf(Level1_First_Child).size == 1) // because flow.flatMap is evaluated sequentially and exception ends whole flow + assertNestingAndRoot() + "end inner spans with event when nested flow fails inside enclosing supervisedError" in new Fixture: val em = EitherMode[String] - val prefix = s"[${UUID.randomUUID()}]" - - val flow = Flow.fromValues(1, 2, 3) - val finalinzer = spanUnsafe(myTracing)(s"$prefix-level0") + val level0 = spanUnsafe(myTracing)(s"$prefix-$Level0") - val spanned = span(flow)(myTracing)(s"$prefix-level1") + val spanned = flow.span(myTracing)(s"$prefix-$Level1") try supervisedError(em) { @@ -558,19 +348,22 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: (1 to 10).map { case 1 => forkUser { - (span(flow)(myTracing)(s"$prefix-1-level1") + flow + .span(myTracing)(s"$prefix-$Level1_First") .flatMap { el => { supervisedError(em): val innerFlow = - em.pure(flow.span(myTracing)(s"$prefix-level2", beforeClose = _._1.addEvent(s"boomed at $el")).map { - case 1 => em.pureError("ops").ok(); - case x => x - }) + em.pure( + flow.span(myTracing)(s"$prefix-$Level1_First_Child", beforeClose = _._1.addEvent(s"boomed at $el")).map { + case 1 => em.pureError("ops").ok(); + case x => x + } + ) innerFlow }.toOption.get } - .runToList()) + .runToList() } case _ => forkUser(spanned.runToList()) } @@ -578,93 +371,46 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: catch case _ => () end try - finalinzer.close() - val spans = exporter.getFinishedSpanItems - - val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) - val level1Spans = thisTestSpans.filter(s => s.getName.endsWith("level1")) - val level2Spans = thisTestSpans.filter(s => s.getName.endsWith("level2")) - val level1_1 = level1Spans.find(s => s.getName.endsWith("1-level1")).get - - val level1Span = level1Spans.head - assert(level2Spans.size == 1) // because flow.flatMap is evaluated sequentially and exception ends whole flow - - level1Spans.foreach { s => - assert(s.getParentSpanId == finalinzer.span.getSpanContext.getSpanId) - } - - level2Spans.foreach { s => - assert(s.getParentSpanId == level1_1.getSpanContext.getSpanId) - assert(s.getEvents.asScala.exists(_.getName.startsWith("boomed"))) - - } - assert(Context.current() == Context.root) - - } + level0.close() + assert(spansOf(Level1_First_Child).size == 1) // because flow.flatMap is evaluated sequentially and exception ends whole flow + assertNestingAndRoot() } "cancelled" should { - "end span when enclosing supervised scope fails before stream completion" in { - - val prefix = s"[${UUID.randomUUID()}]" - + "end span when enclosing supervised scope fails before stream completion" in new Fixture: val semaphore = Semaphore(0) - val flow = Flow.fromValues(1, 2, 3) - try supervised { - val failingFork = forkUser { + forkUser { semaphore.acquire() throw new Exception("oops") - } - val forkWithSpan = forkUser { - span(flow)(myTracing)(s"$prefix-level1").tap { case 1 => semaphore.release(); case _ => sleep(100.seconds) }.runToList() - + forkUser { + flow.span(myTracing)(s"$prefix-$Level0").tap { case 1 => semaphore.release(); case _ => sleep(100.seconds) }.runToList() } - } - catch case err => () end try - val spans = exporter.getFinishedSpanItems - - val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) - - assert(thisTestSpans.size == 1) - assert(Context.current() == Context.root) - - } - - "end span when the enclosing fork is cancelled" in { - - val prefix = s"[${UUID.randomUUID()}]" + assert(spansOf(Level0).size == 1) + assert(testSpans.size == 1) + assertNestingAndRoot() + "end span when the enclosing fork is cancelled" in new Fixture: val semaphore = Semaphore(0) - val flow = Flow.fromValues(1, 2, 3) - supervised { val forkWithSpan = forkCancellable { - span(flow)(myTracing)(s"$prefix-level1").tap { case 1 => semaphore.release(); case _ => sleep(100.seconds) }.runToList() - + flow.span(myTracing)(s"$prefix-$Level0").tap { case 1 => semaphore.release(); case _ => sleep(100.seconds) }.runToList() } semaphore.acquire() forkWithSpan.cancelNow() - } - val spans = exporter.getFinishedSpanItems - - val thisTestSpans = spans.asScala.filter(_.getName.startsWith(prefix)) - - assert(thisTestSpans.size == 1) - assert(Context.current() == Context.root) - - } + assert(spansOf(Level0).size == 1) + assert(testSpans.size == 1) + assertNestingAndRoot() } } - end OxTelemetryTest From ef26f5885d9fe5ced953045b2c56c833122af94d Mon Sep 17 00:00:00 2001 From: Pawel Sadlo Date: Tue, 13 Oct 2026 14:20:08 +0200 Subject: [PATCH 28/38] docs --- build.sbt | 3 +- doc/index.md | 1 + .../scala/ox/telemetry/ContextHolder.scala | 10 ++++- .../telemetry/tracing/AutoCloseableSpan.scala | 6 ++- .../ox/telemetry/tracing/OxTracing.scala | 39 +++++++++++-------- .../tracing/flow/OxFlowTracing.scala | 6 +++ .../telemetry/tracing/flow/flowTracing.scala | 1 + .../scala/ox/telemetry/tracing/tracing.scala | 3 ++ 8 files changed, 49 insertions(+), 20 deletions(-) diff --git a/build.sbt b/build.sbt index 5532b390..f509e652 100644 --- a/build.sbt +++ b/build.sbt @@ -171,5 +171,6 @@ lazy val documentation: Project = (project in file("generated-doc")) // importan flowReactiveStreams, flowJson, cron, - otelContext + otelContext, + otel ) diff --git a/doc/index.md b/doc/index.md index 9dadca2c..442b1aa3 100644 --- a/doc/index.md +++ b/doc/index.md @@ -93,6 +93,7 @@ In addition to this documentation, ScalaDocs can be browsed at [https://javadoc. integrations/mdc-logback integrations/cron4s integrations/otel-context + integrations/otel-tracing integrations/tapir integrations/sttp-client diff --git a/otel/src/main/scala/ox/telemetry/ContextHolder.scala b/otel/src/main/scala/ox/telemetry/ContextHolder.scala index 4d852a88..d91a958a 100644 --- a/otel/src/main/scala/ox/telemetry/ContextHolder.scala +++ b/otel/src/main/scala/ox/telemetry/ContextHolder.scala @@ -3,21 +3,29 @@ package ox.telemetry import io.opentelemetry.context.{Context, Scope} import ox.otel.context.PropagatingVirtualThreadFactory -/** Abstraction managing an OpenTelemetry [[Context]] +/** Abstraction over how [[OxTracing]] interacts with OpenTelemetry [[Context]]. */ sealed trait ContextHolder: + /** Returns the current [[Context]]. */ def get(): Context + /** Makes the given `context` current. Closing the returned [[Scope]] restores the previously current context. */ def set(context: Context): Scope private[telemetry] object ContextHolder: + /** Delegates to OpenTelemetry Context. Propagating the context to new (virtual) threads is + * left to the user. + */ private[telemetry] object Delegating extends ContextHolder: override def get(): Context = Context.current() override def set(context: Context): Scope = context.makeCurrent() + /** As [[Delegating]], but warns when [[ox.oxThreadFactory]] is not a [[PropagatingVirtualThreadFactory]], + * as in that case the context won't be propagated to forks. + */ private[telemetry] object CheckingThreadFactory extends ContextHolder: @volatile private var checked = false private val wrapped: ContextHolder = Delegating diff --git a/otel/src/main/scala/ox/telemetry/tracing/AutoCloseableSpan.scala b/otel/src/main/scala/ox/telemetry/tracing/AutoCloseableSpan.scala index 9ae61030..023c7aa6 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/AutoCloseableSpan.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/AutoCloseableSpan.scala @@ -3,11 +3,13 @@ package ox.telemetry.tracing import io.opentelemetry.api.trace.Span import io.opentelemetry.context.Scope -/** [[AutoCloseable]] wrapper over a [[Span]] +/** An opened [[Span]], which is also the current one, together with the [[Scope]] that made it current. * - * calling [[close]] will end wrapped [[Span]] and [[Scope]] created when opening a [[Span]], restoring previous current [[Span]] + * Calling [[close]] ends the span and closes the scope, restoring the previously current span. Prefer scoped usage, e.g. + * [[ox.useCloseableInScope]], so that the span is always closed. */ sealed trait AutoCloseableSpan extends AutoCloseable: + /** The opened span, e.g. to set attributes or add events. */ def span: Span private object AutoCloseableSpan: diff --git a/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala b/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala index 9f5e3601..add8a45b 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala @@ -11,19 +11,18 @@ sealed trait OxTracing extends OxFlowTracing: protected def tracer: Tracer - /** Gets current span, not unsafe per se, but bears user with responsibility of proper usage of obtained span + /** Returns the current span. This is not unsafe per se, but the caller is responsible for using the span properly, e.g. not ending a span + * that is managed elsewhere. */ def currentSpanUnsafe(): Span = Span.fromContext(contextHolder.get()) - /** Opens a new [[Span]] and make it child of current [[Span]], created [[Span]] becomes new current + /** Opens a new [[Span]] as a child of the current span. The new span becomes the current one. * - * Unsafe because caller is responsible for properly closing returned [[AutoCloseableSpan]], yet resource operations like - * [[useCloseableInScope]] can proper finalization + * Unsafe, because the caller is responsible for closing the returned [[AutoCloseableSpan]], which ends the span and restores the + * previously current one. Scoped usage, e.g. [[ox.useCloseableInScope]], guarantees that. * - * @see - * [[useCloseableInScope]] * @return - * [[AutoCloseableSpan]] which can be used to finalize opened span, this is close it and restore previous current + * An [[AutoCloseableSpan]], which closes the opened span. */ def spanUnsafe( spanName: String, @@ -40,14 +39,18 @@ sealed trait OxTracing extends OxFlowTracing: AutoCloseableSpan.Impl(started, scope) end spanUnsafe - /** Span around execution of arbitrary code - * starts span before execution and ends it right after or in case of exception - * opened span is made a child of current [[Span]] and becomes new current - * on close span is closed, and previous current is restored + /** Evaluates `t` within a new [[Span]]. The span is opened before `t` is evaluated, as a child of the current span, and becomes the + * current one. It is ended once `t` completes, successfully or not, and the previously current span is restored. + * + * If `t` throws, the exception is recorded on the span and the span's status is set to [[io.opentelemetry.api.trace.StatusCode.ERROR]], + * before `beforeClose` is called. The exception is then re-thrown. * - * for [[Flow]] specific spanning use [[OxFlowTracing.span]] + * To span a [[ox.flow.Flow]], use [[OxFlowTracing.span]]. * - * @param beforeClose - operation to be executed right before closing opened span, in case exception was thrown, second argument will contain thrown exception, if execution of [[beforeClose]] itself throws, then exception is added as suppressed to original exception and span is closed + * @param beforeClose + * Run right before the span is closed, e.g. to set attributes or add events. The second element of the argument is the exception + * thrown by `t`, if any. If `beforeClose` itself throws, the exception is added as suppressed to the one thrown by `t`, or thrown + * if `t` succeeded. The span is closed in either case. */ def byNameSpan[T](t: => T)( spanName: String, @@ -84,12 +87,16 @@ end OxTracing object OxTracing: - /** Tracing that relies on [[PropagatingVirtualThreadFactory]] and checks if its properly set + /** Tracing that relies on the [[PropagatingVirtualThreadFactory]] to propagate the context to forks. A warning is printed to stderr on + * first use, if the thread factory is not set up. + * + * @see + * [[ox.OxApp.Settings.threadFactory]], [[ox.setOxThreadFactory]] */ def native(tracer: Tracer): OxTracing = Impl(tracer, ContextHolder.CheckingThreadFactory) - /** Custom tracing that leaves [[Context]] propagation to be implemented by user, for out of the box integration use [[native]] together - * with [[PropagatingVirtualThreadFactory]] + /** Tracing which leaves the propagation of the [[io.opentelemetry.context.Context]] to new threads to the user. For an out-of-the-box + * integration, use [[native]] together with the [[PropagatingVirtualThreadFactory]]. */ def custom(tracer: Tracer): OxTracing = Impl(tracer, ContextHolder.Delegating) diff --git a/otel/src/main/scala/ox/telemetry/tracing/flow/OxFlowTracing.scala b/otel/src/main/scala/ox/telemetry/tracing/flow/OxFlowTracing.scala index c91fcad7..28296cbc 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/flow/OxFlowTracing.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/flow/OxFlowTracing.scala @@ -7,6 +7,12 @@ import ox.telemetry.tracing.OxTracing private[tracing] trait OxFlowTracing: self: OxTracing => + + /** Returns a flow, which when run, evaluates `flow` within a new span. The span is opened when the flow is run, as a child of the span current at that point. It is ended when the flow completes, fails or is interrupted. Exceptions are recorded on the span as + * in [[OxTracing.byNameSpan]]. + * + * The parameters are the same as for [[OxTracing.byNameSpan]]. + */ def span[T](flow: Flow[T])( spanName: String, spanKind: SpanKind = SpanKind.INTERNAL, diff --git a/otel/src/main/scala/ox/telemetry/tracing/flow/flowTracing.scala b/otel/src/main/scala/ox/telemetry/tracing/flow/flowTracing.scala index 9054160f..2e29c7c3 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/flow/flowTracing.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/flow/flowTracing.scala @@ -6,6 +6,7 @@ import ox.flow.Flow import ox.telemetry.tracing.flow.OxFlowTracing extension [T](flow: Flow[T]) + /** Opens a span around the run of this flow, using the given [[OxFlowTracing]], see [[OxFlowTracing.span]]. */ def span(oxTracing: OxFlowTracing)( spanName: String, spanKind: SpanKind = SpanKind.INTERNAL, diff --git a/otel/src/main/scala/ox/telemetry/tracing/tracing.scala b/otel/src/main/scala/ox/telemetry/tracing/tracing.scala index 1bc5c9c0..3b202d68 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/tracing.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/tracing.scala @@ -5,6 +5,7 @@ import io.opentelemetry.api.trace.{Span, SpanContext, SpanKind} import ox.telemetry.tracing.OxTracing extension [T](t: => T) + /** Evaluates the by-name argument within a new span, see [[OxTracing.byNameSpan]]. */ def byNameSpan(oxTracing: OxTracing)( spanName: String, spanKind: SpanKind = SpanKind.INTERNAL, @@ -16,8 +17,10 @@ end extension export ox.telemetry.tracing.flow.span +/** Returns the current span, see [[OxTracing.currentSpanUnsafe]]. */ def currentSpanUnsafe(oxTracing: OxTracing) = oxTracing.currentSpanUnsafe() +/** Opens a new span, which has to be closed by the caller, see [[OxTracing.spanUnsafe]]. */ def spanUnsafe(oxTracing: OxTracing)( spanName: String, spanKind: SpanKind = SpanKind.INTERNAL, From 61bbc9ebceaa813a37086482d3ca0786a0e679b2 Mon Sep 17 00:00:00 2001 From: Pawel Sadlo Date: Wed, 14 Oct 2026 08:14:37 +0200 Subject: [PATCH 29/38] docs --- doc/integrations/otel-context.md | 2 +- doc/integrations/otel-tracing.md | 104 ++++++++++++++++++ .../scala/ox/telemetry/ContextHolder.scala | 2 +- .../ox/telemetry/tracing/OxTracing.scala | 4 +- .../tracing/flow/OxFlowTracing.scala | 5 +- .../telemetry/tracing/flow/flowTracing.scala | 2 +- 6 files changed, 112 insertions(+), 7 deletions(-) create mode 100644 doc/integrations/otel-tracing.md diff --git a/doc/integrations/otel-context.md b/doc/integrations/otel-context.md index 748c26d2..08204381 100644 --- a/doc/integrations/otel-context.md +++ b/doc/integrations/otel-context.md @@ -13,7 +13,7 @@ into traces, or metrics without the appropriate context. To fix this problem, the context must be propagated whenever a new virtual thread is created. One way to achieve this is by using a custom thread factory, provided by this module - `PropagatingVirtualThreadFactory`. It can be set -for the whole app when using [`OxApp`](../utils/oxapp.md), or manually through `oxThreadFactory`: +for the whole app when using [`OxApp`](../utils/oxapp.md), or manually through `setOxThreadFactory`: ```scala mdoc:compile-only import ox.* diff --git a/doc/integrations/otel-tracing.md b/doc/integrations/otel-tracing.md new file mode 100644 index 00000000..fcfa0d34 --- /dev/null +++ b/doc/integrations/otel-tracing.md @@ -0,0 +1,104 @@ +# OpenTelemetry tracing + +Dependency: + +```scala +"com.softwaremill.ox" %% "ox-telemetry" % "@VERSION@" +``` + +The module depends on [otel-context](otel-context.md), so it doesn't need to be added separately. An OpenTelemetry SDK +and exporter have to be configured by the application, as usual. + +Ox provides helpers for creating [OpenTelemetry](https://opentelemetry.io) spans around code that uses Ox's concurrency +operators, including [Flows](../streaming/flows.md). An opened span becomes a child of the current span, and becomes the +current span itself, until it's closed. + +Spans are created using an `OxTracing` instance, wrapping an OpenTelemetry `Tracer`: + +```scala mdoc:compile-only +import io.opentelemetry.api.trace.Tracer +import ox.telemetry.tracing.OxTracing + +def tracing(tracer: Tracer): OxTracing = OxTracing.native(tracer) +``` + +## Propagating the context + +The current span is kept in the OpenTelemetry `Context`, which by default isn't propagated to new virtual threads, e.g. +forks created in [`supervised`](../structured-concurrency/fork-join.md) scopes. `OxTracing.native` relies on the +`PropagatingVirtualThreadFactory` from [otel-context](otel-context.md) to do that, and prints a warning to stderr if +it's not set up. The factory can be set for the whole app when using [`OxApp`](../utils/oxapp.md), or manually through +`setOxThreadFactory`, before the thread factory is first used (that is, before any scope is started, or `OxTracing.native` +is used): + +```scala mdoc:compile-only +import ox.* +import ox.otel.context.PropagatingVirtualThreadFactory + +object MyApp extends OxApp: + override def settings: OxApp.Settings = OxApp.Settings.Default.copy( + threadFactory = Some(PropagatingVirtualThreadFactory()) + ) + + def run(args: Vector[String])(using Ox): ExitCode = ExitCode.Success +``` + +If you want to take care of context propagation yourself, use `OxTracing.custom`. + +## Spans around code and flows + +`byNameSpan` evaluates a code block within a span. The span is ended once the block completes, and if the block throws, +the exception is recorded on the span, and its status is set to `ERROR` (this includes an interruption). `Flow.span` +does the same for a [`Flow`](../streaming/flows.md): the span is opened when the flow is run, and ended when the flow +completes, fails, or is interrupted. Downstream stages are run within the span as well, and running the flow multiple +times opens a new span each time. + +In the example below, the `process-numbers` span is created in a fork, and thanks to the context being propagated, it's +a child of the `request` span: + +```scala mdoc:compile-only +import ox.* +import ox.flow.Flow +import ox.telemetry.tracing.* + +def example(tracing: OxTracing): Unit = + val result = supervised { + fork(Flow.fromValues(1, 2, 3).span(tracing)("process-numbers").runToList()).join() + }.byNameSpan(tracing)("request") + println(result) +``` + +Both accept optional `spanKind`, `attributes` and `links`, as well as a `beforeClose` callback, called right before the +span is closed, e.g. to set attributes or add events. The callback receives the span and the exception thrown by the +spanned code, if any, and has to return the span. If it throws, the exception is added as suppressed to the one thrown +by the spanned code; the span is closed in either case: + +```scala mdoc:compile-only +import ox.flow.Flow +import ox.telemetry.tracing.* + +def withAttributes(tracing: OxTracing): List[Int] = + Flow + .fromValues(1, 2, 3) + .span(tracing)("process-numbers", beforeClose = (span, error) => span.setAttribute("failed", error.isDefined)) + .runToList() +``` + +## Manual spans + +`spanUnsafe` opens a span, which has to be closed by the caller. Closing the returned `AutoCloseableSpan` ends the span +and restores the previously current span. It's unsafe, as forgetting to close it leaves the span open and the current +span unrestored; use it with [`useCloseableInScope`](../utils/resources.md), so that closing is guaranteed. +`currentSpanUnsafe` returns the current span, e.g. to add attributes: + +```scala mdoc:compile-only +import ox.* +import ox.telemetry.tracing.* + +def manual(tracing: OxTracing): Unit = + resourceScope { + val opened = useCloseableInScope(spanUnsafe(tracing)("manual-span")) + opened.span.setAttribute("key", "value") + currentSpanUnsafe(tracing).addEvent("working"): Unit + } +``` diff --git a/otel/src/main/scala/ox/telemetry/ContextHolder.scala b/otel/src/main/scala/ox/telemetry/ContextHolder.scala index d91a958a..b2f42e3d 100644 --- a/otel/src/main/scala/ox/telemetry/ContextHolder.scala +++ b/otel/src/main/scala/ox/telemetry/ContextHolder.scala @@ -3,7 +3,7 @@ package ox.telemetry import io.opentelemetry.context.{Context, Scope} import ox.otel.context.PropagatingVirtualThreadFactory -/** Abstraction over how [[OxTracing]] interacts with OpenTelemetry [[Context]]. +/** Abstraction over how [[ox.telemetry.tracing.OxTracing]] interacts with OpenTelemetry [[Context]]. */ sealed trait ContextHolder: /** Returns the current [[Context]]. */ diff --git a/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala b/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala index add8a45b..e0f3b2e5 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala @@ -87,7 +87,7 @@ end OxTracing object OxTracing: - /** Tracing that relies on the [[PropagatingVirtualThreadFactory]] to propagate the context to forks. A warning is printed to stderr on + /** Tracing that relies on the [[ox.otel.context.PropagatingVirtualThreadFactory]] to propagate the context to forks. A warning is printed to stderr on * first use, if the thread factory is not set up. * * @see @@ -96,7 +96,7 @@ object OxTracing: def native(tracer: Tracer): OxTracing = Impl(tracer, ContextHolder.CheckingThreadFactory) /** Tracing which leaves the propagation of the [[io.opentelemetry.context.Context]] to new threads to the user. For an out-of-the-box - * integration, use [[native]] together with the [[PropagatingVirtualThreadFactory]]. + * integration, use [[native]] together with the [[ox.otel.context.PropagatingVirtualThreadFactory]]. */ def custom(tracer: Tracer): OxTracing = Impl(tracer, ContextHolder.Delegating) diff --git a/otel/src/main/scala/ox/telemetry/tracing/flow/OxFlowTracing.scala b/otel/src/main/scala/ox/telemetry/tracing/flow/OxFlowTracing.scala index 28296cbc..f4cffa2e 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/flow/OxFlowTracing.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/flow/OxFlowTracing.scala @@ -8,8 +8,9 @@ import ox.telemetry.tracing.OxTracing private[tracing] trait OxFlowTracing: self: OxTracing => - /** Returns a flow, which when run, evaluates `flow` within a new span. The span is opened when the flow is run, as a child of the span current at that point. It is ended when the flow completes, fails or is interrupted. Exceptions are recorded on the span as - * in [[OxTracing.byNameSpan]]. + /** Returns a flow, which when run, evaluates `flow` within a new span. The span is opened when the flow is run, as a child of the span + * current at that point. It is ended when the flow completes, fails or is interrupted. Exceptions are recorded on the span as in + * [[OxTracing.byNameSpan]]. * * The parameters are the same as for [[OxTracing.byNameSpan]]. */ diff --git a/otel/src/main/scala/ox/telemetry/tracing/flow/flowTracing.scala b/otel/src/main/scala/ox/telemetry/tracing/flow/flowTracing.scala index 2e29c7c3..98802ca0 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/flow/flowTracing.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/flow/flowTracing.scala @@ -6,7 +6,7 @@ import ox.flow.Flow import ox.telemetry.tracing.flow.OxFlowTracing extension [T](flow: Flow[T]) - /** Opens a span around the run of this flow, using the given [[OxFlowTracing]], see [[OxFlowTracing.span]]. */ + /** Opens a span around the run of this flow, using the given [[ox.telemetry.tracing.OxTracing]], see [[ox.telemetry.tracing.OxTracing.span]]. */ def span(oxTracing: OxFlowTracing)( spanName: String, spanKind: SpanKind = SpanKind.INTERNAL, From 703ffc7623bd22d5481f253ac0f2825a39537ca9 Mon Sep 17 00:00:00 2001 From: Pawel Sadlo Date: Wed, 14 Oct 2026 08:17:33 +0200 Subject: [PATCH 30/38] fmt --- otel/src/main/scala/ox/telemetry/ContextHolder.scala | 7 +++---- otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala | 8 ++++---- .../scala/ox/telemetry/tracing/flow/flowTracing.scala | 4 +++- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/otel/src/main/scala/ox/telemetry/ContextHolder.scala b/otel/src/main/scala/ox/telemetry/ContextHolder.scala index b2f42e3d..c3ab3379 100644 --- a/otel/src/main/scala/ox/telemetry/ContextHolder.scala +++ b/otel/src/main/scala/ox/telemetry/ContextHolder.scala @@ -14,8 +14,7 @@ sealed trait ContextHolder: private[telemetry] object ContextHolder: - /** Delegates to OpenTelemetry Context. Propagating the context to new (virtual) threads is - * left to the user. + /** Delegates to OpenTelemetry Context. Propagating the context to new (virtual) threads is left to the user. */ private[telemetry] object Delegating extends ContextHolder: @@ -23,8 +22,8 @@ private[telemetry] object ContextHolder: override def set(context: Context): Scope = context.makeCurrent() - /** As [[Delegating]], but warns when [[ox.oxThreadFactory]] is not a [[PropagatingVirtualThreadFactory]], - * as in that case the context won't be propagated to forks. + /** As [[Delegating]], but warns when [[ox.oxThreadFactory]] is not a [[PropagatingVirtualThreadFactory]], as in that case the context + * won't be propagated to forks. */ private[telemetry] object CheckingThreadFactory extends ContextHolder: @volatile private var checked = false diff --git a/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala b/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala index e0f3b2e5..c1966039 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala @@ -49,8 +49,8 @@ sealed trait OxTracing extends OxFlowTracing: * * @param beforeClose * Run right before the span is closed, e.g. to set attributes or add events. The second element of the argument is the exception - * thrown by `t`, if any. If `beforeClose` itself throws, the exception is added as suppressed to the one thrown by `t`, or thrown - * if `t` succeeded. The span is closed in either case. + * thrown by `t`, if any. If `beforeClose` itself throws, the exception is added as suppressed to the one thrown by `t`, or thrown if + * `t` succeeded. The span is closed in either case. */ def byNameSpan[T](t: => T)( spanName: String, @@ -87,8 +87,8 @@ end OxTracing object OxTracing: - /** Tracing that relies on the [[ox.otel.context.PropagatingVirtualThreadFactory]] to propagate the context to forks. A warning is printed to stderr on - * first use, if the thread factory is not set up. + /** Tracing that relies on the [[ox.otel.context.PropagatingVirtualThreadFactory]] to propagate the context to forks. A warning is printed + * to stderr on first use, if the thread factory is not set up. * * @see * [[ox.OxApp.Settings.threadFactory]], [[ox.setOxThreadFactory]] diff --git a/otel/src/main/scala/ox/telemetry/tracing/flow/flowTracing.scala b/otel/src/main/scala/ox/telemetry/tracing/flow/flowTracing.scala index 98802ca0..e5c3d11f 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/flow/flowTracing.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/flow/flowTracing.scala @@ -6,7 +6,9 @@ import ox.flow.Flow import ox.telemetry.tracing.flow.OxFlowTracing extension [T](flow: Flow[T]) - /** Opens a span around the run of this flow, using the given [[ox.telemetry.tracing.OxTracing]], see [[ox.telemetry.tracing.OxTracing.span]]. */ + /** Opens a span around the run of this flow, using the given [[ox.telemetry.tracing.OxTracing]], see + * [[ox.telemetry.tracing.OxTracing.span]]. + */ def span(oxTracing: OxFlowTracing)( spanName: String, spanKind: SpanKind = SpanKind.INTERNAL, From dbb804d0c6fef62d1c33560d1547ecc1cd4ac6ec Mon Sep 17 00:00:00 2001 From: Pawel Sadlo Date: Wed, 14 Oct 2026 08:29:25 +0200 Subject: [PATCH 31/38] bump otel --- build.sbt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/build.sbt b/build.sbt index f509e652..ebf56f9c 100644 --- a/build.sbt +++ b/build.sbt @@ -129,7 +129,7 @@ lazy val otelContext: Project = (project in file("otel-context")) .settings( name := "otel-context", libraryDependencies ++= Seq( - "io.opentelemetry" % "opentelemetry-api" % "1.66.0", + "io.opentelemetry" % "opentelemetry-api" % "1.67.0", scalaTest ) ) @@ -141,10 +141,10 @@ lazy val otel: Project = (project in file("otel")) Test / fork := true, Test / javaOptions += "-Dio.opentelemetry.context.enableStrictContext=true", libraryDependencies ++= Seq( - "io.opentelemetry" % "opentelemetry-api" % "1.66.0", - "io.opentelemetry" % "opentelemetry-sdk" % "1.66.0", + "io.opentelemetry" % "opentelemetry-api" % "1.67.0", + "io.opentelemetry" % "opentelemetry-sdk" % "1.67.0", scalaTest, - "io.opentelemetry" % "opentelemetry-sdk-testing" % "1.66.0" % Test + "io.opentelemetry" % "opentelemetry-sdk-testing" % "1.67.0" % Test ) ) .dependsOn(core % "test->test;compile->compile", otelContext % "test->test;compile->compile") From 2c4fb7da83503a4a0b384d7929436dc0c3007ca5 Mon Sep 17 00:00:00 2001 From: Pawel Sadlo Date: Thu, 15 Oct 2026 10:21:53 +0200 Subject: [PATCH 32/38] test --- otel/src/test/scala/OxTelemetryTest.scala | 229 ++++++++++++---------- 1 file changed, 130 insertions(+), 99 deletions(-) diff --git a/otel/src/test/scala/OxTelemetryTest.scala b/otel/src/test/scala/OxTelemetryTest.scala index 579c5c87..303bd9d0 100644 --- a/otel/src/test/scala/OxTelemetryTest.scala +++ b/otel/src/test/scala/OxTelemetryTest.scala @@ -1,16 +1,15 @@ -import io.opentelemetry.api.metrics.Meter import io.opentelemetry.api.trace.StatusCode -import io.opentelemetry.context.Context import io.opentelemetry.sdk.OpenTelemetrySdk import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter import io.opentelemetry.sdk.trace.SdkTracerProvider import io.opentelemetry.sdk.trace.`export`.SimpleSpanProcessor import org.scalatest.BeforeAndAfterAll +import org.scalatest.concurrent.Eventually import org.scalatest.matchers.should.Matchers import org.scalatest.time.SpanSugar.convertIntToGrainOfTime import org.scalatest.wordspec.AnyWordSpec import ox.* -import ox.channels.BufferCapacity +import ox.channels.{BufferCapacity, Channel, Sink, Source} import ox.either.ok import ox.flow.Flow import ox.otel.context.PropagatingVirtualThreadFactory @@ -21,7 +20,7 @@ import java.util.concurrent.Semaphore import scala.collection.JavaConverters.asScalaBufferConverter import scala.util.boundary.Label -class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: +class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll with Eventually: override def beforeAll(): Unit = setOxThreadFactory(new PropagatingVirtualThreadFactory) @@ -41,7 +40,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: val tracer = openTelemetry.getTracer("test") - val myTracing: OxTracing = OxTracing.native(tracer) + val myTracing: OxTracing = OxTracing(tracer) enum Level: case Level0 @@ -59,53 +58,60 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: def spansOf(level: Level) = testSpans.filter(_.getName.endsWith(level.toString)) - def assertContextIsRoot() = assert(Context.current() == Context.root) - def assertNesting(): Unit = val level0 = spansOf(Level0) val level1First = spansOf(Level1_First) - level0.foreach { s => - assert(!s.getParentSpanContext.isValid) - } + val level1 = spansOf(Level1) ++ level1First + val level1FirstChildren = spansOf(Level1_First_Child) - (spansOf(Level1) ++ level1First).foreach { s => + level1.foreach { s => assert(level0.exists(_.getSpanId == s.getParentSpanId)) } - spansOf(Level1_First_Child).foreach { s => + level1FirstChildren.foreach { s => assert(level1First.exists(_.getSpanId == s.getParentSpanId)) } end assertNesting - - def assertNestingAndRoot(): Unit = - assertNesting() - assertContextIsRoot() end Fixture "Flow.span" when { "run normally" should { "not open a span until the flow is run" in new Fixture: + flow.span(myTracing)(s"$prefix-$Level0"): Unit assert(testSpans.isEmpty) - assertNestingAndRoot() + assertNesting() "open a single span for a multi-element flow" in new Fixture: flow.span(myTracing)(s"$prefix-$Level0").runDrain() assert(spansOf(Level0).size == 1) assert(testSpans.size == 1) - assertNestingAndRoot() + assertNesting() + + "handle buffer one span per span() call when spans are nested" in new Fixture: + supervised { + + val spanned1 = flow.span(myTracing)(s"$prefix-$Level1").map(_+ 100).tap(i => currentSpanUnsafe(myTracing).addEvent(i.toString)) + + spanned1.span(myTracing)(s"$prefix-$Level0").runDrain() + + assert(spansOf(Level0).size == 1) + assert(spansOf(Level1).size == 1) + assert(testSpans.size == 2) + assertNesting() + } "open one span per span() call when spans are nested" in new Fixture: supervised { - val spanned1 = flow.span(myTracing)(s"$prefix-$Level1") ++ Flow.fromValues(4, 2) + val spanned1 = (flow.span(myTracing)(s"$prefix-$Level1") ++ Flow.fromValues(4, 2)).tap(i => currentSpanUnsafe(myTracing).addEvent(i.toString)) spanned1.span(myTracing)(s"$prefix-$Level0").runDrain() assert(spansOf(Level0).size == 1) assert(spansOf(Level1).size == 1) assert(testSpans.size == 2) - assertNestingAndRoot() + assertNesting() } "open a single span when downstream uses mapPar" in new Fixture: @@ -116,7 +122,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: val only = spansOf(Level0).head assert(only.getStatus.getStatusCode == StatusCode.UNSET) assert(only.getEvents.isEmpty) - assertNestingAndRoot() + assertNesting() } "used under parent spans" should { @@ -129,7 +135,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: assert(spansOf(Level0).size == 1) assert(spansOf(Level1).size == 1) - assertNestingAndRoot() + assertNesting() "open one child span per element when flatMapped under a parent span" in new Fixture: val spanned = flow.span(myTracing)(s"$prefix-$Level1") @@ -138,7 +144,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: assert(spansOf(Level0).size == 1) assert(spansOf(Level1).size == 3) - assertNestingAndRoot() + assertNesting() "parent spans of concurrently run flows under the enclosing span" in new Fixture: val spanned = flow.span(myTracing)(s"$prefix-$Level1") @@ -153,7 +159,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: assert(spansOf(Level0).size == 1) assert(spansOf(Level1).size == 10) - assertNestingAndRoot() + assertNesting() "nest spans across forks and inner supervised scopes" in new Fixture: val spanned = flow.span(myTracing)(s"$prefix-$Level1") @@ -163,13 +169,13 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: case 1 => forkUser( flow - .span(myTracing)(s"$prefix-$Level1_First") .flatMap { el => val innerFlow = supervised { flow.span(myTracing)(s"$prefix-$Level1_First_Child", beforeClose = _._1.addEvent("boomed")) } innerFlow } + .span(myTracing)(s"$prefix-$Level1_First") .runToList() ) @@ -181,7 +187,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: assert(spansOf(Level1_First).size == 1) assert(spansOf(Level1_First_Child).size == 3) - assertNestingAndRoot() + assertNesting() } "a stage fails" should { @@ -189,7 +195,9 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: try flow .tap { case 2 => throw new Exception("boom"); case _ => () } - .span(myTracing)(s"$prefix-$Level0", beforeClose = (s, thr) => s.addEvent(thr.toString)) + .span(myTracing)(s"$prefix-$Level0", beforeClose = { + case (s, thr) => s.addEvent(thr.toString) + }) .runToList() catch case _ => () @@ -199,39 +207,20 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: assert(failed.getStatus.getStatusCode == StatusCode.ERROR) assert(failed.getStatus.getDescription == "boom") assert(failed.getEvents.asScala.exists(_.getName == "exception")) - assertNestingAndRoot() - - "end span when a downstream stage fails after span" in new Fixture: - val spanned = flow.span(myTracing)(s"$prefix-$Level0", beforeClose = (s, thr) => s.addEvent(thr.toString)) - - try spanned.map { case 2 => throw new Exception("boom"); case n => n }.runToList() - catch case _ => () - - assert(spansOf(Level0).size == 1) - assert(testSpans.size == 1) - assertNestingAndRoot() - - "end span when downstream failure is recovered" in new Fixture: - flow.span(myTracing)(s"$prefix-$Level0").map { case 2 => throw new Exception("boom"); case x => x }.recover(_ => 5).runToList() - - assert(spansOf(Level0).size == 1) - assert(testSpans.size == 1) - assertNestingAndRoot() + assertNesting() "keep the flow span as a child of the recovery span when failure is recovered" in new Fixture: val spanned = flow.span(myTracing)(s"$prefix-$Level1") spanned - .map { case 2 => throw new Exception("boom"); case x => currentSpanUnsafe(myTracing).addEvent(x.toString); x } - .recover { x => - currentSpanUnsafe(myTracing).addEvent("recovery"); 5 - } + .map { case 2 => throw new Exception("boom"); case x => x } + .recover(_ => 5) .span(myTracing)(s"$prefix-$Level0") .runToList(): Unit assert(spansOf(Level0).size == 1) assert(spansOf(Level1).size == 1) - assertNestingAndRoot() + assertNesting() "end spans of concurrent flows when one of them fails" in new Fixture: val spanned = flow.span(myTracing)(s"$prefix-$Level1") @@ -250,7 +239,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: parent.close() assert(spansOf(Level0).size == 1) - assertNestingAndRoot() + assertNesting() "end inner spans with event when nested flow fails in supervisedError" in new Fixture: val em = EitherMode[String] @@ -265,22 +254,21 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: case 1 => forkUser { flow - .span(myTracing)(s"$prefix-$Level1_First") - .flatMap { el => - { - val inner = supervisedError(em): - either: - val innerFlow = - em.pure( - flow.span(myTracing)(s"$prefix-$Level1_First_Child", beforeClose = _._1.addEvent(s"boomed at $el")).map { - case 1 => em.pureError("ops").ok(); - case x => x - } - ) - innerFlow.ok() - inner - }.toOption.get + .flatMap { el => { + val inner = supervisedError(em): + either: + val innerFlow = + em.pure( + flow.span(myTracing)(s"$prefix-$Level1_First_Child", beforeClose = _._1.addEvent(s"boomed at $el")).map { + case 1 => em.pureError("ops").ok(); + case x => x + } + ) + innerFlow.ok() + inner + }.toOption.get } + .span(myTracing)(s"$prefix-$Level1_First") .runToList() } case _ => forkUser(spanned.runToList()) @@ -291,7 +279,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: level0.close() assert(spansOf(Level1_First_Child).size == 1) // because flow.flatMap is evaluated sequentially and exception ends whole flow - assertNestingAndRoot() + assertNesting() "end inner spans with event when nested flow fails and failure is replaced by empty flow" in new Fixture: val em = EitherMode[String] @@ -306,22 +294,21 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: case 1 => forkUser { flow - .span(myTracing)(s"$prefix-$Level1_First") - .flatMap { el => - { - val inner = supervisedError(em): - either: - val innerFlow = - em.pure( - flow.span(myTracing)(s"$prefix-$Level1_First_Child", beforeClose = _._1.addEvent(s"boomed at $el")).map { - case 1 => em.pureError("ops").ok(); - case x => x - } - ) - innerFlow.ok() - inner - }.toOption.getOrElse(Flow.empty) + .flatMap { el => { + val inner = supervisedError(em): + either: + val innerFlow = + em.pure( + flow.span(myTracing)(s"$prefix-$Level1_First_Child", beforeClose = _._1.addEvent(s"boomed at $el")).map { + case 1 => em.pureError("ops").ok(); + case x => x + } + ) + innerFlow.ok() + inner + }.toOption.getOrElse(Flow.empty) } + .span(myTracing)(s"$prefix-$Level1_First") .runToList() } case _ => forkUser(spanned.runToList()) @@ -332,7 +319,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: level0.close() assert(spansOf(Level1_First_Child).size == 1) // because flow.flatMap is evaluated sequentially and exception ends whole flow - assertNestingAndRoot() + assertNesting() "end inner spans with event when nested flow fails inside enclosing supervisedError" in new Fixture: val em = EitherMode[String] @@ -349,20 +336,19 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: case 1 => forkUser { flow - .span(myTracing)(s"$prefix-$Level1_First") - .flatMap { el => - { - supervisedError(em): - val innerFlow = - em.pure( - flow.span(myTracing)(s"$prefix-$Level1_First_Child", beforeClose = _._1.addEvent(s"boomed at $el")).map { - case 1 => em.pureError("ops").ok(); - case x => x - } - ) - innerFlow - }.toOption.get + .flatMap { el => { + supervisedError(em): + val innerFlow = + em.pure( + flow.span(myTracing)(s"$prefix-$Level1_First_Child", beforeClose = _._1.addEvent(s"boomed at $el")).map { + case 1 => em.pureError("ops").ok(); + case x => x + } + ) + innerFlow + }.toOption.get } + .span(myTracing)(s"$prefix-$Level1_First") .runToList() } case _ => forkUser(spanned.runToList()) @@ -373,7 +359,52 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: level0.close() assert(spansOf(Level1_First_Child).size == 1) // because flow.flatMap is evaluated sequentially and exception ends whole flow - assertNestingAndRoot() + assertNesting() + } + + "used with downstream stages" should { + "not make the flow span current in downstream stages" in new Fixture: + val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") + + flow.span(myTracing)(s"$prefix-$Level1").tap(_ => myTracing.byNameSpan(())(s"$prefix-$Level1_First")).runToList(): Unit + + parent.close() + + assert(spansOf(Level1).size == 1) + assert(spansOf(Level1_First).size == 3) + // spans created downstream are siblings of the flow span, as downstream stages run outside of it + assertNesting() + + "make the flow span current in upstream stages" in new Fixture: + val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") + + flow.tap(_ => myTracing.byNameSpan(())(s"$prefix-$Level1_First_Child")).span(myTracing)(s"$prefix-$Level1_First").runToList(): Unit + + parent.close() + + assert(spansOf(Level1_First).size == 1) + assert(spansOf(Level1_First_Child).size == 3) + assertNesting() + + "not mark the span as failed when a downstream stage fails" in new Fixture: + val spanned = flow.span(myTracing)(s"$prefix-$Level0") + + try + spanned.map { case 2 => eventually(assert(spansOf(Level0).nonEmpty)); throw new Exception("boom"); case n => n }.runToList(): Unit + catch case _ => () + + assert(spansOf(Level0).size == 1) + assert(testSpans.size == 1) + assert(spansOf(Level0).head.getStatus.getStatusCode == StatusCode.UNSET) + assert(spansOf(Level0).head.getEvents.isEmpty) + assertNesting() + + "end span when downstream failure is recovered" in new Fixture: + flow.span(myTracing)(s"$prefix-$Level0").map { case 2 => throw new Exception("boom"); case x => x }.recover(_ => 5).runToList() + + assert(spansOf(Level0).size == 1) + assert(testSpans.size == 1) + assertNesting() } "cancelled" should { @@ -395,7 +426,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: assert(spansOf(Level0).size == 1) assert(testSpans.size == 1) - assertNestingAndRoot() + assertNesting() "end span when the enclosing fork is cancelled" in new Fixture: val semaphore = Semaphore(0) @@ -410,7 +441,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: assert(spansOf(Level0).size == 1) assert(testSpans.size == 1) - assertNestingAndRoot() + assertNesting() } } end OxTelemetryTest From 572ffee7d4b7c70c6247009a73627c9dc0f9dba2 Mon Sep 17 00:00:00 2001 From: Pawel Sadlo Date: Mon, 19 Oct 2026 15:24:58 +0200 Subject: [PATCH 33/38] Flow tracing combinators: span variants, timing tests Co-authored-by: Claude-Opus-4.8 via Claude-Code --- build.sbt | 4 +- doc/integrations/otel-tracing.md | 59 +- .../scala/ox/flow/tracing/OxFlowTracing.scala | 178 +++++ .../scala/ox/flow/tracing/flowTracing.scala | 60 ++ .../scala/ox/telemetry/ContextHolder.scala | 48 -- .../ox/telemetry/tracing/OxTracing.scala | 67 +- .../ox/telemetry/tracing/SpanWithError.scala | 11 + .../tracing/flow/OxFlowTracing.scala | 29 - .../telemetry/tracing/flow/flowTracing.scala | 19 - .../scala/ox/telemetry/tracing/tracing.scala | 2 +- otel/src/test/scala/OxTelemetryTest.scala | 710 ++++++++++++++++-- 11 files changed, 999 insertions(+), 188 deletions(-) create mode 100644 otel/src/main/scala/ox/flow/tracing/OxFlowTracing.scala create mode 100644 otel/src/main/scala/ox/flow/tracing/flowTracing.scala delete mode 100644 otel/src/main/scala/ox/telemetry/ContextHolder.scala create mode 100644 otel/src/main/scala/ox/telemetry/tracing/SpanWithError.scala delete mode 100644 otel/src/main/scala/ox/telemetry/tracing/flow/OxFlowTracing.scala delete mode 100644 otel/src/main/scala/ox/telemetry/tracing/flow/flowTracing.scala diff --git a/build.sbt b/build.sbt index ebf56f9c..b312737a 100644 --- a/build.sbt +++ b/build.sbt @@ -142,9 +142,9 @@ lazy val otel: Project = (project in file("otel")) Test / javaOptions += "-Dio.opentelemetry.context.enableStrictContext=true", libraryDependencies ++= Seq( "io.opentelemetry" % "opentelemetry-api" % "1.67.0", - "io.opentelemetry" % "opentelemetry-sdk" % "1.67.0", scalaTest, - "io.opentelemetry" % "opentelemetry-sdk-testing" % "1.67.0" % Test + "io.opentelemetry" % "opentelemetry-sdk-testing" % "1.67.0" % Test, + "io.opentelemetry" % "opentelemetry-exporter-otlp" % "1.67.0" % Test ) ) .dependsOn(core % "test->test;compile->compile", otelContext % "test->test;compile->compile") diff --git a/doc/integrations/otel-tracing.md b/doc/integrations/otel-tracing.md index fcfa0d34..89b54b1b 100644 --- a/doc/integrations/otel-tracing.md +++ b/doc/integrations/otel-tracing.md @@ -19,17 +19,17 @@ Spans are created using an `OxTracing` instance, wrapping an OpenTelemetry `Trac import io.opentelemetry.api.trace.Tracer import ox.telemetry.tracing.OxTracing -def tracing(tracer: Tracer): OxTracing = OxTracing.native(tracer) +def tracing(tracer: Tracer): OxTracing = OxTracing(tracer) ``` ## Propagating the context The current span is kept in the OpenTelemetry `Context`, which by default isn't propagated to new virtual threads, e.g. -forks created in [`supervised`](../structured-concurrency/fork-join.md) scopes. `OxTracing.native` relies on the -`PropagatingVirtualThreadFactory` from [otel-context](otel-context.md) to do that, and prints a warning to stderr if -it's not set up. The factory can be set for the whole app when using [`OxApp`](../utils/oxapp.md), or manually through -`setOxThreadFactory`, before the thread factory is first used (that is, before any scope is started, or `OxTracing.native` -is used): +forks created in [`supervised`](../structured-concurrency/fork-join.md) scopes. `OxTracing` therefore **requires** the +`PropagatingVirtualThreadFactory` from [otel-context](otel-context.md) to be used; without it, spans created in forks +won't be children of the span active when the fork was created. The factory can be set for the whole app when using +[`OxApp`](../utils/oxapp.md), or manually through `setOxThreadFactory`, before the thread factory is first used (that +is, before any scope is started): ```scala mdoc:compile-only import ox.* @@ -43,15 +43,19 @@ object MyApp extends OxApp: def run(args: Vector[String])(using Ox): ExitCode = ExitCode.Success ``` -If you want to take care of context propagation yourself, use `OxTracing.custom`. - ## Spans around code and flows `byNameSpan` evaluates a code block within a span. The span is ended once the block completes, and if the block throws, -the exception is recorded on the span, and its status is set to `ERROR` (this includes an interruption). `Flow.span` +the exception is recorded on the span, and its status is set to `ERROR`. An interruption and control-flow exits +(`boundary.break`) end the span normally, as they aren't failures. `Flow.spanned` does the same for a [`Flow`](../streaming/flows.md): the span is opened when the flow is run, and ended when the flow -completes, fails, or is interrupted. Downstream stages are run within the span as well, and running the flow multiple -times opens a new span each time. +completes, fails, or is interrupted. Running the flow multiple times opens a new span each time. + +The span covers the whole execution of the flow: from when it is run until it completes. This includes the stages placed +after `spanned` that run synchronously (e.g. `map`, `flatMap`): they are called from within the span, so their duration +is counted, and spans created there are its children. Stages after an asynchronous boundary, e.g. +`.spanned(...).buffer().map(...)`, run concurrently, so they don't extend the span, which ends once its upstream +completes. Put `spanned` last to cover the whole pipeline. In the example below, the `process-numbers` span is created in a fork, and thanks to the context being propagated, it's a child of the `request` span: @@ -63,7 +67,7 @@ import ox.telemetry.tracing.* def example(tracing: OxTracing): Unit = val result = supervised { - fork(Flow.fromValues(1, 2, 3).span(tracing)("process-numbers").runToList()).join() + fork(Flow.fromValues(1, 2, 3).spanned(tracing)("process-numbers").runToList()).join() }.byNameSpan(tracing)("request") println(result) ``` @@ -80,10 +84,39 @@ import ox.telemetry.tracing.* def withAttributes(tracing: OxTracing): List[Int] = Flow .fromValues(1, 2, 3) - .span(tracing)("process-numbers", beforeClose = (span, error) => span.setAttribute("failed", error.isDefined)) + .spanned(tracing)("process-numbers", beforeClose = s => s.span.setAttribute("failed", s.errorOpt.isDefined)) .runToList() ``` +## Spans per element + +`Flow.spanned` opens a single span for the whole run. To have a span per element, open it in the stage that processes the +element. For a single stage, use `byNameSpan` inside it; it's a child of the current span, so it nests under a +`Flow.spanned` placed earlier in the flow. To span several stages per element, run them in an inner flow, and span that +flow. `flatMap` processes the elements one after another, while `mapPar` runs the inner flows in parallel: + +```scala mdoc:compile-only +import ox.flow.Flow +import ox.telemetry.tracing.* + +def perElement(tracing: OxTracing, flow: Flow[Int]): Unit = + // one stage + flow.map(x => tracing.byNameSpan(x + 1)("increment")).runDrain() + + // several stages, sequentially + flow + .flatMap(x => Flow.fromValues(x).map(_ + 1).filter(_ % 2 == 0).spanned(tracing)("element")) + .runDrain() + + // several stages, in parallel + flow + .mapPar(4)(x => Flow.fromValues(x).map(_ + 1).filter(_ % 2 == 0).spanned(tracing)("element").runToList()) + .runDrain() +``` + +The inner flow can use any stages, e.g. `grouped`: the span is opened when the inner flow is run, and closed after its +last element is processed, or when it fails. + ## Manual spans `spanUnsafe` opens a span, which has to be closed by the caller. Closing the returned `AutoCloseableSpan` ends the span diff --git a/otel/src/main/scala/ox/flow/tracing/OxFlowTracing.scala b/otel/src/main/scala/ox/flow/tracing/OxFlowTracing.scala new file mode 100644 index 00000000..ce80b800 --- /dev/null +++ b/otel/src/main/scala/ox/flow/tracing/OxFlowTracing.scala @@ -0,0 +1,178 @@ +package ox.flow.tracing + +import io.opentelemetry.api.common.Attributes +import io.opentelemetry.api.trace.{Span, SpanContext, SpanKind} +import io.opentelemetry.context.Context +import ox.* +import ox.channels.BufferCapacity +import ox.flow.{Flow, FlowEmit} +import ox.telemetry.tracing.{OxTracing, SpanWithError} + +private[ox] trait OxFlowTracing: + self: OxTracing => + + /** Returns a flow, which when run, evaluates `flow` within a new span. The span is opened when the flow is run, as a child of the span + * current at that point. It is ended when the flow completes, fails or is interrupted. Exceptions are recorded on the span as in + * [[OxTracing.byNameSpan]]. + * + * The span covers the whole execution of the flow: from when it is run until it completes. This includes the stages placed after + * `spanned` that run synchronously (e.g. `map`, `flatMap`), as they are called from within the span: their duration is counted, spans + * created there are children of this span, and if one of them fails, so does the span. Stages after an asynchronous boundary (e.g. + * `buffer`) run concurrently, so they don't extend the span, which ends once its upstream completes. + * + * The parameters are the same as for [[OxTracing.byNameSpan]]. + * + * * NOT OK Span time depends of donstream stages + * * NOT OK Spanned stages dont follow code structure + */ + def spanned[T](flow: Flow[T])( + spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty, + beforeClose: SpanWithError => Span = _._1 + ): Flow[T] = Flow.usingEmitInline: emit => + self.byNameSpan(flow.runToEmit(emit))(spanName, spanKind, attributes, links, beforeClose) + + /** As [[spanned]], but the stages placed after it are isolated from the span: + * - they are run with the context that was current before the span was opened, so spans created there are not children of this + * span, but siblings of it, + * - if one of them fails, the exception is not recorded on the span and doesn't set its status; it is still thrown through the span. + * + * Their duration is still counted in the span, if they run synchronously. + * + * The parameters are the same as for [[OxTracing.byNameSpan]]. + * + * NOT OK Span time depends of donstream stages + * NOT OK Spanned stages dont follow code structure + */ + def spannedIsolated[T](flow: Flow[T])( + spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty, + beforeClose: SpanWithError => Span = _._1 + ): Flow[T] = Flow.usingEmitInline: emit => + val parent = Context.current() + var downstreamError: Throwable | Null = null + self.byNameSpanIgnoring( + flow.runToEmit( + FlowEmit.fromInline: t => + val scope = parent.makeCurrent() + try emit(t) + catch + case e: Throwable => + downstreamError = e + throw e + finally scope.close() + ) + )(_ eq downstreamError)(spanName, spanKind, attributes, links, beforeClose) + + /** As [[spanned]], but the flow is run asynchronously: `flow` and the span run in a fork, and the elements are passed to the stages + * placed after it through a buffer, of the size given by the [[BufferCapacity]] in scope. + * + * Because of that boundary, the span doesn't depend on which stages follow it: + * - it covers `flow` and everything upstream of it, from when the flow is run until its last element is passed to the buffer, or it + * fails; the duration of the stages placed after it is not counted (except through backpressure, once the buffer is full), + * - the stages placed after it run with the context of the caller, so spans created there are not children of this span, + * - exceptions thrown by them are not recorded on the span. + * + * The price is a change of how the flow is run: `flow` runs in a fork, up to the buffer size ahead of the downstream stages, and + * `beforeClose` is called on the fork's thread. + * + * The parameters are the same as for [[OxTracing.byNameSpan]]. + * + * OK - spanned stages follows code structure + * OK - span time is isolated from downstream stages + */ + def spannedAsync[T](flow: Flow[T])( + spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty, + beforeClose: SpanWithError => Span = _._1 + )(using BufferCapacity): Flow[T] = + spanned(flow)(spanName, spanKind, attributes, links, beforeClose).buffer() + + /** Applies `stages` once to a flow of the elements of `flow`, and opens a new span for each element, from when it enters the `stages` + * until the `stages` are done processing it. The stages can be stateful, and can conflate or extrapolate the elements, as they are not + * restarted for each element. The span of an element ends when its processing returns, so: + * - if the `stages` emit several elements for an element (e.g. `flatMap`), the span covers all of them, + * - if the `stages` hold an element back (e.g. `grouped`), the span covers only taking it in, and the element which completes a group + * is also the one whose span covers emitting the group. + * + * Spans created by the `stages` are children of the span of the element being processed. `flow` is run separately of the spans, so + * the time it takes to produce the elements is not counted, and spans created by `flow` are not their children. The result of the + * `stages` is passed to the stages placed after `spanEachStages` through a buffer, of the size given by the [[BufferCapacity]] in + * scope, so they are not counted either, and their spans are not children of the span. The flow can be infinite. + * + * If the `stages` contain an asynchronous stage (e.g. `buffer`, `mapPar`), the span of an element ends once it is passed to it. + * + * The parameters are the same as for [[OxTracing.byNameSpan]]. + */ + def spanEachStages[T, U](flow: Flow[T])( + spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty, + beforeClose: SpanWithError => Span = _._1 + )(stagesToSpan: Flow[T] => Flow[U])(using BufferCapacity): Flow[U] = + stagesToSpan(spanEachDownstream(flow)(spanName, spanKind, attributes, links, beforeClose)).buffer() + + /** As [[ox.flow.Flow.map]], but each call of `f` is run within a new span, which is ended once `f` completes. Only `f` is covered: + * the result is emitted to the stages placed after it when the span is already closed, so they are neither counted nor children of + * the span. Spans created by `f` are children of the span, and if `f` throws, the exception is recorded on it. + * + * The parameters are the same as for [[OxTracing.byNameSpan]]. + */ + def mapSpanned[T, U](flow: Flow[T])( + spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty, + beforeClose: SpanWithError => Span = _._1 + )(f: T => U): Flow[U] = + flow.map(t => self.byNameSpan(f(t))(spanName, spanKind, attributes, links, beforeClose)) + + /** As [[ox.flow.Flow.flatMap]], but the flow returned by each call of `f` is run within a new span, as with [[spannedAsync]]: each such + * flow is run in a fork, and its elements are passed to the stages placed after `flatMapSpanned` through a buffer, of the size given + * by the [[BufferCapacity]] in scope. Because of that boundary, the span doesn't depend on the stages that follow: it covers the flow + * returned by `f`, from when it is run until its last element is passed to the buffer, spans created by the flow are children of the + * span, and the stages placed after are not counted, are not children of it, and their failures are not recorded on it. The outputs + * are emitted lazily, so the flow can be large or infinite. + * + * The parameters are the same as for [[OxTracing.byNameSpan]]. + */ + def flatMapSpanned[T, U](flow: Flow[T])( + spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty, + beforeClose: SpanWithError => Span = _._1 + )(f: T => Flow[U])(using BufferCapacity): Flow[U] = + flow.flatMap(t => spannedAsync(f(t))(spanName, spanKind, attributes, links, beforeClose)) + + /** Returns a flow, which for each element of `flow` opens a new span, emits the element to the stages placed after it, and ends the span + * once they are done with it. The span is an entry point for the downstream stages: it covers them, but not the stages upstream of it. + * + * The stages that run synchronously (e.g. `map`, `flatMap`) are called from within the span, so their duration is counted, spans + * created there are children of it, and if one of them fails, so does the span. If a downstream stage is asynchronous (e.g. `buffer`), + * the span ends once the element is handed over to it. Each span is opened as a child of the span current when the element is emitted. + * + * The parameters are the same as for [[OxTracing.byNameSpan]]. + * + * not ok - not really each downstream, it spans till first async boundary + */ + def spanEachDownstream[T](flow: Flow[T])( + spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty, + beforeClose: SpanWithError => Span = _._1 + ): Flow[T] = Flow.usingEmitInline: emit => + flow.runToEmit( + FlowEmit.fromInline: t => + self.byNameSpan(emit(t))(spanName, spanKind, attributes, links, beforeClose) + ) + +end OxFlowTracing diff --git a/otel/src/main/scala/ox/flow/tracing/flowTracing.scala b/otel/src/main/scala/ox/flow/tracing/flowTracing.scala new file mode 100644 index 00000000..93296955 --- /dev/null +++ b/otel/src/main/scala/ox/flow/tracing/flowTracing.scala @@ -0,0 +1,60 @@ +package ox.flow.tracing + +import io.opentelemetry.api.common.Attributes +import io.opentelemetry.api.trace.{Span, SpanContext, SpanKind} +import ox.channels.BufferCapacity +import ox.flow.Flow +import ox.telemetry.tracing.SpanWithError + +extension [T](flow: Flow[T]) + /** Opens a span around the run of this flow, using the given [[ox.telemetry.tracing.OxTracing]], see + * [[ox.flow.tracing.OxFlowTracing.spanned]]. + */ + def spanned(oxTracing: OxFlowTracing)( + spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty, + beforeClose: SpanWithError => Span = _.span + ): Flow[T] = oxTracing.spanned(flow)(spanName, spanKind, attributes, links, beforeClose) + + /** Opens a span for each element, around the stages placed after it, using the given [[ox.telemetry.tracing.OxTracing]], see + * [[ox.flow.tracing.OxFlowTracing.spanEachDownstream]]. + */ + def spanEachDownstream(oxTracing: OxFlowTracing)( + spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty, + beforeClose: SpanWithError => Span = _.span + ): Flow[T] = oxTracing.spanEachDownstream(flow)(spanName, spanKind, attributes, links, beforeClose) + + /** As [[ox.flow.Flow.map]], with each call of `f` within a span, see [[ox.flow.tracing.OxFlowTracing.mapSpanned]]. */ + def mapSpanned[U](oxTracing: OxFlowTracing)( + spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty, + beforeClose: SpanWithError => Span = _.span + )(f: T => U): Flow[U] = oxTracing.mapSpanned(flow)(spanName, spanKind, attributes, links, beforeClose)(f) + + /** As [[ox.flow.Flow.flatMap]], with each flow returned by `f` run within a span, see [[ox.flow.tracing.OxFlowTracing.flatMapSpanned]]. */ + def flatMapSpanned[U](oxTracing: OxFlowTracing)( + spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty, + beforeClose: SpanWithError => Span = _.span + )(f: T => Flow[U])(using BufferCapacity): Flow[U] = + oxTracing.flatMapSpanned(flow)(spanName, spanKind, attributes, links, beforeClose)(f) + + /** Opens a span for each element, over the given `stages`, see [[ox.flow.tracing.OxFlowTracing.spanEachStages]]. */ + def spanEachStages[U](oxTracing: OxFlowTracing)( + spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty, + beforeClose: SpanWithError => Span = _.span + )(stages: Flow[T] => Flow[U])(using BufferCapacity): Flow[U] = + oxTracing.spanEachStages(flow)(spanName, spanKind, attributes, links, beforeClose)(stages) +end extension diff --git a/otel/src/main/scala/ox/telemetry/ContextHolder.scala b/otel/src/main/scala/ox/telemetry/ContextHolder.scala deleted file mode 100644 index c3ab3379..00000000 --- a/otel/src/main/scala/ox/telemetry/ContextHolder.scala +++ /dev/null @@ -1,48 +0,0 @@ -package ox.telemetry - -import io.opentelemetry.context.{Context, Scope} -import ox.otel.context.PropagatingVirtualThreadFactory - -/** Abstraction over how [[ox.telemetry.tracing.OxTracing]] interacts with OpenTelemetry [[Context]]. - */ -sealed trait ContextHolder: - /** Returns the current [[Context]]. */ - def get(): Context - - /** Makes the given `context` current. Closing the returned [[Scope]] restores the previously current context. */ - def set(context: Context): Scope - -private[telemetry] object ContextHolder: - - /** Delegates to OpenTelemetry Context. Propagating the context to new (virtual) threads is left to the user. - */ - private[telemetry] object Delegating extends ContextHolder: - - override def get(): Context = Context.current() - - override def set(context: Context): Scope = context.makeCurrent() - - /** As [[Delegating]], but warns when [[ox.oxThreadFactory]] is not a [[PropagatingVirtualThreadFactory]], as in that case the context - * won't be propagated to forks. - */ - private[telemetry] object CheckingThreadFactory extends ContextHolder: - @volatile private var checked = false - private val wrapped: ContextHolder = Delegating - - override def get(): Context = - checkThreadFactory() - wrapped.get() - - override def set(context: Context): Scope = - checkThreadFactory() - wrapped.set(context) - - private def checkThreadFactory() = - if !checked then - if !ox.oxThreadFactory.isInstanceOf[PropagatingVirtualThreadFactory] then - System.err.println( - s"Default OpenTelemetry integration should use ${classOf[PropagatingVirtualThreadFactory].getName}, if you want to use OpenTelemetry with your own oxThreadFactory, you need to take care of propagation of OpenTelemetry context to newly created VirtualThreads" - ) - checked = true - end CheckingThreadFactory -end ContextHolder diff --git a/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala b/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala index c1966039..ce739a98 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala @@ -2,19 +2,20 @@ package ox.telemetry.tracing import io.opentelemetry.api.common.Attributes import io.opentelemetry.api.trace.{Span, SpanContext, SpanKind, StatusCode, Tracer} -import ox.telemetry.ContextHolder -import ox.telemetry.tracing.flow.OxFlowTracing +import io.opentelemetry.context.Context +import ox.flow.tracing.OxFlowTracing -sealed trait OxTracing extends OxFlowTracing: +import scala.util.boundary +import scala.util.control.ControlThrowable - protected def contextHolder: ContextHolder +sealed trait OxTracing extends OxFlowTracing: protected def tracer: Tracer /** Returns the current span. This is not unsafe per se, but the caller is responsible for using the span properly, e.g. not ending a span * that is managed elsewhere. */ - def currentSpanUnsafe(): Span = Span.fromContext(contextHolder.get()) + def currentSpanUnsafe(): Span = Span.fromContext(Context.current()) /** Opens a new [[Span]] as a child of the current span. The new span becomes the current one. * @@ -30,11 +31,11 @@ sealed trait OxTracing extends OxFlowTracing: attributes: Attributes = Attributes.empty(), links: Seq[SpanContext] = Seq.empty ): AutoCloseableSpan = - val previousContext = contextHolder.get() + val previousContext = Context.current() val builder = tracer.spanBuilder(spanName).setSpanKind(spanKind).setAllAttributes(attributes).setParent(previousContext) val withLinks = links.foldLeft(builder)((b, l) => b.addLink(l)) val started = withLinks.startSpan() - val scope = contextHolder.set(previousContext.`with`(started)) + val scope = previousContext.`with`(started).makeCurrent() AutoCloseableSpan.Impl(started, scope) end spanUnsafe @@ -43,9 +44,11 @@ sealed trait OxTracing extends OxFlowTracing: * current one. It is ended once `t` completes, successfully or not, and the previously current span is restored. * * If `t` throws, the exception is recorded on the span and the span's status is set to [[io.opentelemetry.api.trace.StatusCode.ERROR]], - * before `beforeClose` is called. The exception is then re-thrown. + * before `beforeClose` is called. The exception is then re-thrown. This doesn't apply to [[InterruptedException]] and + * [[scala.util.control.ControlThrowable]]s, as well as `boundary.break`: they are not failures, but a way of exiting early, so the + * span is ended without an error status, but `beforeClose` still receives them. * - * To span a [[ox.flow.Flow]], use [[OxFlowTracing.span]]. + * To span a [[ox.flow.Flow]], use [[OxFlowTracing.spanned]]. * * @param beforeClose * Run right before the span is closed, e.g. to set attributes or add events. The second element of the argument is the exception @@ -57,7 +60,19 @@ sealed trait OxTracing extends OxFlowTracing: spanKind: SpanKind = SpanKind.INTERNAL, attributes: Attributes = Attributes.empty(), links: Seq[SpanContext] = Seq.empty, - beforeClose: ((Span, Option[Throwable])) => Span = _._1 + beforeClose: SpanWithError => Span = _.span + ): T = byNameSpanIgnoring(t)(_ => false)(spanName, spanKind, attributes, links, beforeClose) + + /** As [[byNameSpan]], but exceptions for which `ignored` returns `true` are not treated as failures of `t`: they are re-thrown, but not + * recorded on the span, don't set its status, and `beforeClose` doesn't receive them. Used to exclude exceptions, which originate + * outside of `t`, e.g. in the stages downstream of a flow. + */ + private[ox] def byNameSpanIgnoring[T](t: => T)(ignored: Throwable => Boolean)( + spanName: String, + spanKind: SpanKind, + attributes: Attributes, + links: Seq[SpanContext], + beforeClose: SpanWithError => Span ): T = var error: Throwable | Null = null var finalizer: AutoCloseableSpan | Null = null @@ -67,13 +82,14 @@ sealed trait OxTracing extends OxFlowTracing: t catch case e: Throwable => - error = e - if finalizer ne null then - finalizer.nn.span.recordException(e) - finalizer.nn.span.setStatus(StatusCode.ERROR, e.getMessage): Unit + if !ignored(e) then + error = e + if (finalizer ne null) && shouldRecord(e) then + finalizer.nn.span.recordException(e) + finalizer.nn.span.setStatus(StatusCode.ERROR, e.getMessage): Unit throw e finally - try if finalizer ne null then beforeClose(finalizer.nn.span, Option(error)): Unit + try if finalizer ne null then beforeClose(SpanWithError(finalizer.nn.span, error)): Unit catch case e: Throwable => if error ne null then error.nn.addSuppressed(e) @@ -82,24 +98,19 @@ sealed trait OxTracing extends OxFlowTracing: end try end try - end byNameSpan + end byNameSpanIgnoring + + private def shouldRecord(e: Throwable): Boolean = !(e.isInstanceOf[InterruptedException] || e.isInstanceOf[ControlThrowable] || e.isInstanceOf[boundary.Break[?]]) end OxTracing object OxTracing: - /** Tracing that relies on the [[ox.otel.context.PropagatingVirtualThreadFactory]] to propagate the context to forks. A warning is printed - * to stderr on first use, if the thread factory is not set up. - * - * @see - * [[ox.OxApp.Settings.threadFactory]], [[ox.setOxThreadFactory]] - */ - def native(tracer: Tracer): OxTracing = Impl(tracer, ContextHolder.CheckingThreadFactory) - - /** Tracing which leaves the propagation of the [[io.opentelemetry.context.Context]] to new threads to the user. For an out-of-the-box - * integration, use [[native]] together with the [[ox.otel.context.PropagatingVirtualThreadFactory]]. + /** Creates tracing, which keeps spans in the OpenTelemetry [[io.opentelemetry.context.Context]]. For the context to be propagated to + * forks, the [[ox.otel.context.PropagatingVirtualThreadFactory]] is required: it has to be set as the Ox thread factory, e.g. using + * [[ox.OxApp.Settings.threadFactory]] or [[ox.setOxThreadFactory]]. */ - def custom(tracer: Tracer): OxTracing = Impl(tracer, ContextHolder.Delegating) + def apply(tracer: Tracer): OxTracing = Impl(tracer) - private final class Impl(val tracer: Tracer, val contextHolder: ContextHolder) extends OxTracing + private final class Impl(val tracer: Tracer) extends OxTracing end OxTracing diff --git a/otel/src/main/scala/ox/telemetry/tracing/SpanWithError.scala b/otel/src/main/scala/ox/telemetry/tracing/SpanWithError.scala new file mode 100644 index 00000000..d53ac032 --- /dev/null +++ b/otel/src/main/scala/ox/telemetry/tracing/SpanWithError.scala @@ -0,0 +1,11 @@ +package ox.telemetry.tracing + +import io.opentelemetry.api.trace.Span + +opaque type SpanWithError <: (Span, Option[Throwable]) = (Span, Option[Throwable]) +object SpanWithError: + def apply(span:Span,errorOpt:Option[Throwable]):SpanWithError = (span, errorOpt) + def apply(span:Span,errorOpt:Throwable):SpanWithError = (span,Option(errorOpt)) + extension(s:SpanWithError) + def span = s._1 + def errorOpt = s._2 diff --git a/otel/src/main/scala/ox/telemetry/tracing/flow/OxFlowTracing.scala b/otel/src/main/scala/ox/telemetry/tracing/flow/OxFlowTracing.scala deleted file mode 100644 index f4cffa2e..00000000 --- a/otel/src/main/scala/ox/telemetry/tracing/flow/OxFlowTracing.scala +++ /dev/null @@ -1,29 +0,0 @@ -package ox.telemetry.tracing.flow - -import io.opentelemetry.api.common.Attributes -import io.opentelemetry.api.trace.{Span, SpanContext, SpanKind} -import ox.flow.Flow -import ox.telemetry.tracing.OxTracing - -private[tracing] trait OxFlowTracing: - self: OxTracing => - - /** Returns a flow, which when run, evaluates `flow` within a new span. The span is opened when the flow is run, as a child of the span - * current at that point. It is ended when the flow completes, fails or is interrupted. Exceptions are recorded on the span as in - * [[OxTracing.byNameSpan]]. - * - * The parameters are the same as for [[OxTracing.byNameSpan]]. - */ - def span[T](flow: Flow[T])( - spanName: String, - spanKind: SpanKind = SpanKind.INTERNAL, - attributes: Attributes = Attributes.empty(), - links: Seq[SpanContext] = Seq.empty, - beforeClose: ((Span, Option[Throwable])) => Span = _._1 - ): Flow[T] = Flow.usingEmit: emit => - self.byNameSpan( - flow.runToEmit( - emit - ) - )(spanName, spanKind, attributes, links, beforeClose) -end OxFlowTracing diff --git a/otel/src/main/scala/ox/telemetry/tracing/flow/flowTracing.scala b/otel/src/main/scala/ox/telemetry/tracing/flow/flowTracing.scala deleted file mode 100644 index e5c3d11f..00000000 --- a/otel/src/main/scala/ox/telemetry/tracing/flow/flowTracing.scala +++ /dev/null @@ -1,19 +0,0 @@ -package ox.telemetry.tracing.flow - -import io.opentelemetry.api.common.Attributes -import io.opentelemetry.api.trace.{Span, SpanContext, SpanKind} -import ox.flow.Flow -import ox.telemetry.tracing.flow.OxFlowTracing - -extension [T](flow: Flow[T]) - /** Opens a span around the run of this flow, using the given [[ox.telemetry.tracing.OxTracing]], see - * [[ox.telemetry.tracing.OxTracing.span]]. - */ - def span(oxTracing: OxFlowTracing)( - spanName: String, - spanKind: SpanKind = SpanKind.INTERNAL, - attributes: Attributes = Attributes.empty(), - links: Seq[SpanContext] = Seq.empty, - beforeClose: ((Span, Option[Throwable])) => Span = _._1 - ): Flow[T] = oxTracing.span(flow)(spanName, spanKind, attributes, links, beforeClose) -end extension diff --git a/otel/src/main/scala/ox/telemetry/tracing/tracing.scala b/otel/src/main/scala/ox/telemetry/tracing/tracing.scala index 3b202d68..d2f23aec 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/tracing.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/tracing.scala @@ -15,7 +15,7 @@ extension [T](t: => T) ): T = oxTracing.byNameSpan(t)(spanName, spanKind, attributes, links, beforeClose) end extension -export ox.telemetry.tracing.flow.span +export ox.flow.tracing.{flatMapSpanned, mapSpanned, spanEachDownstream, spanEachStages, spanned} /** Returns the current span, see [[OxTracing.currentSpanUnsafe]]. */ def currentSpanUnsafe(oxTracing: OxTracing) = oxTracing.currentSpanUnsafe() diff --git a/otel/src/test/scala/OxTelemetryTest.scala b/otel/src/test/scala/OxTelemetryTest.scala index 303bd9d0..8e5fd584 100644 --- a/otel/src/test/scala/OxTelemetryTest.scala +++ b/otel/src/test/scala/OxTelemetryTest.scala @@ -1,10 +1,12 @@ +import io.opentelemetry.api.common.{AttributeKey, Attributes} import io.opentelemetry.api.trace.StatusCode import io.opentelemetry.sdk.OpenTelemetrySdk import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter +import io.opentelemetry.sdk.resources.Resource import io.opentelemetry.sdk.trace.SdkTracerProvider -import io.opentelemetry.sdk.trace.`export`.SimpleSpanProcessor +import io.opentelemetry.sdk.trace.`export`.{BatchSpanProcessor, SimpleSpanProcessor} +import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter import org.scalatest.BeforeAndAfterAll -import org.scalatest.concurrent.Eventually import org.scalatest.matchers.should.Matchers import org.scalatest.time.SpanSugar.convertIntToGrainOfTime import org.scalatest.wordspec.AnyWordSpec @@ -20,17 +22,26 @@ import java.util.concurrent.Semaphore import scala.collection.JavaConverters.asScalaBufferConverter import scala.util.boundary.Label -class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll with Eventually: +class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: override def beforeAll(): Unit = setOxThreadFactory(new PropagatingVirtualThreadFactory) val exporter = InMemorySpanExporter.create() + // To inspect the spans visually, run an OTLP backend and set the endpoint, e.g.: + // docker run --rm -p 16686:16686 -p 4317:4317 jaegertracing/all-in-one + // OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 sbt otel/test + // and open http://localhost:16686 (service `ox-telemetry-test`). val tracerProvider = - SdkTracerProvider + val builder = SdkTracerProvider .builder() + .setResource(Resource.create(Attributes.of(AttributeKey.stringKey("service.name"), "ox-telemetry-test"))) .addSpanProcessor(SimpleSpanProcessor.create(exporter)) - .build() + sys.env.get("OTEL_EXPORTER_OTLP_ENDPOINT").foreach: endpoint => + builder.addSpanProcessor(BatchSpanProcessor.builder(OtlpGrpcSpanExporter.builder().setEndpoint(endpoint).build()).build()) + builder.build() + + override def afterAll(): Unit = tracerProvider.shutdown().join(10, java.util.concurrent.TimeUnit.SECONDS): Unit val openTelemetry = OpenTelemetrySdk @@ -47,6 +58,8 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll w case Level1 case Level1_First case Level1_First_Child + case Level1_Stage + case Level1_Downstream import Level.* @@ -58,6 +71,15 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll w def spansOf(level: Level) = testSpans.filter(_.getName.endsWith(level.toString)) + def durMs(span: io.opentelemetry.sdk.trace.data.SpanData): Long = (span.getEndEpochNanos - span.getStartEpochNanos) / 1000000 + + def startOffsetMs(from: io.opentelemetry.sdk.trace.data.SpanData, to: io.opentelemetry.sdk.trace.data.SpanData): Long = + (to.getStartEpochNanos - from.getStartEpochNanos) / 1000000 + + // sleeps guarantee the lower bound, the upper one leaves room for scheduling jitter + def assertAbout(actualMs: Long, expectedMs: Long): Unit = + assert(actualMs >= expectedMs - 5 && actualMs < expectedMs + 250, s"expected ~$expectedMs ms, was $actualMs ms") + def assertNesting(): Unit = val level0 = spansOf(Level0) val level1First = spansOf(Level1_First) @@ -78,12 +100,12 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll w "Flow.span" when { "run normally" should { "not open a span until the flow is run" in new Fixture: - flow.span(myTracing)(s"$prefix-$Level0"): Unit + flow.spanned(myTracing)(s"$prefix-$Level0"): Unit assert(testSpans.isEmpty) assertNesting() "open a single span for a multi-element flow" in new Fixture: - flow.span(myTracing)(s"$prefix-$Level0").runDrain() + flow.spanned(myTracing)(s"$prefix-$Level0").runDrain() assert(spansOf(Level0).size == 1) assert(testSpans.size == 1) @@ -93,9 +115,9 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll w supervised { - val spanned1 = flow.span(myTracing)(s"$prefix-$Level1").map(_+ 100).tap(i => currentSpanUnsafe(myTracing).addEvent(i.toString)) + val spanned1 = flow.spanned(myTracing)(s"$prefix-$Level1").map(_+ 100).tap(i => currentSpanUnsafe(myTracing).addEvent(i.toString)) - spanned1.span(myTracing)(s"$prefix-$Level0").runDrain() + spanned1.spanned(myTracing)(s"$prefix-$Level0").runDrain() assert(spansOf(Level0).size == 1) assert(spansOf(Level1).size == 1) @@ -104,9 +126,9 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll w } "open one span per span() call when spans are nested" in new Fixture: supervised { - val spanned1 = (flow.span(myTracing)(s"$prefix-$Level1") ++ Flow.fromValues(4, 2)).tap(i => currentSpanUnsafe(myTracing).addEvent(i.toString)) + val spanned1 = (flow.spanned(myTracing)(s"$prefix-$Level1") ++ Flow.fromValues(4, 2)).tap(i => currentSpanUnsafe(myTracing).addEvent(i.toString)) - spanned1.span(myTracing)(s"$prefix-$Level0").runDrain() + spanned1.spanned(myTracing)(s"$prefix-$Level0").runDrain() assert(spansOf(Level0).size == 1) assert(spansOf(Level1).size == 1) @@ -115,7 +137,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll w } "open a single span when downstream uses mapPar" in new Fixture: - flow.span(myTracing)(s"$prefix-$Level0").mapPar(3)(_ + 1).runDrain() + flow.spanned(myTracing)(s"$prefix-$Level0").mapPar(3)(_ + 1).runDrain() assert(testSpans.size == 1) @@ -129,7 +151,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll w "use the current span as parent" in new Fixture: val parent = myTracing.spanUnsafe(s"$prefix-$Level0") - flow.span(myTracing)(s"$prefix-$Level1").runToList(): Unit + flow.spanned(myTracing)(s"$prefix-$Level1").runToList(): Unit parent.close() @@ -138,16 +160,16 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll w assertNesting() "open one child span per element when flatMapped under a parent span" in new Fixture: - val spanned = flow.span(myTracing)(s"$prefix-$Level1") + val spanned = flow.spanned(myTracing)(s"$prefix-$Level1") - flow.flatMap { _ => spanned }.span(myTracing)(s"$prefix-$Level0").runToList(): Unit + flow.flatMap { _ => spanned }.spanned(myTracing)(s"$prefix-$Level0").runToList(): Unit assert(spansOf(Level0).size == 1) assert(spansOf(Level1).size == 3) assertNesting() "parent spans of concurrently run flows under the enclosing span" in new Fixture: - val spanned = flow.span(myTracing)(s"$prefix-$Level1") + val spanned = flow.spanned(myTracing)(s"$prefix-$Level1") val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") @@ -162,7 +184,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll w assertNesting() "nest spans across forks and inner supervised scopes" in new Fixture: - val spanned = flow.span(myTracing)(s"$prefix-$Level1") + val spanned = flow.spanned(myTracing)(s"$prefix-$Level1") supervised { (1 to 10).map { @@ -171,11 +193,11 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll w flow .flatMap { el => val innerFlow = supervised { - flow.span(myTracing)(s"$prefix-$Level1_First_Child", beforeClose = _._1.addEvent("boomed")) + flow.spanned(myTracing)(s"$prefix-$Level1_First_Child", beforeClose = _._1.addEvent("boomed")) } innerFlow } - .span(myTracing)(s"$prefix-$Level1_First") + .spanned(myTracing)(s"$prefix-$Level1_First") .runToList() ) @@ -195,7 +217,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll w try flow .tap { case 2 => throw new Exception("boom"); case _ => () } - .span(myTracing)(s"$prefix-$Level0", beforeClose = { + .spanned(myTracing)(s"$prefix-$Level0", beforeClose = { case (s, thr) => s.addEvent(thr.toString) }) .runToList() @@ -210,12 +232,12 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll w assertNesting() "keep the flow span as a child of the recovery span when failure is recovered" in new Fixture: - val spanned = flow.span(myTracing)(s"$prefix-$Level1") + val spanned = flow.spanned(myTracing)(s"$prefix-$Level1") spanned .map { case 2 => throw new Exception("boom"); case x => x } .recover(_ => 5) - .span(myTracing)(s"$prefix-$Level0") + .spanned(myTracing)(s"$prefix-$Level0") .runToList(): Unit assert(spansOf(Level0).size == 1) @@ -223,7 +245,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll w assertNesting() "end spans of concurrent flows when one of them fails" in new Fixture: - val spanned = flow.span(myTracing)(s"$prefix-$Level1") + val spanned = flow.spanned(myTracing)(s"$prefix-$Level1") val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") @@ -246,7 +268,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll w val level0 = spanUnsafe(myTracing)(s"$prefix-$Level0") - val spanned = flow.span(myTracing)(s"$prefix-$Level1") + val spanned = flow.spanned(myTracing)(s"$prefix-$Level1") try supervised { @@ -259,7 +281,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll w either: val innerFlow = em.pure( - flow.span(myTracing)(s"$prefix-$Level1_First_Child", beforeClose = _._1.addEvent(s"boomed at $el")).map { + flow.spanned(myTracing)(s"$prefix-$Level1_First_Child", beforeClose = _._1.addEvent(s"boomed at $el")).map { case 1 => em.pureError("ops").ok(); case x => x } @@ -268,7 +290,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll w inner }.toOption.get } - .span(myTracing)(s"$prefix-$Level1_First") + .spanned(myTracing)(s"$prefix-$Level1_First") .runToList() } case _ => forkUser(spanned.runToList()) @@ -286,7 +308,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll w val level0 = spanUnsafe(myTracing)(s"$prefix-$Level0") - val spanned = flow.span(myTracing)(s"$prefix-$Level1") + val spanned = flow.spanned(myTracing)(s"$prefix-$Level1") try supervised { @@ -299,7 +321,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll w either: val innerFlow = em.pure( - flow.span(myTracing)(s"$prefix-$Level1_First_Child", beforeClose = _._1.addEvent(s"boomed at $el")).map { + flow.spanned(myTracing)(s"$prefix-$Level1_First_Child", beforeClose = _._1.addEvent(s"boomed at $el")).map { case 1 => em.pureError("ops").ok(); case x => x } @@ -308,7 +330,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll w inner }.toOption.getOrElse(Flow.empty) } - .span(myTracing)(s"$prefix-$Level1_First") + .spanned(myTracing)(s"$prefix-$Level1_First") .runToList() } case _ => forkUser(spanned.runToList()) @@ -326,7 +348,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll w val level0 = spanUnsafe(myTracing)(s"$prefix-$Level0") - val spanned = flow.span(myTracing)(s"$prefix-$Level1") + val spanned = flow.spanned(myTracing)(s"$prefix-$Level1") try supervisedError(em) { @@ -340,7 +362,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll w supervisedError(em): val innerFlow = em.pure( - flow.span(myTracing)(s"$prefix-$Level1_First_Child", beforeClose = _._1.addEvent(s"boomed at $el")).map { + flow.spanned(myTracing)(s"$prefix-$Level1_First_Child", beforeClose = _._1.addEvent(s"boomed at $el")).map { case 1 => em.pureError("ops").ok(); case x => x } @@ -348,7 +370,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll w innerFlow }.toOption.get } - .span(myTracing)(s"$prefix-$Level1_First") + .spanned(myTracing)(s"$prefix-$Level1_First") .runToList() } case _ => forkUser(spanned.runToList()) @@ -363,22 +385,22 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll w } "used with downstream stages" should { - "not make the flow span current in downstream stages" in new Fixture: + "make the flow span current in downstream stages" in new Fixture: val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") - flow.span(myTracing)(s"$prefix-$Level1").tap(_ => myTracing.byNameSpan(())(s"$prefix-$Level1_First")).runToList(): Unit + flow.spanned(myTracing)(s"$prefix-$Level1_First").tap(_ => myTracing.byNameSpan(())(s"$prefix-$Level1_First_Child")).runToList(): Unit parent.close() - assert(spansOf(Level1).size == 1) - assert(spansOf(Level1_First).size == 3) - // spans created downstream are siblings of the flow span, as downstream stages run outside of it + assert(spansOf(Level1_First).size == 1) + // downstream stages are called from within the span, so spans created there are its children + assert(spansOf(Level1_First_Child).size == 3) assertNesting() "make the flow span current in upstream stages" in new Fixture: val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") - flow.tap(_ => myTracing.byNameSpan(())(s"$prefix-$Level1_First_Child")).span(myTracing)(s"$prefix-$Level1_First").runToList(): Unit + flow.tap(_ => myTracing.byNameSpan(())(s"$prefix-$Level1_First_Child")).spanned(myTracing)(s"$prefix-$Level1_First").runToList(): Unit parent.close() @@ -386,27 +408,607 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll w assert(spansOf(Level1_First_Child).size == 3) assertNesting() - "not mark the span as failed when a downstream stage fails" in new Fixture: - val spanned = flow.span(myTracing)(s"$prefix-$Level0") - - try - spanned.map { case 2 => eventually(assert(spansOf(Level0).nonEmpty)); throw new Exception("boom"); case n => n }.runToList(): Unit + "end span when a downstream stage fails" in new Fixture: + try flow.spanned(myTracing)(s"$prefix-$Level0").map { case 2 => throw new Exception("boom"); case n => n }.runToList(): Unit catch case _ => () assert(spansOf(Level0).size == 1) assert(testSpans.size == 1) - assert(spansOf(Level0).head.getStatus.getStatusCode == StatusCode.UNSET) - assert(spansOf(Level0).head.getEvents.isEmpty) + assert(spansOf(Level0).head.getStatus.getStatusCode == StatusCode.ERROR) assertNesting() "end span when downstream failure is recovered" in new Fixture: - flow.span(myTracing)(s"$prefix-$Level0").map { case 2 => throw new Exception("boom"); case x => x }.recover(_ => 5).runToList() + flow.spanned(myTracing)(s"$prefix-$Level0").map { case 2 => throw new Exception("boom"); case x => x }.recover(_ => 5).runToList() assert(spansOf(Level0).size == 1) assert(testSpans.size == 1) assertNesting() } + "spanEachDownstream" should { + "open a span per element, around the stages placed after it" in new Fixture: + val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") + + flow + .spanEachDownstream(myTracing)(s"$prefix-$Level1_First") + .map(x => myTracing.byNameSpan(x)(s"$prefix-$Level1_First_Child")) + .runToList(): Unit + + parent.close() + + assert(spansOf(Level1_First).size == 3) + assert(spansOf(Level1_First_Child).size == 3) + assertNesting() + + "not cover the stages placed before it" in new Fixture: + val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") + + flow.map(x => myTracing.byNameSpan(x)(s"$prefix-$Level1")).spanEachDownstream(myTracing)(s"$prefix-$Level1_First").runToList(): Unit + + parent.close() + + // spans created upstream are not children of the per-element spans + assert(spansOf(Level1).size == 3) + assert(spansOf(Level1_First).size == 3) + assertNesting() + + "open a span per element, with the stage and fan-out downstream spans as its children" in new Fixture: + val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") + + flow + .map(_ + 1) + .spanEachDownstream(myTracing)(s"$prefix-$Level1_First") + .map(x => myTracing.byNameSpan(x)(s"$prefix-$Level1_Stage")) + .flatMap(x => Flow.fromValues(1 to x*)) + .map(x => myTracing.byNameSpan(x)(s"$prefix-$Level1_Downstream")) + .runToList(): Unit + + parent.close() + + val elementSpans = spansOf(Level1_First) + assert(elementSpans.size == 3) + + // one stage span per element + assert(spansOf(Level1_Stage).size == 3) + // elements 1, 2, 3 are mapped to 2, 3, 4, and fanned out to that many outputs, each of which is passed to the last map + assert(spansOf(Level1_Downstream).size == 9) + + val childrenPerElement = (spansOf(Level1_Stage) ++ spansOf(Level1_Downstream)).groupBy(_.getParentSpanId) + assert(childrenPerElement.keySet == elementSpans.map(_.getSpanId).toSet) + assert(childrenPerElement.values.map(_.size).toList.sorted == List(3, 4, 5)) + } + + "used with async stages downstream" should { + "make a span after buffer a sibling of the per-element spans" in new Fixture: + val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") + + flow + .spanEachDownstream(myTracing)(s"$prefix-$Level1") + .buffer() + .map(x => myTracing.byNameSpan(x)(s"$prefix-$Level1_Stage")) + .runToList(): Unit + + parent.close() + + val level0Id = spansOf(Level0).head.getSpanId + assert(spansOf(Level1).size == 3) + assert(spansOf(Level1_Stage).size == 3) + // buffer's consumer doesn't run within the per-element spans, it keeps the context of where the flow is run + assert(spansOf(Level1_Stage).forall(_.getParentSpanId == level0Id)) + assert(spansOf(Level1).forall(_.getParentSpanId == level0Id)) + + "keep the stages between the span and buffer as children, and the ones after it as siblings" in new Fixture: + val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") + + flow + .spanEachDownstream(myTracing)(s"$prefix-$Level1") + .map(x => myTracing.byNameSpan(x)(s"$prefix-$Level1_Stage")) + .buffer() + .map(x => myTracing.byNameSpan(x)(s"$prefix-$Level1_Downstream")) + .runToList(): Unit + + parent.close() + + val level0Id = spansOf(Level0).head.getSpanId + val elementIds = spansOf(Level1).map(_.getSpanId).toSet + assert(spansOf(Level1_Stage).size == 3) + assert(spansOf(Level1_Downstream).size == 3) + assert(spansOf(Level1_Stage).forall(s => elementIds.contains(s.getParentSpanId))) + assert(spansOf(Level1_Downstream).forall(_.getParentSpanId == level0Id)) + + "make a span after buffer a sibling of the whole-run span" in new Fixture: + val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") + + flow + .spanned(myTracing)(s"$prefix-$Level1") + .buffer() + .map(x => myTracing.byNameSpan(x)(s"$prefix-$Level1_Stage")) + .runToList(): Unit + + parent.close() + + val level0Id = spansOf(Level0).head.getSpanId + assert(spansOf(Level1).size == 1) + assert(spansOf(Level1_Stage).size == 3) + assert(spansOf(Level1_Stage).forall(_.getParentSpanId == level0Id)) + + "make a span in mapPar a child of the per-element spans" in new Fixture: + val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") + + flow + .spanEachDownstream(myTracing)(s"$prefix-$Level1") + .mapPar(2)(x => myTracing.byNameSpan(x)(s"$prefix-$Level1_Stage")) + .runToList(): Unit + + parent.close() + + val elementIds = spansOf(Level1).map(_.getSpanId).toSet + assert(spansOf(Level1_Stage).size == 3) + // mapPar forks the function from within the emit, so the forks inherit the per-element span + assert(spansOf(Level1_Stage).forall(s => elementIds.contains(s.getParentSpanId))) + + "not end the span at a buffer placed inside a later flatMap" in new Fixture: + Flow + .fromValues(1, 2, 3) + .flatMap(x => Flow.fromValues(x).tap(_ => sleep(100.millis)).spanned(myTracing)(s"$prefix-$Level1")) + .tap(_ => sleep(100.millis)) + .flatMap(x => Flow.fromValues(x).buffer()) + .tap(_ => sleep(100.millis)) + .runToList(): Unit + + // the buffer is inside the flow returned by flatMap, its consumer is drained from within the upstream emit, + // so the span still covers both taps placed after it + assert(spansOf(Level1).size == 3) + spansOf(Level1).foreach(s => assert((s.getEndEpochNanos - s.getStartEpochNanos) / 1000000 >= 300)) + + "end the span at a buffer placed directly in the flow" in new Fixture: + Flow + .fromValues(1, 2, 3) + .flatMap(x => Flow.fromValues(x).tap(_ => sleep(100.millis)).spanned(myTracing)(s"$prefix-$Level1")) + .buffer() + .tap(_ => sleep(100.millis)) + .runToList(): Unit + + assert(spansOf(Level1).size == 3) + spansOf(Level1).foreach(s => assert((s.getEndEpochNanos - s.getStartEpochNanos) / 1000000 < 200)) + // one element, with sleeps far apart: a = first generator, b = second generator, c = stage placed after the whole comprehension + val a = 200.millis + val b = 600.millis + val c = 1200.millis + val (aMs, bMs, cMs) = (a.toMillis, b.toMillis, c.toMillis) + + "keep the outer span over a recoverWith placed inside the later generator" in new Fixture: + val filesToDownload = Seq(1) + def download(file: Int): Int = + sleep(a) + file + def readLines(file: Int): Flow[String] = Flow.fromValues(s"line of $file").tap(_ => sleep(b)) + def streamLinesFlow(file: Int) = readLines(file).spanned(myTracing)(s"$prefix-$Level1_First") + (for + file <- Flow.fromIterable(filesToDownload).map(download).spanned(myTracing)(s"$prefix-$Level1") + line <- streamLinesFlow(file) + yield line).tap(_ => sleep(c)).runToList(): Unit + + val outer = spansOf(Level1).head + val inner = spansOf(Level1_First).head + // recoverWith drains its channel from within the outer emit, so the outer span still covers the last sleep + assertAbout(durMs(outer), aMs + bMs + cMs) + // the inner span is inside the fork of recoverWith, so it ends at the hand-over + assertAbout(durMs(inner), bMs) + assert(inner.getParentSpanId == outer.getSpanId) + + "open a span per file with spanEachDownstream, keeping the following stages inside it" in new Fixture: + val filesToDownload = Seq(1, 2) + def download(file: Int): Int = + sleep(a) + file + def readLines(file: Int): Flow[String] = Flow.fromValues(s"line of $file").tap(_ => sleep(b)) + def streamLinesFlow(file: Int) = readLines(file).spanned(myTracing)(s"$prefix-$Level1_First") + (for + file <- Flow.fromIterable(filesToDownload).map(download).spanEachDownstream(myTracing)(s"$prefix-$Level1") + line <- streamLinesFlow(file).recoverWith(_ => streamLinesFlow(file)) + yield line).tap(_ => sleep(c)).runToList(): Unit + + val perFile = spansOf(Level1).sortBy(_.getStartEpochNanos) + val lines = spansOf(Level1_First) + assert(perFile.size == 2 && lines.size == 2) + // download is upstream of the span, and recoverWith ends only the lines span at the hand-over, so a file's span is b + c + perFile.foreach(s => assertAbout(durMs(s), bMs + cMs)) + lines.foreach(s => assertAbout(durMs(s), bMs)) + // each lines span belongs to a different file span + assert(lines.map(_.getParentSpanId).toSet == perFile.map(_.getSpanId).toSet) + // the second file is downloaded only after the first one is fully processed + assertAbout(startOffsetMs(perFile(0), perFile(1)), bMs + cMs + aMs) + + "open a span per file with mapSpanned, covering only the download" in new Fixture: + val filesToDownload = Seq(1, 2) + def download(file: Int): Int = + sleep(a) + file + def readLines(file: Int): Flow[String] = Flow.fromValues(s"line of $file").tap(_ => sleep(b)) + def streamLinesFlow(file: Int) = readLines(file).spanned(myTracing)(s"$prefix-$Level1_First") + (for + file <- Flow.fromIterable(filesToDownload).mapSpanned(myTracing)(s"$prefix-$Level1")(download) + line <- streamLinesFlow(file).recoverWith(_ => streamLinesFlow(file)) + yield line).tap(_ => sleep(c)).runToList(): Unit + + val downloads = spansOf(Level1).sortBy(_.getStartEpochNanos) + val lines = spansOf(Level1_First) + assert(downloads.size == 2 && lines.size == 2) + // the span ends once `download` returns, so neither the lines nor the following stages are counted + downloads.foreach(s => assertAbout(durMs(s), aMs)) + lines.foreach(s => assertAbout(durMs(s), bMs)) + // the span is closed before its result is emitted, so the lines spans are not its children + assert(lines.forall(l => !downloads.exists(_.getSpanId == l.getParentSpanId))) + // the second file is downloaded only after the first one is fully processed + assertAbout(startOffsetMs(downloads(0), downloads(1)), aMs + bMs + cMs) + + "count the nested generators and the following stages in a flat for comprehension with spanned" in new Fixture: + (for + x <- Flow.fromValues(1) + y <- Flow.fromValues(x).tap(_ => sleep(a)).spanned(myTracing)(s"$prefix-$Level1") + z <- Flow.fromValues(y).tap(_ => sleep(b)).spanned(myTracing)(s"$prefix-$Level1_First") + yield z).tap(_ => sleep(c)).runToList(): Unit + + val y = spansOf(Level1).head + val z = spansOf(Level1_First).head + assert(z.getParentSpanId == y.getSpanId) + assertAbout(durMs(y), aMs + bMs + cMs) + assertAbout(durMs(z), bMs + cMs) + assertAbout(startOffsetMs(y, z), aMs) + + "count only its own flow in a flat for comprehension with spannedAsync" in new Fixture: + (for + x <- Flow.fromValues(1,2,3) + y <- myTracing.spannedAsync(Flow.fromValues(x).tap(_ => sleep(a)))(s"$prefix-$Level1") + z <- myTracing.spannedAsync(Flow.fromValues(y).tap(_ => sleep(b)))(s"$prefix-$Level1_First") + yield z) + .tap(_ => sleep(c)).runToList(): Unit + + val y = spansOf(Level1).head + val z = spansOf(Level1_First).head + assert(z.getParentSpanId != y.getSpanId) + assertAbout(durMs(y), aMs) + assertAbout(durMs(z), bMs) + assertAbout(startOffsetMs(y, z), aMs) + + "end the first span at a buffer placed after it in a flat for comprehension with spanned" in new Fixture: + (for + x <- Flow.fromValues(1) + y <- Flow.fromValues(x).tap(_ => sleep(a)).spanned(myTracing)(s"$prefix-$Level1").buffer() + z <- Flow.fromValues(y).tap(_ => sleep(b)).spanned(myTracing)(s"$prefix-$Level1_First") + yield z).tap(_ => sleep(c)).runToList(): Unit + + val y = spansOf(Level1).head + val z = spansOf(Level1_First).head + assert(z.getParentSpanId != y.getSpanId) + assertAbout(durMs(y), aMs) + assertAbout(durMs(z), bMs + cMs) + assertAbout(startOffsetMs(y, z), aMs) + + "nest the durations of a spannedAsync in a flatMapSpanned" in new Fixture: + Flow + .fromValues(1) + .flatMapSpanned(myTracing)(s"$prefix-$Level1")(x => + for + y <- Flow.fromValues(x).tap(_ => sleep(a)) + z <- myTracing.spannedAsync(Flow.fromValues(y).tap(_ => sleep(b)))(s"$prefix-$Level1_First") + yield z + ) + .tap(_ => sleep(c)) + .runToList(): Unit + + val parent = spansOf(Level1).head + val child = spansOf(Level1_First).head + assert(child.getParentSpanId == parent.getSpanId) + assertAbout(durMs(parent), aMs + bMs) + assertAbout(durMs(child), bMs) + assertAbout(startOffsetMs(parent, child), aMs) + assert(child.getEndEpochNanos <= parent.getEndEpochNanos) + + "not end the span at alsoTo, as it sends to the sink synchronously" in new Fixture: + supervised: + val sink = Channel.rendezvous[Int] + fork: + for _ <- 1 to 3 do + sleep(200.millis) + sink.receive(): Unit + Flow + .fromValues(1, 2, 3) + .flatMap(x => Flow.fromValues(x).spanned(myTracing)(s"$prefix-$Level1")) + .alsoTo(sink) + .runToList(): Unit + + // no sleeps in the flow, yet each span lasts as long as the send to the slow sink blocks + assert(spansOf(Level1).size == 3) + spansOf(Level1).foreach(s => assert((s.getEndEpochNanos - s.getStartEpochNanos) / 1000000 >= 150)) + } + + "spannedIsolated" should { + def durationMs(span: io.opentelemetry.sdk.trace.data.SpanData) = (span.getEndEpochNanos - span.getStartEpochNanos) / 1000000 + + "make the spans created in upstream stages children, and in downstream stages siblings" in new Fixture: + val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") + + myTracing + .spannedIsolated(flow.tap(x => myTracing.byNameSpan(x)(s"$prefix-$Level1_First")))(s"$prefix-$Level1") + .tap(x => myTracing.byNameSpan(x)(s"$prefix-$Level1_Stage")) + .runToList(): Unit + + parent.close() + + val level0Id = spansOf(Level0).head.getSpanId + val level1Id = spansOf(Level1).head.getSpanId + assert(spansOf(Level1).size == 1) + assert(spansOf(Level1).head.getParentSpanId == level0Id) + assert(spansOf(Level1_First).size == 3 && spansOf(Level1_First).forall(_.getParentSpanId == level1Id)) + assert(spansOf(Level1_Stage).size == 3 && spansOf(Level1_Stage).forall(_.getParentSpanId == level0Id)) + + "keep the spans created in stages before and after a buffer placed before it as children" in new Fixture: + val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") + + myTracing + .spannedIsolated( + flow + .tap(x => myTracing.byNameSpan(x)(s"$prefix-$Level1_First")) + .buffer() + .tap(x => myTracing.byNameSpan(x)(s"$prefix-$Level1_Stage")) + )(s"$prefix-$Level1") + .runToList(): Unit + + parent.close() + + val level1Id = spansOf(Level1).head.getSpanId + // both the producer (before the buffer) and the consumer (after it) run within the span + assert(spansOf(Level1_First).size == 3 && spansOf(Level1_First).forall(_.getParentSpanId == level1Id)) + assert(spansOf(Level1_Stage).size == 3 && spansOf(Level1_Stage).forall(_.getParentSpanId == level1Id)) + + "make the spans created in stages after a buffer placed after it siblings" in new Fixture: + val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") + + myTracing + .spannedIsolated(flow.tap(x => myTracing.byNameSpan(x)(s"$prefix-$Level1_First")))(s"$prefix-$Level1") + .buffer() + .tap(x => myTracing.byNameSpan(x)(s"$prefix-$Level1_Stage")) + .runToList(): Unit + + parent.close() + + val level0Id = spansOf(Level0).head.getSpanId + val level1Id = spansOf(Level1).head.getSpanId + assert(spansOf(Level1_First).forall(_.getParentSpanId == level1Id)) + assert(spansOf(Level1_Stage).size == 3 && spansOf(Level1_Stage).forall(_.getParentSpanId == level0Id)) + + "count the time of synchronous downstream stages" in new Fixture: + val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") + + myTracing.spannedIsolated(flow)(s"$prefix-$Level1").tap(_ => sleep(200.millis)).runToList(): Unit + + parent.close() + + assert(durationMs(spansOf(Level1).head) >= 500) + + "not count the time of downstream stages after a buffer" in new Fixture: + val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") + + myTracing.spannedIsolated(flow)(s"$prefix-$Level1").buffer().tap(_ => sleep(200.millis)).runToList(): Unit + + parent.close() + + // upstream is done quickly, while the downstream takes 3 * 200ms + assert(durationMs(spansOf(Level1).head) < 300) + + "not mark the span as failed when a downstream stage fails" in new Fixture: + val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") + + try myTracing.spannedIsolated(flow)(s"$prefix-$Level1").map { case 2 => throw new Exception("boom"); case x => x }.runToList(): Unit + catch case _ => () + + parent.close() + + assert(spansOf(Level1).size == 1) + assert(spansOf(Level1).head.getStatus.getStatusCode == StatusCode.UNSET) + assert(spansOf(Level1).head.getEvents.isEmpty) + + "mark the span as failed when an upstream stage fails" in new Fixture: + val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") + + try myTracing.spannedIsolated(flow.map { case 2 => throw new Exception("boom"); case x => x })(s"$prefix-$Level1").runToList(): Unit + catch case _ => () + + parent.close() + + assert(spansOf(Level1).head.getStatus.getStatusCode == StatusCode.ERROR) + } + + "mapSpanned" should { + "open a span around each call of the mapping function only" in new Fixture: + val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") + + flow + .mapSpanned(myTracing)(s"$prefix-$Level1_First")(x => myTracing.byNameSpan(x + 1)(s"$prefix-$Level1_First_Child")) + .map(x => myTracing.byNameSpan(x)(s"$prefix-$Level1")) + .runToList(): Unit + + parent.close() + + assert(spansOf(Level1_First).size == 3) + // created by the function, so they are children of the per-element spans + assert(spansOf(Level1_First_Child).size == 3) + // created by the following stage, after the per-element spans were closed, so they are siblings + assert(spansOf(Level1).size == 3) + assertNesting() + + "mark the span as failed when the mapping function fails" in new Fixture: + try flow.mapSpanned(myTracing)(s"$prefix-$Level0") { case 2 => throw new Exception("boom"); case x => x }.runToList(): Unit + catch case _ => () + + assert(spansOf(Level0).size == 2) + assert(spansOf(Level0).count(_.getStatus.getStatusCode == StatusCode.ERROR) == 1) + } + + "flatMapSpanned" should { + def durationMs(span: io.opentelemetry.sdk.trace.data.SpanData) = (span.getEndEpochNanos - span.getStartEpochNanos) / 1000000 + + "open a span per element, with the spans created in the inner flows as children and the following ones as siblings" in new Fixture: + val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") + + flow + .flatMapSpanned(myTracing)(s"$prefix-$Level1_First")(x => + Flow.fromValues(1 to x*).map(y => myTracing.byNameSpan(y)(s"$prefix-$Level1_First_Child")) + ) + .map(x => myTracing.byNameSpan(x)(s"$prefix-$Level1")) + .runToList(): Unit + + parent.close() + + // elements 1, 2, 3 are flatMapped to 1, 2, 3 outputs + assert(spansOf(Level1_First).size == 3) + assert(spansOf(Level1_First_Child).size == 6) + assert(spansOf(Level1).size == 6) + assertNesting() + + "emit the elements of the inner flows in order" in new Fixture: + val result = flow.flatMapSpanned(myTracing)(s"$prefix-$Level0")(x => Flow.fromValues(x, x * 10)).runToList() + + assert(result == List(1, 10, 2, 20, 3, 30)) + assert(spansOf(Level0).size == 3) + + "not count the time of the following stages" in new Fixture: + val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") + + flow.flatMapSpanned(myTracing)(s"$prefix-$Level1")(x => Flow.fromValues(x)).tap(_ => sleep(200.millis)).runToList(): Unit + + parent.close() + + // each inner flow is done quickly, while the following stage takes 200ms per element + assert(spansOf(Level1).size == 3) + assert(spansOf(Level1).forall(durationMs(_) < 150)) + + "not mark the spans as failed when a following stage fails" in new Fixture: + try flow.flatMapSpanned(myTracing)(s"$prefix-$Level0")(x => Flow.fromValues(x)).map { case 2 => throw new Exception("boom"); case x => x }.runToList(): Unit + catch case _ => () + + assert(spansOf(Level0).nonEmpty) + assert(spansOf(Level0).forall(_.getStatus.getStatusCode == StatusCode.UNSET)) + assert(spansOf(Level0).forall(_.getEvents.isEmpty)) + + "mark only the span of the inner flow that fails" in new Fixture: + try + flow + .flatMapSpanned(myTracing)(s"$prefix-$Level0")(x => Flow.fromValues(x).map { case 2 => throw new Exception("boom"); case y => y }) + .runToList(): Unit + catch case _ => () + + // the flow is stopped by the failure of the second inner flow, so there is no third one + assert(spansOf(Level0).size == 2) + assert(spansOf(Level0).count(_.getStatus.getStatusCode == StatusCode.ERROR) == 1) + + "work with infinite inner flows" in new Fixture: + val result = flow.flatMapSpanned(myTracing)(s"$prefix-$Level0")(_ => Flow.iterate(1)(_ + 1)).take(5).runToList() + + assert(result == List(1, 2, 3, 4, 5)) + assert(spansOf(Level0).size == 1) + // the inner flow is cancelled once the downstream is done, which isn't a failure + assert(spansOf(Level0).head.getStatus.getStatusCode == StatusCode.UNSET) + + "run the inner flows one after another" in new Fixture: + val events = new java.util.concurrent.ConcurrentLinkedQueue[String]() + + flow + .flatMapSpanned(myTracing)(s"$prefix-$Level0")(x => + Flow.usingEmit[Int] { emit => + events.add(s"start $x") + emit(x) + events.add(s"end $x") + } + ) + .tap(x => events.add(s"got $x")) + .runToList(): Unit + + val log = scala.jdk.CollectionConverters.CollectionHasAsScala(events).asScala.toList + // the inner flow of an element starts only after the previous one is done, and its elements are consumed + List(1, 2).foreach { x => + assert(log.indexOf(s"end $x") < log.indexOf(s"start ${x + 1}")) + assert(log.indexOf(s"got $x") < log.indexOf(s"start ${x + 1}")) + } + } + + "spanEachStages" should { + "open a span per element over the stages, with the spans of the stages as children and the other ones as siblings" in new Fixture: + val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") + + flow + .tap(x => myTracing.byNameSpan(x)(s"$prefix-$Level1_First")) + .spanEachStages(myTracing)(s"$prefix-$Level1")( + _.map(x => myTracing.byNameSpan(x)(s"$prefix-$Level1_Stage")).flatMap(x => Flow.fromValues(x, x * 10)) + ) + .map(x => myTracing.byNameSpan(x)(s"$prefix-$Level1_Downstream")) + .runToList(): Unit + + parent.close() + + val level0Id = spansOf(Level0).head.getSpanId + val elementIds = spansOf(Level1).map(_.getSpanId).toSet + assert(spansOf(Level1).size == 3) + assert(spansOf(Level1).forall(_.getParentSpanId == level0Id)) + // created before the stages, so outside of the element spans + assert(spansOf(Level1_First).size == 3 && spansOf(Level1_First).forall(_.getParentSpanId == level0Id)) + // one per element, in the stages + assert(spansOf(Level1_Stage).size == 3 && spansOf(Level1_Stage).forall(s => elementIds.contains(s.getParentSpanId))) + // each element is extrapolated to 2 elements, which go through the stage after + assert(spansOf(Level1_Downstream).size == 6 && spansOf(Level1_Downstream).forall(_.getParentSpanId == level0Id)) + + "work with stages that conflate the elements" in new Fixture: + val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") + + val result = Flow.fromValues(1, 2, 3, 4).spanEachStages(myTracing)(s"$prefix-$Level1")(_.grouped(2)).runToList() + + parent.close() + + assert(result == List(List(1, 2), List(3, 4))) + assert(spansOf(Level1).size == 4) + assert(spansOf(Level1).forall(_.getParentSpanId == spansOf(Level0).head.getSpanId)) + + "work with infinite flows" in new Fixture: + val result = Flow.iterate(1)(_ + 1).spanEachStages(myTracing)(s"$prefix-$Level0")(_.map(_ * 2)).take(3).runToList() + + assert(result == List(2, 4, 6)) + // the spans of the elements that were being processed when the flow was stopped are not marked as failed + assert(spansOf(Level0).size >= 3) + assert(spansOf(Level0).forall(_.getStatus.getStatusCode == StatusCode.UNSET)) + } + + "nested whole-run spans over a concat" should { + def durationMs(span: io.opentelemetry.sdk.trace.data.SpanData) = (span.getEndEpochNanos - span.getStartEpochNanos) / 1000000 + + "cover the downstream stages and the flows run before them" in new Fixture: + // 3 elements, each taking 100ms to produce + val slowFlow = Flow.fromValues(1, 2, 3).tap(_ => sleep(100.millis)) + + val start = System.nanoTime() + slowFlow + .spanned(myTracing)(s"$prefix-$Level1") + .concat(slowFlow) + .spanned(myTracing)(s"$prefix-$Level0") + .tap(_ => sleep(100.millis)) + .runToList(): Unit + val totalMs = (System.nanoTime() - start) / 1000000 + + val inner = spansOf(Level1).head + val outer = spansOf(Level0).head + + // 6 elements, each taking 100ms to produce and 100ms to be processed by the last stage + assert(totalMs >= 1150 && totalMs < 1800) + // the outer span covers the whole concat + assert(durationMs(outer) >= 1150 && durationMs(outer) < 1800) + // the inner span covers the first flow and the last stage's processing of its 3 elements + assert(durationMs(inner) >= 550 && durationMs(inner) < 900) + // it was opened inside the outer span + assert(inner.getParentSpanId == outer.getSpanId) + assert(inner.getStartEpochNanos >= outer.getStartEpochNanos && inner.getEndEpochNanos <= outer.getEndEpochNanos) + } + "cancelled" should { "end span when enclosing supervised scope fails before stream completion" in new Fixture: val semaphore = Semaphore(0) @@ -418,7 +1020,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll w throw new Exception("oops") } forkUser { - flow.span(myTracing)(s"$prefix-$Level0").tap { case 1 => semaphore.release(); case _ => sleep(100.seconds) }.runToList() + flow.spanned(myTracing)(s"$prefix-$Level0").tap { case 1 => semaphore.release(); case _ => sleep(100.seconds) }.runToList() } } catch case err => () @@ -433,7 +1035,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll w supervised { val forkWithSpan = forkCancellable { - flow.span(myTracing)(s"$prefix-$Level0").tap { case 1 => semaphore.release(); case _ => sleep(100.seconds) }.runToList() + flow.spanned(myTracing)(s"$prefix-$Level0").tap { case 1 => semaphore.release(); case _ => sleep(100.seconds) }.runToList() } semaphore.acquire() forkWithSpan.cancelNow() @@ -441,7 +1043,19 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll w assert(spansOf(Level0).size == 1) assert(testSpans.size == 1) + assert(spansOf(Level0).head.getStatus.getStatusCode == StatusCode.UNSET) + assert(spansOf(Level0).head.getEvents.isEmpty) assertNesting() + + "not mark the span as failed when the code exits with a boundary break" in new Fixture: + val result = scala.util.boundary: + myTracing.byNameSpan(scala.util.boundary.break(1))(s"$prefix-$Level0") + 2 + + assert(result == 1) + assert(spansOf(Level0).size == 1) + assert(spansOf(Level0).head.getStatus.getStatusCode == StatusCode.UNSET) + assert(spansOf(Level0).head.getEvents.isEmpty) } } end OxTelemetryTest From 669cde5cfbf1c55686519558f4e2882c9435681b Mon Sep 17 00:00:00 2001 From: Pawel Sadlo Date: Tue, 20 Oct 2026 18:47:30 +0200 Subject: [PATCH 34/38] Flow tracing: keep only boundary-delimited spans Remove spanned, spannedIsolated, spanEachDownstream and spanEachStages, whose extent depended on the stages placed after them. Rename spannedAsync to span, run the function of flatMapSpanned within its span, make byNameSpan internal, and stop passing non-failures to beforeClose. Co-authored-by: Claude-Opus-4.8 via Claude-Code --- doc/integrations/otel-tracing.md | 66 +- .../scala/ox/flow/tracing/OxFlowTracing.scala | 154 +--- .../scala/ox/flow/tracing/flowTracing.scala | 31 +- .../ox/telemetry/tracing/OxTracing.scala | 48 +- .../ox/telemetry/tracing/SpanWithError.scala | 8 +- .../scala/ox/telemetry/tracing/tracing.scala | 13 +- otel/src/test/scala/OxTelemetryTest.scala | 775 ++++++------------ 7 files changed, 351 insertions(+), 744 deletions(-) diff --git a/doc/integrations/otel-tracing.md b/doc/integrations/otel-tracing.md index 89b54b1b..ea27412e 100644 --- a/doc/integrations/otel-tracing.md +++ b/doc/integrations/otel-tracing.md @@ -43,19 +43,18 @@ object MyApp extends OxApp: def run(args: Vector[String])(using Ox): ExitCode = ExitCode.Success ``` -## Spans around code and flows +## Spans around flows -`byNameSpan` evaluates a code block within a span. The span is ended once the block completes, and if the block throws, -the exception is recorded on the span, and its status is set to `ERROR`. An interruption and control-flow exits -(`boundary.break`) end the span normally, as they aren't failures. `Flow.spanned` -does the same for a [`Flow`](../streaming/flows.md): the span is opened when the flow is run, and ended when the flow -completes, fails, or is interrupted. Running the flow multiple times opens a new span each time. +`Flow.span` evaluates a [`Flow`](../streaming/flows.md) within a span: the span is opened when the flow is run, and ended +when the flow completes, fails, or is interrupted. If the flow fails, the exception is recorded on the span, and its +status is set to `ERROR`. An interruption and control-flow exits (`boundary.break`) end the span normally, as they +aren't failures. Running the flow multiple times opens a new span each time. -The span covers the whole execution of the flow: from when it is run until it completes. This includes the stages placed -after `spanned` that run synchronously (e.g. `map`, `flatMap`): they are called from within the span, so their duration -is counted, and spans created there are its children. Stages after an asynchronous boundary, e.g. -`.spanned(...).buffer().map(...)`, run concurrently, so they don't extend the span, which ends once its upstream -completes. Put `spanned` last to cover the whole pipeline. +The flow and the span run in a fork, and the elements are passed to the stages placed after `span` through a buffer, of +the size given by the `BufferCapacity` in scope. Because of that boundary, the span doesn't depend on which stages +follow it: it covers the flow and everything upstream of it, until the last element is passed to the buffer. The stages +placed after it are not counted, spans created there are not its children, and their failures are not recorded on it. +To include more stages in the span, put them inside the spanned flow. In the example below, the `process-numbers` span is created in a fork, and thanks to the context being propagated, it's a child of the `request` span: @@ -66,16 +65,19 @@ import ox.flow.Flow import ox.telemetry.tracing.* def example(tracing: OxTracing): Unit = - val result = supervised { - fork(Flow.fromValues(1, 2, 3).spanned(tracing)("process-numbers").runToList()).join() - }.byNameSpan(tracing)("request") + val result = resourceScope { + useCloseableInScope(spanUnsafe(tracing)("request")): Unit + supervised { + fork(Flow.fromValues(1, 2, 3).span(tracing)("process-numbers").runToList()).join() + } + } println(result) ``` -Both accept optional `spanKind`, `attributes` and `links`, as well as a `beforeClose` callback, called right before the -span is closed, e.g. to set attributes or add events. The callback receives the span and the exception thrown by the -spanned code, if any, and has to return the span. If it throws, the exception is added as suppressed to the one thrown -by the spanned code; the span is closed in either case: +Optional parameters are `spanKind`, `attributes` and `links`, as well as a `beforeClose` callback, called right before +the span is closed, e.g. to set attributes or add events. The callback receives the span and the exception thrown by the +spanned flow, if any, and has to return the span. If it throws, the exception is added as suppressed to the one thrown +by the spanned flow; the span is closed in either case: ```scala mdoc:compile-only import ox.flow.Flow @@ -84,39 +86,35 @@ import ox.telemetry.tracing.* def withAttributes(tracing: OxTracing): List[Int] = Flow .fromValues(1, 2, 3) - .spanned(tracing)("process-numbers", beforeClose = s => s.span.setAttribute("failed", s.errorOpt.isDefined)) + .span(tracing)("process-numbers", beforeClose = s => s.span.setAttribute("failed", s.errorOpt.isDefined)) .runToList() ``` ## Spans per element -`Flow.spanned` opens a single span for the whole run. To have a span per element, open it in the stage that processes the -element. For a single stage, use `byNameSpan` inside it; it's a child of the current span, so it nests under a -`Flow.spanned` placed earlier in the flow. To span several stages per element, run them in an inner flow, and span that -flow. `flatMap` processes the elements one after another, while `mapPar` runs the inner flows in parallel: +The following stages open a span per element, each covering only what is passed to it, so the span doesn't depend on the +stages placed after it: + + - `mapSpanned` is `map`, where each call of the function is run within a span, + - `flatMapSpanned` is `flatMap`, where each flow returned by the function is run as with `span`. + +The flow returned by the function is run for each element separately, so stateful stages, e.g. `grouped`, don't carry +their state across elements. ```scala mdoc:compile-only import ox.flow.Flow import ox.telemetry.tracing.* def perElement(tracing: OxTracing, flow: Flow[Int]): Unit = - // one stage - flow.map(x => tracing.byNameSpan(x + 1)("increment")).runDrain() + // one function + flow.mapSpanned(tracing)("increment")(_ + 1).runDrain() // several stages, sequentially flow - .flatMap(x => Flow.fromValues(x).map(_ + 1).filter(_ % 2 == 0).spanned(tracing)("element")) - .runDrain() - - // several stages, in parallel - flow - .mapPar(4)(x => Flow.fromValues(x).map(_ + 1).filter(_ % 2 == 0).spanned(tracing)("element").runToList()) + .flatMapSpanned(tracing)("element")(x => Flow.fromValues(x).map(_ + 1).filter(_ % 2 == 0)) .runDrain() ``` -The inner flow can use any stages, e.g. `grouped`: the span is opened when the inner flow is run, and closed after its -last element is processed, or when it fails. - ## Manual spans `spanUnsafe` opens a span, which has to be closed by the caller. Closing the returned `AutoCloseableSpan` ends the span diff --git a/otel/src/main/scala/ox/flow/tracing/OxFlowTracing.scala b/otel/src/main/scala/ox/flow/tracing/OxFlowTracing.scala index ce80b800..b0f5b5c2 100644 --- a/otel/src/main/scala/ox/flow/tracing/OxFlowTracing.scala +++ b/otel/src/main/scala/ox/flow/tracing/OxFlowTracing.scala @@ -2,8 +2,6 @@ package ox.flow.tracing import io.opentelemetry.api.common.Attributes import io.opentelemetry.api.trace.{Span, SpanContext, SpanKind} -import io.opentelemetry.context.Context -import ox.* import ox.channels.BufferCapacity import ox.flow.{Flow, FlowEmit} import ox.telemetry.tracing.{OxTracing, SpanWithError} @@ -11,119 +9,26 @@ import ox.telemetry.tracing.{OxTracing, SpanWithError} private[ox] trait OxFlowTracing: self: OxTracing => - /** Returns a flow, which when run, evaluates `flow` within a new span. The span is opened when the flow is run, as a child of the span - * current at that point. It is ended when the flow completes, fails or is interrupted. Exceptions are recorded on the span as in - * [[OxTracing.byNameSpan]]. - * - * The span covers the whole execution of the flow: from when it is run until it completes. This includes the stages placed after - * `spanned` that run synchronously (e.g. `map`, `flatMap`), as they are called from within the span: their duration is counted, spans - * created there are children of this span, and if one of them fails, so does the span. Stages after an asynchronous boundary (e.g. - * `buffer`) run concurrently, so they don't extend the span, which ends once its upstream completes. - * - * The parameters are the same as for [[OxTracing.byNameSpan]]. - * - * * NOT OK Span time depends of donstream stages - * * NOT OK Spanned stages dont follow code structure - */ - def spanned[T](flow: Flow[T])( - spanName: String, - spanKind: SpanKind = SpanKind.INTERNAL, - attributes: Attributes = Attributes.empty(), - links: Seq[SpanContext] = Seq.empty, - beforeClose: SpanWithError => Span = _._1 - ): Flow[T] = Flow.usingEmitInline: emit => - self.byNameSpan(flow.runToEmit(emit))(spanName, spanKind, attributes, links, beforeClose) - - /** As [[spanned]], but the stages placed after it are isolated from the span: - * - they are run with the context that was current before the span was opened, so spans created there are not children of this - * span, but siblings of it, - * - if one of them fails, the exception is not recorded on the span and doesn't set its status; it is still thrown through the span. - * - * Their duration is still counted in the span, if they run synchronously. - * - * The parameters are the same as for [[OxTracing.byNameSpan]]. - * - * NOT OK Span time depends of donstream stages - * NOT OK Spanned stages dont follow code structure + /** Returns a flow, which when run, evaluates `flow` within a new span, which is made a child of current span Span is ended when the flow + * completes, fails or is interrupted. errors are recorded in span as events and via recordExceptions creates async boundary to isolate + * downstream stages processing time from created span + * @param beforeClose + * callback that will be executed right before closing a span */ - def spannedIsolated[T](flow: Flow[T])( - spanName: String, - spanKind: SpanKind = SpanKind.INTERNAL, - attributes: Attributes = Attributes.empty(), - links: Seq[SpanContext] = Seq.empty, - beforeClose: SpanWithError => Span = _._1 - ): Flow[T] = Flow.usingEmitInline: emit => - val parent = Context.current() - var downstreamError: Throwable | Null = null - self.byNameSpanIgnoring( - flow.runToEmit( - FlowEmit.fromInline: t => - val scope = parent.makeCurrent() - try emit(t) - catch - case e: Throwable => - downstreamError = e - throw e - finally scope.close() - ) - )(_ eq downstreamError)(spanName, spanKind, attributes, links, beforeClose) - - /** As [[spanned]], but the flow is run asynchronously: `flow` and the span run in a fork, and the elements are passed to the stages - * placed after it through a buffer, of the size given by the [[BufferCapacity]] in scope. - * - * Because of that boundary, the span doesn't depend on which stages follow it: - * - it covers `flow` and everything upstream of it, from when the flow is run until its last element is passed to the buffer, or it - * fails; the duration of the stages placed after it is not counted (except through backpressure, once the buffer is full), - * - the stages placed after it run with the context of the caller, so spans created there are not children of this span, - * - exceptions thrown by them are not recorded on the span. - * - * The price is a change of how the flow is run: `flow` runs in a fork, up to the buffer size ahead of the downstream stages, and - * `beforeClose` is called on the fork's thread. - * - * The parameters are the same as for [[OxTracing.byNameSpan]]. - * - * OK - spanned stages follows code structure - * OK - span time is isolated from downstream stages - */ - def spannedAsync[T](flow: Flow[T])( + def span[T](flow: Flow[T])( spanName: String, spanKind: SpanKind = SpanKind.INTERNAL, attributes: Attributes = Attributes.empty(), links: Seq[SpanContext] = Seq.empty, beforeClose: SpanWithError => Span = _._1 )(using BufferCapacity): Flow[T] = - spanned(flow)(spanName, spanKind, attributes, links, beforeClose).buffer() - - /** Applies `stages` once to a flow of the elements of `flow`, and opens a new span for each element, from when it enters the `stages` - * until the `stages` are done processing it. The stages can be stateful, and can conflate or extrapolate the elements, as they are not - * restarted for each element. The span of an element ends when its processing returns, so: - * - if the `stages` emit several elements for an element (e.g. `flatMap`), the span covers all of them, - * - if the `stages` hold an element back (e.g. `grouped`), the span covers only taking it in, and the element which completes a group - * is also the one whose span covers emitting the group. - * - * Spans created by the `stages` are children of the span of the element being processed. `flow` is run separately of the spans, so - * the time it takes to produce the elements is not counted, and spans created by `flow` are not their children. The result of the - * `stages` is passed to the stages placed after `spanEachStages` through a buffer, of the size given by the [[BufferCapacity]] in - * scope, so they are not counted either, and their spans are not children of the span. The flow can be infinite. - * - * If the `stages` contain an asynchronous stage (e.g. `buffer`, `mapPar`), the span of an element ends once it is passed to it. - * - * The parameters are the same as for [[OxTracing.byNameSpan]]. - */ - def spanEachStages[T, U](flow: Flow[T])( - spanName: String, - spanKind: SpanKind = SpanKind.INTERNAL, - attributes: Attributes = Attributes.empty(), - links: Seq[SpanContext] = Seq.empty, - beforeClose: SpanWithError => Span = _._1 - )(stagesToSpan: Flow[T] => Flow[U])(using BufferCapacity): Flow[U] = - stagesToSpan(spanEachDownstream(flow)(spanName, spanKind, attributes, links, beforeClose)).buffer() + val spanned = Flow.usingEmitInline[T]: emit => + self.byNameSpan(flow.runToEmit(emit))(spanName, spanKind, attributes, links, beforeClose) + spanned.buffer() + end span - /** As [[ox.flow.Flow.map]], but each call of `f` is run within a new span, which is ended once `f` completes. Only `f` is covered: - * the result is emitted to the stages placed after it when the span is already closed, so they are neither counted nor children of - * the span. Spans created by `f` are children of the span, and if `f` throws, the exception is recorded on it. - * - * The parameters are the same as for [[OxTracing.byNameSpan]]. + /** As [[ox.flow.Flow.map]] but spans around execution of mapping function Span is made child of span which is current when executing a + * flow */ def mapSpanned[T, U](flow: Flow[T])( spanName: String, @@ -134,14 +39,8 @@ private[ox] trait OxFlowTracing: )(f: T => U): Flow[U] = flow.map(t => self.byNameSpan(f(t))(spanName, spanKind, attributes, links, beforeClose)) - /** As [[ox.flow.Flow.flatMap]], but the flow returned by each call of `f` is run within a new span, as with [[spannedAsync]]: each such - * flow is run in a fork, and its elements are passed to the stages placed after `flatMapSpanned` through a buffer, of the size given - * by the [[BufferCapacity]] in scope. Because of that boundary, the span doesn't depend on the stages that follow: it covers the flow - * returned by `f`, from when it is run until its last element is passed to the buffer, spans created by the flow are children of the - * span, and the stages placed after are not counted, are not children of it, and their failures are not recorded on it. The outputs - * are emitted lazily, so the flow can be large or infinite. - * - * The parameters are the same as for [[OxTracing.byNameSpan]]. + /** As [[ox.flow.Flow.flatMap]], but spans around execution of each flow returned by f spans opened within execution of f and of flow + * returned by f are children of created span, f is called within the span, so if it throws, the exception is recorded on it */ def flatMapSpanned[T, U](flow: Flow[T])( spanName: String, @@ -150,29 +49,6 @@ private[ox] trait OxFlowTracing: links: Seq[SpanContext] = Seq.empty, beforeClose: SpanWithError => Span = _._1 )(f: T => Flow[U])(using BufferCapacity): Flow[U] = - flow.flatMap(t => spannedAsync(f(t))(spanName, spanKind, attributes, links, beforeClose)) - - /** Returns a flow, which for each element of `flow` opens a new span, emits the element to the stages placed after it, and ends the span - * once they are done with it. The span is an entry point for the downstream stages: it covers them, but not the stages upstream of it. - * - * The stages that run synchronously (e.g. `map`, `flatMap`) are called from within the span, so their duration is counted, spans - * created there are children of it, and if one of them fails, so does the span. If a downstream stage is asynchronous (e.g. `buffer`), - * the span ends once the element is handed over to it. Each span is opened as a child of the span current when the element is emitted. - * - * The parameters are the same as for [[OxTracing.byNameSpan]]. - * - * not ok - not really each downstream, it spans till first async boundary - */ - def spanEachDownstream[T](flow: Flow[T])( - spanName: String, - spanKind: SpanKind = SpanKind.INTERNAL, - attributes: Attributes = Attributes.empty(), - links: Seq[SpanContext] = Seq.empty, - beforeClose: SpanWithError => Span = _._1 - ): Flow[T] = Flow.usingEmitInline: emit => - flow.runToEmit( - FlowEmit.fromInline: t => - self.byNameSpan(emit(t))(spanName, spanKind, attributes, links, beforeClose) - ) + flow.flatMap(t => span(Flow.usingEmitInline[U](emit => f(t).runToEmit(emit)))(spanName, spanKind, attributes, links, beforeClose)) end OxFlowTracing diff --git a/otel/src/main/scala/ox/flow/tracing/flowTracing.scala b/otel/src/main/scala/ox/flow/tracing/flowTracing.scala index 93296955..c5830adb 100644 --- a/otel/src/main/scala/ox/flow/tracing/flowTracing.scala +++ b/otel/src/main/scala/ox/flow/tracing/flowTracing.scala @@ -7,27 +7,15 @@ import ox.flow.Flow import ox.telemetry.tracing.SpanWithError extension [T](flow: Flow[T]) - /** Opens a span around the run of this flow, using the given [[ox.telemetry.tracing.OxTracing]], see - * [[ox.flow.tracing.OxFlowTracing.spanned]]. + /** Opens a span around the run of this flow, see [[ox.flow.tracing.OxFlowTracing.span]]. */ - def spanned(oxTracing: OxFlowTracing)( + def span(oxTracing: OxFlowTracing)( spanName: String, spanKind: SpanKind = SpanKind.INTERNAL, attributes: Attributes = Attributes.empty(), links: Seq[SpanContext] = Seq.empty, beforeClose: SpanWithError => Span = _.span - ): Flow[T] = oxTracing.spanned(flow)(spanName, spanKind, attributes, links, beforeClose) - - /** Opens a span for each element, around the stages placed after it, using the given [[ox.telemetry.tracing.OxTracing]], see - * [[ox.flow.tracing.OxFlowTracing.spanEachDownstream]]. - */ - def spanEachDownstream(oxTracing: OxFlowTracing)( - spanName: String, - spanKind: SpanKind = SpanKind.INTERNAL, - attributes: Attributes = Attributes.empty(), - links: Seq[SpanContext] = Seq.empty, - beforeClose: SpanWithError => Span = _.span - ): Flow[T] = oxTracing.spanEachDownstream(flow)(spanName, spanKind, attributes, links, beforeClose) + )(using BufferCapacity): Flow[T] = oxTracing.span(flow)(spanName, spanKind, attributes, links, beforeClose) /** As [[ox.flow.Flow.map]], with each call of `f` within a span, see [[ox.flow.tracing.OxFlowTracing.mapSpanned]]. */ def mapSpanned[U](oxTracing: OxFlowTracing)( @@ -38,7 +26,8 @@ extension [T](flow: Flow[T]) beforeClose: SpanWithError => Span = _.span )(f: T => U): Flow[U] = oxTracing.mapSpanned(flow)(spanName, spanKind, attributes, links, beforeClose)(f) - /** As [[ox.flow.Flow.flatMap]], with each flow returned by `f` run within a span, see [[ox.flow.tracing.OxFlowTracing.flatMapSpanned]]. */ + /** As [[ox.flow.Flow.flatMap]], with each flow returned by `f` run within a span, see [[ox.flow.tracing.OxFlowTracing.flatMapSpanned]]. + */ def flatMapSpanned[U](oxTracing: OxFlowTracing)( spanName: String, spanKind: SpanKind = SpanKind.INTERNAL, @@ -47,14 +36,4 @@ extension [T](flow: Flow[T]) beforeClose: SpanWithError => Span = _.span )(f: T => Flow[U])(using BufferCapacity): Flow[U] = oxTracing.flatMapSpanned(flow)(spanName, spanKind, attributes, links, beforeClose)(f) - - /** Opens a span for each element, over the given `stages`, see [[ox.flow.tracing.OxFlowTracing.spanEachStages]]. */ - def spanEachStages[U](oxTracing: OxFlowTracing)( - spanName: String, - spanKind: SpanKind = SpanKind.INTERNAL, - attributes: Attributes = Attributes.empty(), - links: Seq[SpanContext] = Seq.empty, - beforeClose: SpanWithError => Span = _.span - )(stages: Flow[T] => Flow[U])(using BufferCapacity): Flow[U] = - oxTracing.spanEachStages(flow)(spanName, spanKind, attributes, links, beforeClose)(stages) end extension diff --git a/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala b/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala index ce739a98..35aa35a0 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala @@ -5,6 +5,8 @@ import io.opentelemetry.api.trace.{Span, SpanContext, SpanKind, StatusCode, Trac import io.opentelemetry.context.Context import ox.flow.tracing.OxFlowTracing +import java.nio.channels.ClosedByInterruptException +import java.util.concurrent.ExecutionException import scala.util.boundary import scala.util.control.ControlThrowable @@ -45,36 +47,28 @@ sealed trait OxTracing extends OxFlowTracing: * * If `t` throws, the exception is recorded on the span and the span's status is set to [[io.opentelemetry.api.trace.StatusCode.ERROR]], * before `beforeClose` is called. The exception is then re-thrown. This doesn't apply to [[InterruptedException]] and - * [[scala.util.control.ControlThrowable]]s, as well as `boundary.break`: they are not failures, but a way of exiting early, so the - * span is ended without an error status, but `beforeClose` still receives them. + * [[java.nio.channels.ClosedByInterruptException]] (an interruption during I/O), [[scala.util.control.ControlThrowable]]s, as well as + * `boundary.break`: they are not failures, but a way of exiting early, so the span is ended without an error status, and `beforeClose` + * doesn't receive them, as if `t` succeeded. The same applies to a [[java.util.concurrent.ExecutionException]] wrapping one of them, as + * when a cancelled fork is joined. Fatal errors, e.g. [[OutOfMemoryError]], are failures and are recorded. * - * To span a [[ox.flow.Flow]], use [[OxFlowTracing.spanned]]. + * To span a [[ox.flow.Flow]], use [[OxFlowTracing.span]]. * * @param beforeClose - * Run right before the span is closed, e.g. to set attributes or add events. The second element of the argument is the exception - * thrown by `t`, if any. If `beforeClose` itself throws, the exception is added as suppressed to the one thrown by `t`, or thrown if - * `t` succeeded. The span is closed in either case. + * Run right before the span is closed, e.g. to set attributes or add events. The second element of the argument is the failure of `t`, + * if any, that is the exception it threw, other than the ones which are not failures. If `beforeClose` itself throws, the exception is + * added as suppressed to the one thrown by `t`, including the ones which are not failures, or thrown if `t` succeeded. The span is + * closed in either case. */ - def byNameSpan[T](t: => T)( + private[ox] def byNameSpan[T](t: => T)( spanName: String, spanKind: SpanKind = SpanKind.INTERNAL, attributes: Attributes = Attributes.empty(), links: Seq[SpanContext] = Seq.empty, beforeClose: SpanWithError => Span = _.span - ): T = byNameSpanIgnoring(t)(_ => false)(spanName, spanKind, attributes, links, beforeClose) - - /** As [[byNameSpan]], but exceptions for which `ignored` returns `true` are not treated as failures of `t`: they are re-thrown, but not - * recorded on the span, don't set its status, and `beforeClose` doesn't receive them. Used to exclude exceptions, which originate - * outside of `t`, e.g. in the stages downstream of a flow. - */ - private[ox] def byNameSpanIgnoring[T](t: => T)(ignored: Throwable => Boolean)( - spanName: String, - spanKind: SpanKind, - attributes: Attributes, - links: Seq[SpanContext], - beforeClose: SpanWithError => Span ): T = var error: Throwable | Null = null + var actionableError: Throwable | Null = null var finalizer: AutoCloseableSpan | Null = null try val finalize = spanUnsafe(spanName, spanKind, attributes, links) @@ -82,14 +76,15 @@ sealed trait OxTracing extends OxFlowTracing: t catch case e: Throwable => - if !ignored(e) then - error = e - if (finalizer ne null) && shouldRecord(e) then + error = e + if shouldRecord(e) then + actionableError = e + if finalizer ne null then finalizer.nn.span.recordException(e) finalizer.nn.span.setStatus(StatusCode.ERROR, e.getMessage): Unit throw e finally - try if finalizer ne null then beforeClose(SpanWithError(finalizer.nn.span, error)): Unit + try if finalizer ne null then beforeClose(SpanWithError(finalizer.nn.span, actionableError)): Unit catch case e: Throwable => if error ne null then error.nn.addSuppressed(e) @@ -98,9 +93,12 @@ sealed trait OxTracing extends OxFlowTracing: end try end try - end byNameSpanIgnoring + end byNameSpan - private def shouldRecord(e: Throwable): Boolean = !(e.isInstanceOf[InterruptedException] || e.isInstanceOf[ControlThrowable] || e.isInstanceOf[boundary.Break[?]]) + private def shouldRecord(e: Throwable): Boolean = e match + case _: InterruptedException | _: ClosedByInterruptException | _: ControlThrowable | _: boundary.Break[?] => false + case e: ExecutionException if e.getCause ne null => shouldRecord(e.getCause) + case _ => true end OxTracing object OxTracing: diff --git a/otel/src/main/scala/ox/telemetry/tracing/SpanWithError.scala b/otel/src/main/scala/ox/telemetry/tracing/SpanWithError.scala index d53ac032..bd98b9a6 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/SpanWithError.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/SpanWithError.scala @@ -2,10 +2,10 @@ package ox.telemetry.tracing import io.opentelemetry.api.trace.Span -opaque type SpanWithError <: (Span, Option[Throwable]) = (Span, Option[Throwable]) +opaque type SpanWithError <: (Span, Option[Throwable]) = (Span, Option[Throwable]) object SpanWithError: - def apply(span:Span,errorOpt:Option[Throwable]):SpanWithError = (span, errorOpt) - def apply(span:Span,errorOpt:Throwable):SpanWithError = (span,Option(errorOpt)) - extension(s:SpanWithError) + def apply(span: Span, errorOpt: Option[Throwable]): SpanWithError = (span, errorOpt) + def apply(span: Span, errorOpt: Throwable): SpanWithError = (span, Option(errorOpt)) + extension (s: SpanWithError) def span = s._1 def errorOpt = s._2 diff --git a/otel/src/main/scala/ox/telemetry/tracing/tracing.scala b/otel/src/main/scala/ox/telemetry/tracing/tracing.scala index d2f23aec..dba748cf 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/tracing.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/tracing.scala @@ -4,18 +4,7 @@ import io.opentelemetry.api.common.Attributes import io.opentelemetry.api.trace.{Span, SpanContext, SpanKind} import ox.telemetry.tracing.OxTracing -extension [T](t: => T) - /** Evaluates the by-name argument within a new span, see [[OxTracing.byNameSpan]]. */ - def byNameSpan(oxTracing: OxTracing)( - spanName: String, - spanKind: SpanKind = SpanKind.INTERNAL, - attributes: Attributes = Attributes.empty(), - links: Seq[SpanContext] = Seq.empty, - beforeClose: ((Span, Option[Throwable])) => Span = _._1 - ): T = oxTracing.byNameSpan(t)(spanName, spanKind, attributes, links, beforeClose) -end extension - -export ox.flow.tracing.{flatMapSpanned, mapSpanned, spanEachDownstream, spanEachStages, spanned} +export ox.flow.tracing.{flatMapSpanned, mapSpanned, span} /** Returns the current span, see [[OxTracing.currentSpanUnsafe]]. */ def currentSpanUnsafe(oxTracing: OxTracing) = oxTracing.currentSpanUnsafe() diff --git a/otel/src/test/scala/OxTelemetryTest.scala b/otel/src/test/scala/OxTelemetryTest.scala index 8e5fd584..c301a406 100644 --- a/otel/src/test/scala/OxTelemetryTest.scala +++ b/otel/src/test/scala/OxTelemetryTest.scala @@ -1,17 +1,19 @@ +package ox.telemetry + import io.opentelemetry.api.common.{AttributeKey, Attributes} import io.opentelemetry.api.trace.StatusCode +import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter import io.opentelemetry.sdk.OpenTelemetrySdk -import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter import io.opentelemetry.sdk.resources.Resource +import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter import io.opentelemetry.sdk.trace.SdkTracerProvider import io.opentelemetry.sdk.trace.`export`.{BatchSpanProcessor, SimpleSpanProcessor} -import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter import org.scalatest.BeforeAndAfterAll import org.scalatest.matchers.should.Matchers import org.scalatest.time.SpanSugar.convertIntToGrainOfTime import org.scalatest.wordspec.AnyWordSpec import ox.* -import ox.channels.{BufferCapacity, Channel, Sink, Source} +import ox.channels.BufferCapacity import ox.either.ok import ox.flow.Flow import ox.otel.context.PropagatingVirtualThreadFactory @@ -37,9 +39,12 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: .builder() .setResource(Resource.create(Attributes.of(AttributeKey.stringKey("service.name"), "ox-telemetry-test"))) .addSpanProcessor(SimpleSpanProcessor.create(exporter)) - sys.env.get("OTEL_EXPORTER_OTLP_ENDPOINT").foreach: endpoint => - builder.addSpanProcessor(BatchSpanProcessor.builder(OtlpGrpcSpanExporter.builder().setEndpoint(endpoint).build()).build()) + sys.env + .get("OTEL_EXPORTER_OTLP_ENDPOINT") + .foreach: endpoint => + builder.addSpanProcessor(BatchSpanProcessor.builder(OtlpGrpcSpanExporter.builder().setEndpoint(endpoint).build()).build()) builder.build() + end tracerProvider override def afterAll(): Unit = tracerProvider.shutdown().join(10, java.util.concurrent.TimeUnit.SECONDS): Unit @@ -100,12 +105,12 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: "Flow.span" when { "run normally" should { "not open a span until the flow is run" in new Fixture: - flow.spanned(myTracing)(s"$prefix-$Level0"): Unit + flow.span(myTracing)(s"$prefix-$Level0"): Unit assert(testSpans.isEmpty) assertNesting() "open a single span for a multi-element flow" in new Fixture: - flow.spanned(myTracing)(s"$prefix-$Level0").runDrain() + flow.span(myTracing)(s"$prefix-$Level0").runDrain() assert(spansOf(Level0).size == 1) assert(testSpans.size == 1) @@ -114,10 +119,9 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: "handle buffer one span per span() call when spans are nested" in new Fixture: supervised { + val spanned1 = flow.span(myTracing)(s"$prefix-$Level1").map(_ + 100).tap(i => currentSpanUnsafe(myTracing).addEvent(i.toString)) - val spanned1 = flow.spanned(myTracing)(s"$prefix-$Level1").map(_+ 100).tap(i => currentSpanUnsafe(myTracing).addEvent(i.toString)) - - spanned1.spanned(myTracing)(s"$prefix-$Level0").runDrain() + spanned1.span(myTracing)(s"$prefix-$Level0").runDrain() assert(spansOf(Level0).size == 1) assert(spansOf(Level1).size == 1) @@ -126,9 +130,10 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: } "open one span per span() call when spans are nested" in new Fixture: supervised { - val spanned1 = (flow.spanned(myTracing)(s"$prefix-$Level1") ++ Flow.fromValues(4, 2)).tap(i => currentSpanUnsafe(myTracing).addEvent(i.toString)) + val spanned1 = + (flow.span(myTracing)(s"$prefix-$Level1") ++ Flow.fromValues(4, 2)).tap(i => currentSpanUnsafe(myTracing).addEvent(i.toString)) - spanned1.spanned(myTracing)(s"$prefix-$Level0").runDrain() + spanned1.span(myTracing)(s"$prefix-$Level0").runDrain() assert(spansOf(Level0).size == 1) assert(spansOf(Level1).size == 1) @@ -137,7 +142,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: } "open a single span when downstream uses mapPar" in new Fixture: - flow.spanned(myTracing)(s"$prefix-$Level0").mapPar(3)(_ + 1).runDrain() + flow.span(myTracing)(s"$prefix-$Level0").mapPar(3)(_ + 1).runDrain() assert(testSpans.size == 1) @@ -151,7 +156,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: "use the current span as parent" in new Fixture: val parent = myTracing.spanUnsafe(s"$prefix-$Level0") - flow.spanned(myTracing)(s"$prefix-$Level1").runToList(): Unit + flow.span(myTracing)(s"$prefix-$Level1").runToList(): Unit parent.close() @@ -160,16 +165,16 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: assertNesting() "open one child span per element when flatMapped under a parent span" in new Fixture: - val spanned = flow.spanned(myTracing)(s"$prefix-$Level1") + val spanned = flow.span(myTracing)(s"$prefix-$Level1") - flow.flatMap { _ => spanned }.spanned(myTracing)(s"$prefix-$Level0").runToList(): Unit + flow.flatMap { _ => spanned }.span(myTracing)(s"$prefix-$Level0").runToList(): Unit assert(spansOf(Level0).size == 1) assert(spansOf(Level1).size == 3) assertNesting() "parent spans of concurrently run flows under the enclosing span" in new Fixture: - val spanned = flow.spanned(myTracing)(s"$prefix-$Level1") + val spanned = flow.span(myTracing)(s"$prefix-$Level1") val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") @@ -184,26 +189,26 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: assertNesting() "nest spans across forks and inner supervised scopes" in new Fixture: - val spanned = flow.spanned(myTracing)(s"$prefix-$Level1") + val spanned = flow.span(myTracing)(s"$prefix-$Level1") - supervised { + myTracing.byNameSpan(supervised { (1 to 10).map { case 1 => forkUser( flow .flatMap { el => val innerFlow = supervised { - flow.spanned(myTracing)(s"$prefix-$Level1_First_Child", beforeClose = _._1.addEvent("boomed")) + flow.span(myTracing)(s"$prefix-$Level1_First_Child", beforeClose = _._1.addEvent("boomed")) } innerFlow } - .spanned(myTracing)(s"$prefix-$Level1_First") + .span(myTracing)(s"$prefix-$Level1_First") .runToList() ) case _ => forkUser(spanned.runToList()) } - }.byNameSpan(myTracing)(s"$prefix-$Level0") + })(s"$prefix-$Level0") assert(spansOf(Level1).size == 9) assert(spansOf(Level1_First).size == 1) @@ -217,11 +222,15 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: try flow .tap { case 2 => throw new Exception("boom"); case _ => () } - .spanned(myTracing)(s"$prefix-$Level0", beforeClose = { - case (s, thr) => s.addEvent(thr.toString) - }) + .span(myTracing)( + s"$prefix-$Level0", + beforeClose = { case (s, thr) => + s.addEvent(thr.toString) + } + ) .runToList() catch case _ => () + end try assert(testSpans.size == 1) @@ -232,12 +241,12 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: assertNesting() "keep the flow span as a child of the recovery span when failure is recovered" in new Fixture: - val spanned = flow.spanned(myTracing)(s"$prefix-$Level1") + val spanned = flow.span(myTracing)(s"$prefix-$Level1") spanned .map { case 2 => throw new Exception("boom"); case x => x } .recover(_ => 5) - .spanned(myTracing)(s"$prefix-$Level0") + .span(myTracing)(s"$prefix-$Level0") .runToList(): Unit assert(spansOf(Level0).size == 1) @@ -245,7 +254,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: assertNesting() "end spans of concurrent flows when one of them fails" in new Fixture: - val spanned = flow.spanned(myTracing)(s"$prefix-$Level1") + val spanned = flow.span(myTracing)(s"$prefix-$Level1") val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") @@ -268,7 +277,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: val level0 = spanUnsafe(myTracing)(s"$prefix-$Level0") - val spanned = flow.spanned(myTracing)(s"$prefix-$Level1") + val spanned = flow.span(myTracing)(s"$prefix-$Level1") try supervised { @@ -276,21 +285,22 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: case 1 => forkUser { flow - .flatMap { el => { - val inner = supervisedError(em): - either: - val innerFlow = - em.pure( - flow.spanned(myTracing)(s"$prefix-$Level1_First_Child", beforeClose = _._1.addEvent(s"boomed at $el")).map { - case 1 => em.pureError("ops").ok(); - case x => x - } - ) - innerFlow.ok() - inner - }.toOption.get + .flatMap { el => + { + val inner = supervisedError(em): + either: + val innerFlow = + em.pure( + flow.span(myTracing)(s"$prefix-$Level1_First_Child", beforeClose = _._1.addEvent(s"boomed at $el")).map { + case 1 => em.pureError("ops").ok(); + case x => x + } + ) + innerFlow.ok() + inner + }.toOption.get } - .spanned(myTracing)(s"$prefix-$Level1_First") + .span(myTracing)(s"$prefix-$Level1_First") .runToList() } case _ => forkUser(spanned.runToList()) @@ -308,7 +318,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: val level0 = spanUnsafe(myTracing)(s"$prefix-$Level0") - val spanned = flow.spanned(myTracing)(s"$prefix-$Level1") + val spanned = flow.span(myTracing)(s"$prefix-$Level1") try supervised { @@ -316,21 +326,22 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: case 1 => forkUser { flow - .flatMap { el => { - val inner = supervisedError(em): - either: - val innerFlow = - em.pure( - flow.spanned(myTracing)(s"$prefix-$Level1_First_Child", beforeClose = _._1.addEvent(s"boomed at $el")).map { - case 1 => em.pureError("ops").ok(); - case x => x - } - ) - innerFlow.ok() - inner - }.toOption.getOrElse(Flow.empty) + .flatMap { el => + { + val inner = supervisedError(em): + either: + val innerFlow = + em.pure( + flow.span(myTracing)(s"$prefix-$Level1_First_Child", beforeClose = _._1.addEvent(s"boomed at $el")).map { + case 1 => em.pureError("ops").ok(); + case x => x + } + ) + innerFlow.ok() + inner + }.toOption.getOrElse(Flow.empty) } - .spanned(myTracing)(s"$prefix-$Level1_First") + .span(myTracing)(s"$prefix-$Level1_First") .runToList() } case _ => forkUser(spanned.runToList()) @@ -348,7 +359,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: val level0 = spanUnsafe(myTracing)(s"$prefix-$Level0") - val spanned = flow.spanned(myTracing)(s"$prefix-$Level1") + val spanned = flow.span(myTracing)(s"$prefix-$Level1") try supervisedError(em) { @@ -358,19 +369,20 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: case 1 => forkUser { flow - .flatMap { el => { - supervisedError(em): - val innerFlow = - em.pure( - flow.spanned(myTracing)(s"$prefix-$Level1_First_Child", beforeClose = _._1.addEvent(s"boomed at $el")).map { - case 1 => em.pureError("ops").ok(); - case x => x - } - ) - innerFlow - }.toOption.get + .flatMap { el => + { + supervisedError(em): + val innerFlow = + em.pure( + flow.span(myTracing)(s"$prefix-$Level1_First_Child", beforeClose = _._1.addEvent(s"boomed at $el")).map { + case 1 => em.pureError("ops").ok(); + case x => x + } + ) + innerFlow + }.toOption.get } - .spanned(myTracing)(s"$prefix-$Level1_First") + .span(myTracing)(s"$prefix-$Level1_First") .runToList() } case _ => forkUser(spanned.runToList()) @@ -384,143 +396,12 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: assertNesting() } - "used with downstream stages" should { - "make the flow span current in downstream stages" in new Fixture: - val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") - - flow.spanned(myTracing)(s"$prefix-$Level1_First").tap(_ => myTracing.byNameSpan(())(s"$prefix-$Level1_First_Child")).runToList(): Unit - - parent.close() - - assert(spansOf(Level1_First).size == 1) - // downstream stages are called from within the span, so spans created there are its children - assert(spansOf(Level1_First_Child).size == 3) - assertNesting() - - "make the flow span current in upstream stages" in new Fixture: - val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") - - flow.tap(_ => myTracing.byNameSpan(())(s"$prefix-$Level1_First_Child")).spanned(myTracing)(s"$prefix-$Level1_First").runToList(): Unit - - parent.close() - - assert(spansOf(Level1_First).size == 1) - assert(spansOf(Level1_First_Child).size == 3) - assertNesting() - - "end span when a downstream stage fails" in new Fixture: - try flow.spanned(myTracing)(s"$prefix-$Level0").map { case 2 => throw new Exception("boom"); case n => n }.runToList(): Unit - catch case _ => () - - assert(spansOf(Level0).size == 1) - assert(testSpans.size == 1) - assert(spansOf(Level0).head.getStatus.getStatusCode == StatusCode.ERROR) - assertNesting() - - "end span when downstream failure is recovered" in new Fixture: - flow.spanned(myTracing)(s"$prefix-$Level0").map { case 2 => throw new Exception("boom"); case x => x }.recover(_ => 5).runToList() - - assert(spansOf(Level0).size == 1) - assert(testSpans.size == 1) - assertNesting() - } - - "spanEachDownstream" should { - "open a span per element, around the stages placed after it" in new Fixture: - val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") - - flow - .spanEachDownstream(myTracing)(s"$prefix-$Level1_First") - .map(x => myTracing.byNameSpan(x)(s"$prefix-$Level1_First_Child")) - .runToList(): Unit - - parent.close() - - assert(spansOf(Level1_First).size == 3) - assert(spansOf(Level1_First_Child).size == 3) - assertNesting() - - "not cover the stages placed before it" in new Fixture: - val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") - - flow.map(x => myTracing.byNameSpan(x)(s"$prefix-$Level1")).spanEachDownstream(myTracing)(s"$prefix-$Level1_First").runToList(): Unit - - parent.close() - - // spans created upstream are not children of the per-element spans - assert(spansOf(Level1).size == 3) - assert(spansOf(Level1_First).size == 3) - assertNesting() - - "open a span per element, with the stage and fan-out downstream spans as its children" in new Fixture: - val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") - - flow - .map(_ + 1) - .spanEachDownstream(myTracing)(s"$prefix-$Level1_First") - .map(x => myTracing.byNameSpan(x)(s"$prefix-$Level1_Stage")) - .flatMap(x => Flow.fromValues(1 to x*)) - .map(x => myTracing.byNameSpan(x)(s"$prefix-$Level1_Downstream")) - .runToList(): Unit - - parent.close() - - val elementSpans = spansOf(Level1_First) - assert(elementSpans.size == 3) - - // one stage span per element - assert(spansOf(Level1_Stage).size == 3) - // elements 1, 2, 3 are mapped to 2, 3, 4, and fanned out to that many outputs, each of which is passed to the last map - assert(spansOf(Level1_Downstream).size == 9) - - val childrenPerElement = (spansOf(Level1_Stage) ++ spansOf(Level1_Downstream)).groupBy(_.getParentSpanId) - assert(childrenPerElement.keySet == elementSpans.map(_.getSpanId).toSet) - assert(childrenPerElement.values.map(_.size).toList.sorted == List(3, 4, 5)) - } - "used with async stages downstream" should { - "make a span after buffer a sibling of the per-element spans" in new Fixture: - val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") - - flow - .spanEachDownstream(myTracing)(s"$prefix-$Level1") - .buffer() - .map(x => myTracing.byNameSpan(x)(s"$prefix-$Level1_Stage")) - .runToList(): Unit - - parent.close() - - val level0Id = spansOf(Level0).head.getSpanId - assert(spansOf(Level1).size == 3) - assert(spansOf(Level1_Stage).size == 3) - // buffer's consumer doesn't run within the per-element spans, it keeps the context of where the flow is run - assert(spansOf(Level1_Stage).forall(_.getParentSpanId == level0Id)) - assert(spansOf(Level1).forall(_.getParentSpanId == level0Id)) - - "keep the stages between the span and buffer as children, and the ones after it as siblings" in new Fixture: - val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") - - flow - .spanEachDownstream(myTracing)(s"$prefix-$Level1") - .map(x => myTracing.byNameSpan(x)(s"$prefix-$Level1_Stage")) - .buffer() - .map(x => myTracing.byNameSpan(x)(s"$prefix-$Level1_Downstream")) - .runToList(): Unit - - parent.close() - - val level0Id = spansOf(Level0).head.getSpanId - val elementIds = spansOf(Level1).map(_.getSpanId).toSet - assert(spansOf(Level1_Stage).size == 3) - assert(spansOf(Level1_Downstream).size == 3) - assert(spansOf(Level1_Stage).forall(s => elementIds.contains(s.getParentSpanId))) - assert(spansOf(Level1_Downstream).forall(_.getParentSpanId == level0Id)) - "make a span after buffer a sibling of the whole-run span" in new Fixture: val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") flow - .spanned(myTracing)(s"$prefix-$Level1") + .span(myTracing)(s"$prefix-$Level1") .buffer() .map(x => myTracing.byNameSpan(x)(s"$prefix-$Level1_Stage")) .runToList(): Unit @@ -532,101 +413,23 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: assert(spansOf(Level1_Stage).size == 3) assert(spansOf(Level1_Stage).forall(_.getParentSpanId == level0Id)) - "make a span in mapPar a child of the per-element spans" in new Fixture: - val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") - - flow - .spanEachDownstream(myTracing)(s"$prefix-$Level1") - .mapPar(2)(x => myTracing.byNameSpan(x)(s"$prefix-$Level1_Stage")) - .runToList(): Unit - - parent.close() - - val elementIds = spansOf(Level1).map(_.getSpanId).toSet - assert(spansOf(Level1_Stage).size == 3) - // mapPar forks the function from within the emit, so the forks inherit the per-element span - assert(spansOf(Level1_Stage).forall(s => elementIds.contains(s.getParentSpanId))) - - "not end the span at a buffer placed inside a later flatMap" in new Fixture: - Flow - .fromValues(1, 2, 3) - .flatMap(x => Flow.fromValues(x).tap(_ => sleep(100.millis)).spanned(myTracing)(s"$prefix-$Level1")) - .tap(_ => sleep(100.millis)) - .flatMap(x => Flow.fromValues(x).buffer()) - .tap(_ => sleep(100.millis)) - .runToList(): Unit - - // the buffer is inside the flow returned by flatMap, its consumer is drained from within the upstream emit, - // so the span still covers both taps placed after it - assert(spansOf(Level1).size == 3) - spansOf(Level1).foreach(s => assert((s.getEndEpochNanos - s.getStartEpochNanos) / 1000000 >= 300)) - - "end the span at a buffer placed directly in the flow" in new Fixture: - Flow - .fromValues(1, 2, 3) - .flatMap(x => Flow.fromValues(x).tap(_ => sleep(100.millis)).spanned(myTracing)(s"$prefix-$Level1")) - .buffer() - .tap(_ => sleep(100.millis)) - .runToList(): Unit - - assert(spansOf(Level1).size == 3) - spansOf(Level1).foreach(s => assert((s.getEndEpochNanos - s.getStartEpochNanos) / 1000000 < 200)) // one element, with sleeps far apart: a = first generator, b = second generator, c = stage placed after the whole comprehension val a = 200.millis val b = 600.millis val c = 1200.millis val (aMs, bMs, cMs) = (a.toMillis, b.toMillis, c.toMillis) - "keep the outer span over a recoverWith placed inside the later generator" in new Fixture: - val filesToDownload = Seq(1) - def download(file: Int): Int = - sleep(a) - file - def readLines(file: Int): Flow[String] = Flow.fromValues(s"line of $file").tap(_ => sleep(b)) - def streamLinesFlow(file: Int) = readLines(file).spanned(myTracing)(s"$prefix-$Level1_First") - (for - file <- Flow.fromIterable(filesToDownload).map(download).spanned(myTracing)(s"$prefix-$Level1") - line <- streamLinesFlow(file) - yield line).tap(_ => sleep(c)).runToList(): Unit - - val outer = spansOf(Level1).head - val inner = spansOf(Level1_First).head - // recoverWith drains its channel from within the outer emit, so the outer span still covers the last sleep - assertAbout(durMs(outer), aMs + bMs + cMs) - // the inner span is inside the fork of recoverWith, so it ends at the hand-over - assertAbout(durMs(inner), bMs) - assert(inner.getParentSpanId == outer.getSpanId) - - "open a span per file with spanEachDownstream, keeping the following stages inside it" in new Fixture: + "open a span per file with mapSpanned, covering only the download" in new Fixture: val filesToDownload = Seq(1, 2) + def download(file: Int): Int = sleep(a) file + def readLines(file: Int): Flow[String] = Flow.fromValues(s"line of $file").tap(_ => sleep(b)) - def streamLinesFlow(file: Int) = readLines(file).spanned(myTracing)(s"$prefix-$Level1_First") - (for - file <- Flow.fromIterable(filesToDownload).map(download).spanEachDownstream(myTracing)(s"$prefix-$Level1") - line <- streamLinesFlow(file).recoverWith(_ => streamLinesFlow(file)) - yield line).tap(_ => sleep(c)).runToList(): Unit - val perFile = spansOf(Level1).sortBy(_.getStartEpochNanos) - val lines = spansOf(Level1_First) - assert(perFile.size == 2 && lines.size == 2) - // download is upstream of the span, and recoverWith ends only the lines span at the hand-over, so a file's span is b + c - perFile.foreach(s => assertAbout(durMs(s), bMs + cMs)) - lines.foreach(s => assertAbout(durMs(s), bMs)) - // each lines span belongs to a different file span - assert(lines.map(_.getParentSpanId).toSet == perFile.map(_.getSpanId).toSet) - // the second file is downloaded only after the first one is fully processed - assertAbout(startOffsetMs(perFile(0), perFile(1)), bMs + cMs + aMs) + def streamLinesFlow(file: Int) = readLines(file).span(myTracing)(s"$prefix-$Level1_First") - "open a span per file with mapSpanned, covering only the download" in new Fixture: - val filesToDownload = Seq(1, 2) - def download(file: Int): Int = - sleep(a) - file - def readLines(file: Int): Flow[String] = Flow.fromValues(s"line of $file").tap(_ => sleep(b)) - def streamLinesFlow(file: Int) = readLines(file).spanned(myTracing)(s"$prefix-$Level1_First") (for file <- Flow.fromIterable(filesToDownload).mapSpanned(myTracing)(s"$prefix-$Level1")(download) line <- streamLinesFlow(file).recoverWith(_ => streamLinesFlow(file)) @@ -643,27 +446,14 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: // the second file is downloaded only after the first one is fully processed assertAbout(startOffsetMs(downloads(0), downloads(1)), aMs + bMs + cMs) - "count the nested generators and the following stages in a flat for comprehension with spanned" in new Fixture: - (for - x <- Flow.fromValues(1) - y <- Flow.fromValues(x).tap(_ => sleep(a)).spanned(myTracing)(s"$prefix-$Level1") - z <- Flow.fromValues(y).tap(_ => sleep(b)).spanned(myTracing)(s"$prefix-$Level1_First") - yield z).tap(_ => sleep(c)).runToList(): Unit - - val y = spansOf(Level1).head - val z = spansOf(Level1_First).head - assert(z.getParentSpanId == y.getSpanId) - assertAbout(durMs(y), aMs + bMs + cMs) - assertAbout(durMs(z), bMs + cMs) - assertAbout(startOffsetMs(y, z), aMs) - "count only its own flow in a flat for comprehension with spannedAsync" in new Fixture: (for - x <- Flow.fromValues(1,2,3) - y <- myTracing.spannedAsync(Flow.fromValues(x).tap(_ => sleep(a)))(s"$prefix-$Level1") - z <- myTracing.spannedAsync(Flow.fromValues(y).tap(_ => sleep(b)))(s"$prefix-$Level1_First") + x <- Flow.fromValues(1) + y <- Flow.fromValues(x).tap(_ => sleep(a)).span(myTracing)(s"$prefix-$Level1") + z <- Flow.fromValues(y).tap(_ => sleep(b)).span(myTracing)(s"$prefix-$Level1_First") yield z) - .tap(_ => sleep(c)).runToList(): Unit + .tap(_ => sleep(c)) + .runToList(): Unit val y = spansOf(Level1).head val z = spansOf(Level1_First).head @@ -672,27 +462,13 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: assertAbout(durMs(z), bMs) assertAbout(startOffsetMs(y, z), aMs) - "end the first span at a buffer placed after it in a flat for comprehension with spanned" in new Fixture: - (for - x <- Flow.fromValues(1) - y <- Flow.fromValues(x).tap(_ => sleep(a)).spanned(myTracing)(s"$prefix-$Level1").buffer() - z <- Flow.fromValues(y).tap(_ => sleep(b)).spanned(myTracing)(s"$prefix-$Level1_First") - yield z).tap(_ => sleep(c)).runToList(): Unit - - val y = spansOf(Level1).head - val z = spansOf(Level1_First).head - assert(z.getParentSpanId != y.getSpanId) - assertAbout(durMs(y), aMs) - assertAbout(durMs(z), bMs + cMs) - assertAbout(startOffsetMs(y, z), aMs) - "nest the durations of a spannedAsync in a flatMapSpanned" in new Fixture: Flow .fromValues(1) .flatMapSpanned(myTracing)(s"$prefix-$Level1")(x => for y <- Flow.fromValues(x).tap(_ => sleep(a)) - z <- myTracing.spannedAsync(Flow.fromValues(y).tap(_ => sleep(b)))(s"$prefix-$Level1_First") + z <- Flow.fromValues(y).tap(_ => sleep(b)).span(myTracing)(s"$prefix-$Level1_First") yield z ) .tap(_ => sleep(c)) @@ -706,119 +482,41 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: assertAbout(startOffsetMs(parent, child), aMs) assert(child.getEndEpochNanos <= parent.getEndEpochNanos) - "not end the span at alsoTo, as it sends to the sink synchronously" in new Fixture: - supervised: - val sink = Channel.rendezvous[Int] - fork: - for _ <- 1 to 3 do - sleep(200.millis) - sink.receive(): Unit - Flow - .fromValues(1, 2, 3) - .flatMap(x => Flow.fromValues(x).spanned(myTracing)(s"$prefix-$Level1")) - .alsoTo(sink) - .runToList(): Unit - - // no sleeps in the flow, yet each span lasts as long as the send to the slow sink blocks - assert(spansOf(Level1).size == 3) - spansOf(Level1).foreach(s => assert((s.getEndEpochNanos - s.getStartEpochNanos) / 1000000 >= 150)) - } - - "spannedIsolated" should { - def durationMs(span: io.opentelemetry.sdk.trace.data.SpanData) = (span.getEndEpochNanos - span.getStartEpochNanos) / 1000000 - - "make the spans created in upstream stages children, and in downstream stages siblings" in new Fixture: - val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") - - myTracing - .spannedIsolated(flow.tap(x => myTracing.byNameSpan(x)(s"$prefix-$Level1_First")))(s"$prefix-$Level1") - .tap(x => myTracing.byNameSpan(x)(s"$prefix-$Level1_Stage")) - .runToList(): Unit - - parent.close() - - val level0Id = spansOf(Level0).head.getSpanId - val level1Id = spansOf(Level1).head.getSpanId - assert(spansOf(Level1).size == 1) - assert(spansOf(Level1).head.getParentSpanId == level0Id) - assert(spansOf(Level1_First).size == 3 && spansOf(Level1_First).forall(_.getParentSpanId == level1Id)) - assert(spansOf(Level1_Stage).size == 3 && spansOf(Level1_Stage).forall(_.getParentSpanId == level0Id)) - - "keep the spans created in stages before and after a buffer placed before it as children" in new Fixture: - val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") - - myTracing - .spannedIsolated( - flow - .tap(x => myTracing.byNameSpan(x)(s"$prefix-$Level1_First")) - .buffer() - .tap(x => myTracing.byNameSpan(x)(s"$prefix-$Level1_Stage")) - )(s"$prefix-$Level1") + "nest the spans and durations of a flatMapSpanned inside a flatMapSpanned" in new Fixture: + Flow + .fromValues(1) + .flatMapSpanned(myTracing)(s"$prefix-$Level1")(x => + Flow + .fromValues(x) + .tap(_ => sleep(a)) + .flatMapSpanned(myTracing)(s"$prefix-$Level1_First")(y => Flow.fromValues(y).tap(_ => sleep(b))) + ) + .tap(_ => sleep(c)) .runToList(): Unit - parent.close() - - val level1Id = spansOf(Level1).head.getSpanId - // both the producer (before the buffer) and the consumer (after it) run within the span - assert(spansOf(Level1_First).size == 3 && spansOf(Level1_First).forall(_.getParentSpanId == level1Id)) - assert(spansOf(Level1_Stage).size == 3 && spansOf(Level1_Stage).forall(_.getParentSpanId == level1Id)) - - "make the spans created in stages after a buffer placed after it siblings" in new Fixture: - val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") + val outer = spansOf(Level1).head + val inner = spansOf(Level1_First).head + assert(inner.getParentSpanId == outer.getSpanId) + assertAbout(durMs(outer), aMs + bMs) + assertAbout(durMs(inner), bMs) + assertAbout(startOffsetMs(outer, inner), aMs) + assert(inner.getEndEpochNanos <= outer.getEndEpochNanos) - myTracing - .spannedIsolated(flow.tap(x => myTracing.byNameSpan(x)(s"$prefix-$Level1_First")))(s"$prefix-$Level1") - .buffer() - .tap(x => myTracing.byNameSpan(x)(s"$prefix-$Level1_Stage")) + "make chained flatMapSpanned spans siblings, each covering only its own flow" in new Fixture: + Flow + .fromValues(1) + .flatMapSpanned(myTracing)(s"$prefix-$Level1")(x => Flow.fromValues(x).tap(_ => sleep(a))) + .flatMapSpanned(myTracing)(s"$prefix-$Level1_First")(y => Flow.fromValues(y).tap(_ => sleep(b))) + .tap(_ => sleep(c)) .runToList(): Unit - parent.close() - - val level0Id = spansOf(Level0).head.getSpanId - val level1Id = spansOf(Level1).head.getSpanId - assert(spansOf(Level1_First).forall(_.getParentSpanId == level1Id)) - assert(spansOf(Level1_Stage).size == 3 && spansOf(Level1_Stage).forall(_.getParentSpanId == level0Id)) + val first = spansOf(Level1).head + val second = spansOf(Level1_First).head + assert(second.getParentSpanId != first.getSpanId) + assertAbout(durMs(first), aMs) + assertAbout(durMs(second), bMs) + assertAbout(startOffsetMs(first, second), aMs) - "count the time of synchronous downstream stages" in new Fixture: - val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") - - myTracing.spannedIsolated(flow)(s"$prefix-$Level1").tap(_ => sleep(200.millis)).runToList(): Unit - - parent.close() - - assert(durationMs(spansOf(Level1).head) >= 500) - - "not count the time of downstream stages after a buffer" in new Fixture: - val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") - - myTracing.spannedIsolated(flow)(s"$prefix-$Level1").buffer().tap(_ => sleep(200.millis)).runToList(): Unit - - parent.close() - - // upstream is done quickly, while the downstream takes 3 * 200ms - assert(durationMs(spansOf(Level1).head) < 300) - - "not mark the span as failed when a downstream stage fails" in new Fixture: - val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") - - try myTracing.spannedIsolated(flow)(s"$prefix-$Level1").map { case 2 => throw new Exception("boom"); case x => x }.runToList(): Unit - catch case _ => () - - parent.close() - - assert(spansOf(Level1).size == 1) - assert(spansOf(Level1).head.getStatus.getStatusCode == StatusCode.UNSET) - assert(spansOf(Level1).head.getEvents.isEmpty) - - "mark the span as failed when an upstream stage fails" in new Fixture: - val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") - - try myTracing.spannedIsolated(flow.map { case 2 => throw new Exception("boom"); case x => x })(s"$prefix-$Level1").runToList(): Unit - catch case _ => () - - parent.close() - - assert(spansOf(Level1).head.getStatus.getStatusCode == StatusCode.ERROR) } "mapSpanned" should { @@ -850,6 +548,61 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: "flatMapSpanned" should { def durationMs(span: io.opentelemetry.sdk.trace.data.SpanData) = (span.getEndEpochNanos - span.getStartEpochNanos) / 1000000 + "open spans created in the body of the function, before it returns the flow, as children of the span" in new Fixture: + val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") + + flow + .flatMapSpanned(myTracing)(s"$prefix-$Level1_First")(x => + myTracing.byNameSpan(())(s"$prefix-$Level1_First_Child") + Flow.fromValues(x).map(y => myTracing.byNameSpan(y)(s"$prefix-$Level1_Stage")) + ) + .runToList(): Unit + + parent.close() + + val elementIds = spansOf(Level1_First).map(_.getSpanId).toSet + assert(spansOf(Level1_First).size == 3) + // created by f itself, which is called within the span + assert(spansOf(Level1_First_Child).size == 3 && spansOf(Level1_First_Child).forall(c => elementIds.contains(c.getParentSpanId))) + // created by the returned flow, so within the span + assert(spansOf(Level1_Stage).size == 3 && spansOf(Level1_Stage).forall(s => elementIds.contains(s.getParentSpanId))) + + "call the function in the fork of the span, as the returned flow is run" in new Fixture: + val caller = Thread.currentThread() + val inF = java.util.concurrent.ConcurrentLinkedQueue[Thread]() + val inFlow = java.util.concurrent.ConcurrentLinkedQueue[Thread]() + + flow + .flatMapSpanned(myTracing)(s"$prefix-$Level1_First")(x => + inF.add(Thread.currentThread()): Unit + Flow.fromValues(x).tap(_ => inFlow.add(Thread.currentThread()): Unit) + ) + .runToList(): Unit + + assert(inF.size == 3 && inF.toArray.forall(_ ne caller)) + assert(inFlow.size == 3 && inFlow.toArray.forall(_ ne caller)) + + "record the exception thrown by the function before it returns the flow on the span of the element" in new Fixture: + val thrown = intercept[Exception]: + flow + .flatMapSpanned(myTracing)(s"$prefix-$Level1_First")(x => if x == 2 then throw new Exception("boom") else Flow.fromValues(x)) + .runToList(): Unit + + // it reaches the caller through the fork, wrapped + assert(thrown.getMessage.contains("boom")) + // the first element is fine, the second one fails in the function, which is called within the span + val spans = spansOf(Level1_First).sortBy(_.getStartEpochNanos) + assert(spans.size == 2) + assert(spans.head.getStatus.getStatusCode == StatusCode.UNSET) + assert(spans(1).getStatus.getStatusCode == StatusCode.ERROR) + assert(spans(1).getStatus.getDescription == "boom") + assert(spans(1).getEvents.asScala.exists(_.getName == "exception")) + + "emit the same elements as flatMap" in new Fixture: + val f = (x: Int) => Flow.fromValues(x, x * 10) + + assert(flow.flatMapSpanned(myTracing)(s"$prefix-$Level1_First")(f).runToList() == flow.flatMap(f).runToList()) + "open a span per element, with the spans created in the inner flows as children and the following ones as siblings" in new Fixture: val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") @@ -886,7 +639,11 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: assert(spansOf(Level1).forall(durationMs(_) < 150)) "not mark the spans as failed when a following stage fails" in new Fixture: - try flow.flatMapSpanned(myTracing)(s"$prefix-$Level0")(x => Flow.fromValues(x)).map { case 2 => throw new Exception("boom"); case x => x }.runToList(): Unit + try + flow + .flatMapSpanned(myTracing)(s"$prefix-$Level0")(x => Flow.fromValues(x)) + .map { case 2 => throw new Exception("boom"); case x => x } + .runToList(): Unit catch case _ => () assert(spansOf(Level0).nonEmpty) @@ -896,7 +653,9 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: "mark only the span of the inner flow that fails" in new Fixture: try flow - .flatMapSpanned(myTracing)(s"$prefix-$Level0")(x => Flow.fromValues(x).map { case 2 => throw new Exception("boom"); case y => y }) + .flatMapSpanned(myTracing)(s"$prefix-$Level0")(x => + Flow.fromValues(x).map { case 2 => throw new Exception("boom"); case y => y } + ) .runToList(): Unit catch case _ => () @@ -934,81 +693,6 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: } } - "spanEachStages" should { - "open a span per element over the stages, with the spans of the stages as children and the other ones as siblings" in new Fixture: - val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") - - flow - .tap(x => myTracing.byNameSpan(x)(s"$prefix-$Level1_First")) - .spanEachStages(myTracing)(s"$prefix-$Level1")( - _.map(x => myTracing.byNameSpan(x)(s"$prefix-$Level1_Stage")).flatMap(x => Flow.fromValues(x, x * 10)) - ) - .map(x => myTracing.byNameSpan(x)(s"$prefix-$Level1_Downstream")) - .runToList(): Unit - - parent.close() - - val level0Id = spansOf(Level0).head.getSpanId - val elementIds = spansOf(Level1).map(_.getSpanId).toSet - assert(spansOf(Level1).size == 3) - assert(spansOf(Level1).forall(_.getParentSpanId == level0Id)) - // created before the stages, so outside of the element spans - assert(spansOf(Level1_First).size == 3 && spansOf(Level1_First).forall(_.getParentSpanId == level0Id)) - // one per element, in the stages - assert(spansOf(Level1_Stage).size == 3 && spansOf(Level1_Stage).forall(s => elementIds.contains(s.getParentSpanId))) - // each element is extrapolated to 2 elements, which go through the stage after - assert(spansOf(Level1_Downstream).size == 6 && spansOf(Level1_Downstream).forall(_.getParentSpanId == level0Id)) - - "work with stages that conflate the elements" in new Fixture: - val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") - - val result = Flow.fromValues(1, 2, 3, 4).spanEachStages(myTracing)(s"$prefix-$Level1")(_.grouped(2)).runToList() - - parent.close() - - assert(result == List(List(1, 2), List(3, 4))) - assert(spansOf(Level1).size == 4) - assert(spansOf(Level1).forall(_.getParentSpanId == spansOf(Level0).head.getSpanId)) - - "work with infinite flows" in new Fixture: - val result = Flow.iterate(1)(_ + 1).spanEachStages(myTracing)(s"$prefix-$Level0")(_.map(_ * 2)).take(3).runToList() - - assert(result == List(2, 4, 6)) - // the spans of the elements that were being processed when the flow was stopped are not marked as failed - assert(spansOf(Level0).size >= 3) - assert(spansOf(Level0).forall(_.getStatus.getStatusCode == StatusCode.UNSET)) - } - - "nested whole-run spans over a concat" should { - def durationMs(span: io.opentelemetry.sdk.trace.data.SpanData) = (span.getEndEpochNanos - span.getStartEpochNanos) / 1000000 - - "cover the downstream stages and the flows run before them" in new Fixture: - // 3 elements, each taking 100ms to produce - val slowFlow = Flow.fromValues(1, 2, 3).tap(_ => sleep(100.millis)) - - val start = System.nanoTime() - slowFlow - .spanned(myTracing)(s"$prefix-$Level1") - .concat(slowFlow) - .spanned(myTracing)(s"$prefix-$Level0") - .tap(_ => sleep(100.millis)) - .runToList(): Unit - val totalMs = (System.nanoTime() - start) / 1000000 - - val inner = spansOf(Level1).head - val outer = spansOf(Level0).head - - // 6 elements, each taking 100ms to produce and 100ms to be processed by the last stage - assert(totalMs >= 1150 && totalMs < 1800) - // the outer span covers the whole concat - assert(durationMs(outer) >= 1150 && durationMs(outer) < 1800) - // the inner span covers the first flow and the last stage's processing of its 3 elements - assert(durationMs(inner) >= 550 && durationMs(inner) < 900) - // it was opened inside the outer span - assert(inner.getParentSpanId == outer.getSpanId) - assert(inner.getStartEpochNanos >= outer.getStartEpochNanos && inner.getEndEpochNanos <= outer.getEndEpochNanos) - } - "cancelled" should { "end span when enclosing supervised scope fails before stream completion" in new Fixture: val semaphore = Semaphore(0) @@ -1020,7 +704,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: throw new Exception("oops") } forkUser { - flow.spanned(myTracing)(s"$prefix-$Level0").tap { case 1 => semaphore.release(); case _ => sleep(100.seconds) }.runToList() + flow.span(myTracing)(s"$prefix-$Level0").tap { case 1 => semaphore.release(); case _ => sleep(100.seconds) }.runToList() } } catch case err => () @@ -1035,7 +719,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: supervised { val forkWithSpan = forkCancellable { - flow.spanned(myTracing)(s"$prefix-$Level0").tap { case 1 => semaphore.release(); case _ => sleep(100.seconds) }.runToList() + flow.span(myTracing)(s"$prefix-$Level0").tap { case 1 => semaphore.release(); case _ => sleep(100.seconds) }.runToList() } semaphore.acquire() forkWithSpan.cancelNow() @@ -1056,6 +740,89 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: assert(spansOf(Level0).size == 1) assert(spansOf(Level0).head.getStatus.getStatusCode == StatusCode.UNSET) assert(spansOf(Level0).head.getEvents.isEmpty) + + "not pass a boundary break, a control throwable or an interruption to beforeClose" in new Fixture: + var seen: List[Option[Throwable]] = Nil + def record(s: SpanWithError) = + seen = s.errorOpt :: seen + s.span + + scala.util.boundary: + myTracing.byNameSpan(scala.util.boundary.break(1))(s"$prefix-$Level0", beforeClose = record) + intercept[scala.util.control.ControlThrowable]: + myTracing.byNameSpan(throw new scala.util.control.ControlThrowable {})(s"$prefix-$Level0", beforeClose = record) + intercept[InterruptedException]: + myTracing.byNameSpan(throw new InterruptedException())(s"$prefix-$Level0", beforeClose = record) + + assert(seen == List(None, None, None)) + assert(spansOf(Level0).size == 3) + assert(spansOf(Level0).forall(_.getStatus.getStatusCode == StatusCode.UNSET)) + + "treat an interruption during I/O as not a failure" in new Fixture: + var seen: Option[Throwable] = Some(new Exception("unset")) + intercept[java.nio.channels.ClosedByInterruptException]: + myTracing.byNameSpan(throw new java.nio.channels.ClosedByInterruptException())( + s"$prefix-$Level0", + beforeClose = s => + seen = s.errorOpt + s.span + ) + + assert(seen.isEmpty) + assert(spansOf(Level0).head.getStatus.getStatusCode == StatusCode.UNSET) + + "record the closing of a channel by another thread as a failure" in new Fixture: + intercept[java.nio.channels.AsynchronousCloseException]: + myTracing.byNameSpan(throw new java.nio.channels.AsynchronousCloseException())(s"$prefix-$Level0") + + assert(spansOf(Level0).head.getStatus.getStatusCode == StatusCode.ERROR) + + "treat an ExecutionException wrapping an interruption as not a failure" in new Fixture: + var seen: Option[Throwable] = Some(new Exception("unset")) + intercept[java.util.concurrent.ExecutionException]: + myTracing.byNameSpan(throw new java.util.concurrent.ExecutionException(new InterruptedException()))( + s"$prefix-$Level0", + beforeClose = s => + seen = s.errorOpt + s.span + ) + + assert(seen.isEmpty) + assert(spansOf(Level0).head.getStatus.getStatusCode == StatusCode.UNSET) + + "treat an ExecutionException wrapping a failure as a failure" in new Fixture: + intercept[java.util.concurrent.ExecutionException]: + myTracing.byNameSpan(throw new java.util.concurrent.ExecutionException(new Exception("boom")))(s"$prefix-$Level0") + + assert(spansOf(Level0).head.getStatus.getStatusCode == StatusCode.ERROR) + + "record fatal errors as failures" in new Fixture: + intercept[LinkageError]: + myTracing.byNameSpan(throw new LinkageError("broken"))(s"$prefix-$Level0") + + assert(spansOf(Level0).head.getStatus.getStatusCode == StatusCode.ERROR) + + "pass a failure to beforeClose" in new Fixture: + var seen: Option[Throwable] = None + intercept[Exception]: + myTracing.byNameSpan(throw new Exception("boom"))( + s"$prefix-$Level0", + beforeClose = s => + seen = s.errorOpt + s.span + ) + + assert(seen.exists(_.getMessage == "boom")) + + "add the exception of beforeClose as suppressed to an interruption, which is still thrown" in new Fixture: + val interruption = new InterruptedException() + + val thrown = intercept[InterruptedException]: + myTracing.byNameSpan(throw interruption)(s"$prefix-$Level0", beforeClose = _ => throw new Exception("from beforeClose")) + + assert(thrown eq interruption) + assert(thrown.getSuppressed.exists(_.getMessage == "from beforeClose")) + assert(spansOf(Level0).size == 1) } } end OxTelemetryTest From f423088b2e05661653ed6908f956da5e88e79512 Mon Sep 17 00:00:00 2001 From: Pawel Sadlo Date: Wed, 21 Oct 2026 15:35:40 +0200 Subject: [PATCH 35/38] Flow tracing: replace span and flatMapSpanned with spanStages spanStages runs each element through the stages added by the given function within its own span. The function is called within the span, so spans opened by it and errors thrown by it belong to the span. The span is separated from the following stages by a buffer, so it is isolated from downstream backpressure as long as the stages emit at most BufferCapacity + 1 elements. Co-authored-by: Claude-Opus-4.8 via Claude-Code --- doc/integrations/otel-tracing.md | 62 +- .../scala/ox/flow/tracing/OxFlowTracing.scala | 38 +- .../scala/ox/flow/tracing/flowTracing.scala | 19 +- .../ox/telemetry/tracing/OxTracing.scala | 2 +- .../scala/ox/telemetry/tracing/tracing.scala | 2 +- otel/src/test/scala/OxTelemetryTest.scala | 536 +++++++++++++----- 6 files changed, 445 insertions(+), 214 deletions(-) diff --git a/doc/integrations/otel-tracing.md b/doc/integrations/otel-tracing.md index ea27412e..9e0fe5d1 100644 --- a/doc/integrations/otel-tracing.md +++ b/doc/integrations/otel-tracing.md @@ -45,19 +45,27 @@ object MyApp extends OxApp: ## Spans around flows -`Flow.span` evaluates a [`Flow`](../streaming/flows.md) within a span: the span is opened when the flow is run, and ended -when the flow completes, fails, or is interrupted. If the flow fails, the exception is recorded on the span, and its -status is set to `ERROR`. An interruption and control-flow exits (`boundary.break`) end the span normally, as they -aren't failures. Running the flow multiple times opens a new span each time. - -The flow and the span run in a fork, and the elements are passed to the stages placed after `span` through a buffer, of -the size given by the `BufferCapacity` in scope. Because of that boundary, the span doesn't depend on which stages -follow it: it covers the flow and everything upstream of it, until the last element is passed to the buffer. The stages -placed after it are not counted, spans created there are not its children, and their failures are not recorded on it. -To include more stages in the span, put them inside the spanned flow. - -In the example below, the `process-numbers` span is created in a fork, and thanks to the context being propagated, it's -a child of the `request` span: +`spanStages` runs each element of a [`Flow`](../streaming/flows.md) through the stages added by the given function, +within a span opened for that element. The span is opened when the element enters the stages, and ended when the last +stage finishes processing everything derived from it. Elements are processed one after another, so each one gets its own +span, and the flow returned by the function is run for each element separately: stateful stages, e.g. `grouped`, don't +carry their state across elements. + +If the stages fail, the exception is recorded on the span of the element, and its status is set to `ERROR`. An +interruption and control-flow exits (`boundary.break`) end the span normally, as they aren't failures. The function is +called within the span, so spans opened by it, and by the stages, are the span's children, and if it throws, the +exception is recorded as well. + +The stages and the span run in a fork, and the results are passed to the stages placed after `spanStages` through a +buffer, of the size given by the `BufferCapacity` in scope. Because of that boundary, the stages placed after it are not +counted, spans created there are not the span's children, and their failures are not recorded on it. This holds as long +as the stages emit at most `BufferCapacity + 1` elements for one input element, as they are processed without waiting +for downstream. Beyond that, the span also includes the time of waiting for the following stages to free up space in the +buffer. The buffers of the stages placed after it add up, so the more of them, the later it happens. To include more +stages in the span, put them inside the function. + +In the example below, the `process-numbers` spans are created in a fork, and thanks to the context being propagated, +they are children of the `request` span: ```scala mdoc:compile-only import ox.* @@ -68,7 +76,7 @@ def example(tracing: OxTracing): Unit = val result = resourceScope { useCloseableInScope(spanUnsafe(tracing)("request")): Unit supervised { - fork(Flow.fromValues(1, 2, 3).span(tracing)("process-numbers").runToList()).join() + fork(Flow.fromValues(1, 2, 3).spanStages(tracing)("process-numbers")(_.map(_ + 1)).runToList()).join() } } println(result) @@ -76,8 +84,8 @@ def example(tracing: OxTracing): Unit = Optional parameters are `spanKind`, `attributes` and `links`, as well as a `beforeClose` callback, called right before the span is closed, e.g. to set attributes or add events. The callback receives the span and the exception thrown by the -spanned flow, if any, and has to return the span. If it throws, the exception is added as suppressed to the one thrown -by the spanned flow; the span is closed in either case: +stages, if any, and has to return the span. If it throws, the exception is added as suppressed to the one thrown by the +stages; the span is closed in either case: ```scala mdoc:compile-only import ox.flow.Flow @@ -86,33 +94,21 @@ import ox.telemetry.tracing.* def withAttributes(tracing: OxTracing): List[Int] = Flow .fromValues(1, 2, 3) - .span(tracing)("process-numbers", beforeClose = s => s.span.setAttribute("failed", s.errorOpt.isDefined)) + .spanStages(tracing)("process-numbers", beforeClose = s => s.span.setAttribute("failed", s.errorOpt.isDefined))( + _.map(_ + 1).filter(_ % 2 == 0) + ) .runToList() ``` -## Spans per element - -The following stages open a span per element, each covering only what is passed to it, so the span doesn't depend on the -stages placed after it: - - - `mapSpanned` is `map`, where each call of the function is run within a span, - - `flatMapSpanned` is `flatMap`, where each flow returned by the function is run as with `span`. - -The flow returned by the function is run for each element separately, so stateful stages, e.g. `grouped`, don't carry -their state across elements. +`mapSpanned` is `map`, where each call of the function is run within a span. Unlike `spanStages`, it doesn't use a fork +and a buffer, so the span covers only the call of the function: ```scala mdoc:compile-only import ox.flow.Flow import ox.telemetry.tracing.* def perElement(tracing: OxTracing, flow: Flow[Int]): Unit = - // one function flow.mapSpanned(tracing)("increment")(_ + 1).runDrain() - - // several stages, sequentially - flow - .flatMapSpanned(tracing)("element")(x => Flow.fromValues(x).map(_ + 1).filter(_ % 2 == 0)) - .runDrain() ``` ## Manual spans diff --git a/otel/src/main/scala/ox/flow/tracing/OxFlowTracing.scala b/otel/src/main/scala/ox/flow/tracing/OxFlowTracing.scala index b0f5b5c2..d9b1fd84 100644 --- a/otel/src/main/scala/ox/flow/tracing/OxFlowTracing.scala +++ b/otel/src/main/scala/ox/flow/tracing/OxFlowTracing.scala @@ -3,29 +3,35 @@ package ox.flow.tracing import io.opentelemetry.api.common.Attributes import io.opentelemetry.api.trace.{Span, SpanContext, SpanKind} import ox.channels.BufferCapacity -import ox.flow.{Flow, FlowEmit} +import ox.flow.Flow import ox.telemetry.tracing.{OxTracing, SpanWithError} private[ox] trait OxFlowTracing: self: OxTracing => - /** Returns a flow, which when run, evaluates `flow` within a new span, which is made a child of current span Span is ended when the flow - * completes, fails or is interrupted. errors are recorded in span as events and via recordExceptions creates async boundary to isolate - * downstream stages processing time from created span + /** Runs each element of `flow` through the stages added by `f` within its own span: from the element entering the stages, to the last + * stage finishing the processing of everything derived from it. Elements are processed one after another. `f` is called within the span + * of each element, so spans opened by its body are children of that span, and if it throws, the exception is recorded on it. Stages + * created by `f`, including async ones (e.g. `buffer`), are covered by the span. The span is ended when the stages complete, fail or are + * interrupted; errors are recorded in it as events. + * + * The span is separated from the stages placed after `spanStages` by an async boundary, so their processing time is counted only as far + * as the `BufferCapacity` allows. + * * @param beforeClose * callback that will be executed right before closing a span */ - def span[T](flow: Flow[T])( + def spanStages[T, U](flow: Flow[T])( spanName: String, spanKind: SpanKind = SpanKind.INTERNAL, attributes: Attributes = Attributes.empty(), links: Seq[SpanContext] = Seq.empty, beforeClose: SpanWithError => Span = _._1 - )(using BufferCapacity): Flow[T] = - val spanned = Flow.usingEmitInline[T]: emit => - self.byNameSpan(flow.runToEmit(emit))(spanName, spanKind, attributes, links, beforeClose) - spanned.buffer() - end span + )(f: Flow[T] => Flow[U])(using BufferCapacity): Flow[U] = + flow.flatMap: t => + val spanned = Flow.usingEmitInline[U]: emit => + self.byNameSpan(f(Flow.fromValues(t)).runToEmit(emit))(spanName, spanKind, attributes, links, beforeClose) + spanned.buffer() /** As [[ox.flow.Flow.map]] but spans around execution of mapping function Span is made child of span which is current when executing a * flow @@ -39,16 +45,4 @@ private[ox] trait OxFlowTracing: )(f: T => U): Flow[U] = flow.map(t => self.byNameSpan(f(t))(spanName, spanKind, attributes, links, beforeClose)) - /** As [[ox.flow.Flow.flatMap]], but spans around execution of each flow returned by f spans opened within execution of f and of flow - * returned by f are children of created span, f is called within the span, so if it throws, the exception is recorded on it - */ - def flatMapSpanned[T, U](flow: Flow[T])( - spanName: String, - spanKind: SpanKind = SpanKind.INTERNAL, - attributes: Attributes = Attributes.empty(), - links: Seq[SpanContext] = Seq.empty, - beforeClose: SpanWithError => Span = _._1 - )(f: T => Flow[U])(using BufferCapacity): Flow[U] = - flow.flatMap(t => span(Flow.usingEmitInline[U](emit => f(t).runToEmit(emit)))(spanName, spanKind, attributes, links, beforeClose)) - end OxFlowTracing diff --git a/otel/src/main/scala/ox/flow/tracing/flowTracing.scala b/otel/src/main/scala/ox/flow/tracing/flowTracing.scala index c5830adb..dda78bbe 100644 --- a/otel/src/main/scala/ox/flow/tracing/flowTracing.scala +++ b/otel/src/main/scala/ox/flow/tracing/flowTracing.scala @@ -7,15 +7,15 @@ import ox.flow.Flow import ox.telemetry.tracing.SpanWithError extension [T](flow: Flow[T]) - /** Opens a span around the run of this flow, see [[ox.flow.tracing.OxFlowTracing.span]]. - */ - def span(oxTracing: OxFlowTracing)( + /** Runs each element through the stages added by `f` within its own span, see [[ox.flow.tracing.OxFlowTracing.spanStages]]. */ + def spanStages[U](oxTracing: OxFlowTracing)( spanName: String, spanKind: SpanKind = SpanKind.INTERNAL, attributes: Attributes = Attributes.empty(), links: Seq[SpanContext] = Seq.empty, beforeClose: SpanWithError => Span = _.span - )(using BufferCapacity): Flow[T] = oxTracing.span(flow)(spanName, spanKind, attributes, links, beforeClose) + )(f: Flow[T] => Flow[U])(using BufferCapacity): Flow[U] = + oxTracing.spanStages(flow)(spanName, spanKind, attributes, links, beforeClose)(f) /** As [[ox.flow.Flow.map]], with each call of `f` within a span, see [[ox.flow.tracing.OxFlowTracing.mapSpanned]]. */ def mapSpanned[U](oxTracing: OxFlowTracing)( @@ -25,15 +25,4 @@ extension [T](flow: Flow[T]) links: Seq[SpanContext] = Seq.empty, beforeClose: SpanWithError => Span = _.span )(f: T => U): Flow[U] = oxTracing.mapSpanned(flow)(spanName, spanKind, attributes, links, beforeClose)(f) - - /** As [[ox.flow.Flow.flatMap]], with each flow returned by `f` run within a span, see [[ox.flow.tracing.OxFlowTracing.flatMapSpanned]]. - */ - def flatMapSpanned[U](oxTracing: OxFlowTracing)( - spanName: String, - spanKind: SpanKind = SpanKind.INTERNAL, - attributes: Attributes = Attributes.empty(), - links: Seq[SpanContext] = Seq.empty, - beforeClose: SpanWithError => Span = _.span - )(f: T => Flow[U])(using BufferCapacity): Flow[U] = - oxTracing.flatMapSpanned(flow)(spanName, spanKind, attributes, links, beforeClose)(f) end extension diff --git a/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala b/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala index 35aa35a0..82c96861 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala @@ -52,7 +52,7 @@ sealed trait OxTracing extends OxFlowTracing: * doesn't receive them, as if `t` succeeded. The same applies to a [[java.util.concurrent.ExecutionException]] wrapping one of them, as * when a cancelled fork is joined. Fatal errors, e.g. [[OutOfMemoryError]], are failures and are recorded. * - * To span a [[ox.flow.Flow]], use [[OxFlowTracing.span]]. + * To span a [[ox.flow.Flow]], use [[OxFlowTracing.spanStages]]. * * @param beforeClose * Run right before the span is closed, e.g. to set attributes or add events. The second element of the argument is the failure of `t`, diff --git a/otel/src/main/scala/ox/telemetry/tracing/tracing.scala b/otel/src/main/scala/ox/telemetry/tracing/tracing.scala index dba748cf..c8e045a0 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/tracing.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/tracing.scala @@ -4,7 +4,7 @@ import io.opentelemetry.api.common.Attributes import io.opentelemetry.api.trace.{Span, SpanContext, SpanKind} import ox.telemetry.tracing.OxTracing -export ox.flow.tracing.{flatMapSpanned, mapSpanned, span} +export ox.flow.tracing.{mapSpanned, spanStages} /** Returns the current span, see [[OxTracing.currentSpanUnsafe]]. */ def currentSpanUnsafe(oxTracing: OxTracing) = oxTracing.currentSpanUnsafe() diff --git a/otel/src/test/scala/OxTelemetryTest.scala b/otel/src/test/scala/OxTelemetryTest.scala index c301a406..8d701356 100644 --- a/otel/src/test/scala/OxTelemetryTest.scala +++ b/otel/src/test/scala/OxTelemetryTest.scala @@ -1,7 +1,7 @@ package ox.telemetry import io.opentelemetry.api.common.{AttributeKey, Attributes} -import io.opentelemetry.api.trace.StatusCode +import io.opentelemetry.api.trace.{Span, SpanContext, SpanKind, StatusCode} import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter import io.opentelemetry.sdk.OpenTelemetrySdk import io.opentelemetry.sdk.resources.Resource @@ -105,12 +105,12 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: "Flow.span" when { "run normally" should { "not open a span until the flow is run" in new Fixture: - flow.span(myTracing)(s"$prefix-$Level0"): Unit + Flow.usingEmit[Int](emit => myTracing.byNameSpan(flow.runToEmit(emit))(s"$prefix-$Level0")).buffer(): Unit assert(testSpans.isEmpty) assertNesting() "open a single span for a multi-element flow" in new Fixture: - flow.span(myTracing)(s"$prefix-$Level0").runDrain() + Flow.usingEmit[Int](emit => myTracing.byNameSpan(flow.runToEmit(emit))(s"$prefix-$Level0")).buffer().runDrain() assert(spansOf(Level0).size == 1) assert(testSpans.size == 1) @@ -119,9 +119,13 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: "handle buffer one span per span() call when spans are nested" in new Fixture: supervised { - val spanned1 = flow.span(myTracing)(s"$prefix-$Level1").map(_ + 100).tap(i => currentSpanUnsafe(myTracing).addEvent(i.toString)) + val spanned1 = Flow + .usingEmit[Int](emit => myTracing.byNameSpan(flow.runToEmit(emit))(s"$prefix-$Level1")) + .buffer() + .map(_ + 100) + .tap(i => currentSpanUnsafe(myTracing).addEvent(i.toString)) - spanned1.span(myTracing)(s"$prefix-$Level0").runDrain() + Flow.usingEmit[Int](emit => myTracing.byNameSpan(spanned1.runToEmit(emit))(s"$prefix-$Level0")).buffer().runDrain() assert(spansOf(Level0).size == 1) assert(spansOf(Level1).size == 1) @@ -131,9 +135,10 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: "open one span per span() call when spans are nested" in new Fixture: supervised { val spanned1 = - (flow.span(myTracing)(s"$prefix-$Level1") ++ Flow.fromValues(4, 2)).tap(i => currentSpanUnsafe(myTracing).addEvent(i.toString)) + (Flow.usingEmit[Int](emit => myTracing.byNameSpan(flow.runToEmit(emit))(s"$prefix-$Level1")).buffer() ++ Flow.fromValues(4, 2)) + .tap(i => currentSpanUnsafe(myTracing).addEvent(i.toString)) - spanned1.span(myTracing)(s"$prefix-$Level0").runDrain() + Flow.usingEmit[Int](emit => myTracing.byNameSpan(spanned1.runToEmit(emit))(s"$prefix-$Level0")).buffer().runDrain() assert(spansOf(Level0).size == 1) assert(spansOf(Level1).size == 1) @@ -142,7 +147,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: } "open a single span when downstream uses mapPar" in new Fixture: - flow.span(myTracing)(s"$prefix-$Level0").mapPar(3)(_ + 1).runDrain() + Flow.usingEmit[Int](emit => myTracing.byNameSpan(flow.runToEmit(emit))(s"$prefix-$Level0")).buffer().mapPar(3)(_ + 1).runDrain() assert(testSpans.size == 1) @@ -156,7 +161,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: "use the current span as parent" in new Fixture: val parent = myTracing.spanUnsafe(s"$prefix-$Level0") - flow.span(myTracing)(s"$prefix-$Level1").runToList(): Unit + Flow.usingEmit[Int](emit => myTracing.byNameSpan(flow.runToEmit(emit))(s"$prefix-$Level1")).buffer().runToList(): Unit parent.close() @@ -165,16 +170,19 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: assertNesting() "open one child span per element when flatMapped under a parent span" in new Fixture: - val spanned = flow.span(myTracing)(s"$prefix-$Level1") + val spanned = Flow.usingEmit[Int](emit => myTracing.byNameSpan(flow.runToEmit(emit))(s"$prefix-$Level1")).buffer() - flow.flatMap { _ => spanned }.span(myTracing)(s"$prefix-$Level0").runToList(): Unit + Flow + .usingEmit[Int](emit => myTracing.byNameSpan(flow.flatMap { _ => spanned }.runToEmit(emit))(s"$prefix-$Level0")) + .buffer() + .runToList(): Unit assert(spansOf(Level0).size == 1) assert(spansOf(Level1).size == 3) assertNesting() "parent spans of concurrently run flows under the enclosing span" in new Fixture: - val spanned = flow.span(myTracing)(s"$prefix-$Level1") + val spanned = Flow.usingEmit[Int](emit => myTracing.byNameSpan(flow.runToEmit(emit))(s"$prefix-$Level1")).buffer() val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") @@ -189,20 +197,31 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: assertNesting() "nest spans across forks and inner supervised scopes" in new Fixture: - val spanned = flow.span(myTracing)(s"$prefix-$Level1") + val spanned = Flow.usingEmit[Int](emit => myTracing.byNameSpan(flow.runToEmit(emit))(s"$prefix-$Level1")).buffer() myTracing.byNameSpan(supervised { (1 to 10).map { case 1 => forkUser( - flow - .flatMap { el => - val innerFlow = supervised { - flow.span(myTracing)(s"$prefix-$Level1_First_Child", beforeClose = _._1.addEvent("boomed")) - } - innerFlow - } - .span(myTracing)(s"$prefix-$Level1_First") + Flow + .usingEmit[Int](emit => + myTracing.byNameSpan( + flow + .flatMap { el => + val innerFlow = supervised { + Flow + .usingEmit[Int](emit => + myTracing + .byNameSpan(flow.runToEmit(emit))(s"$prefix-$Level1_First_Child", beforeClose = _._1.addEvent("boomed")) + ) + .buffer() + } + innerFlow + } + .runToEmit(emit) + )(s"$prefix-$Level1_First") + ) + .buffer() .runToList() ) @@ -220,14 +239,20 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: "a stage fails" should { "record exception and set ERROR status when the flow fails" in new Fixture: try - flow - .tap { case 2 => throw new Exception("boom"); case _ => () } - .span(myTracing)( - s"$prefix-$Level0", - beforeClose = { case (s, thr) => - s.addEvent(thr.toString) - } + Flow + .usingEmit[Int](emit => + myTracing.byNameSpan( + flow + .tap { case 2 => throw new Exception("boom"); case _ => () } + .runToEmit(emit) + )( + s"$prefix-$Level0", + beforeClose = { case (s, thr) => + s.addEvent(thr.toString) + } + ) ) + .buffer() .runToList() catch case _ => () end try @@ -241,12 +266,18 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: assertNesting() "keep the flow span as a child of the recovery span when failure is recovered" in new Fixture: - val spanned = flow.span(myTracing)(s"$prefix-$Level1") + val spanned = Flow.usingEmit[Int](emit => myTracing.byNameSpan(flow.runToEmit(emit))(s"$prefix-$Level1")).buffer() - spanned - .map { case 2 => throw new Exception("boom"); case x => x } - .recover(_ => 5) - .span(myTracing)(s"$prefix-$Level0") + Flow + .usingEmit[Int](emit => + myTracing.byNameSpan( + spanned + .map { case 2 => throw new Exception("boom"); case x => x } + .recover(_ => 5) + .runToEmit(emit) + )(s"$prefix-$Level0") + ) + .buffer() .runToList(): Unit assert(spansOf(Level0).size == 1) @@ -254,7 +285,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: assertNesting() "end spans of concurrent flows when one of them fails" in new Fixture: - val spanned = flow.span(myTracing)(s"$prefix-$Level1") + val spanned = Flow.usingEmit[Int](emit => myTracing.byNameSpan(flow.runToEmit(emit))(s"$prefix-$Level1")).buffer() val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") @@ -277,30 +308,44 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: val level0 = spanUnsafe(myTracing)(s"$prefix-$Level0") - val spanned = flow.span(myTracing)(s"$prefix-$Level1") + val spanned = Flow.usingEmit[Int](emit => myTracing.byNameSpan(flow.runToEmit(emit))(s"$prefix-$Level1")).buffer() try supervised { (1 to 10).map { case 1 => forkUser { - flow - .flatMap { el => - { - val inner = supervisedError(em): - either: - val innerFlow = - em.pure( - flow.span(myTracing)(s"$prefix-$Level1_First_Child", beforeClose = _._1.addEvent(s"boomed at $el")).map { - case 1 => em.pureError("ops").ok(); - case x => x - } - ) - innerFlow.ok() - inner - }.toOption.get - } - .span(myTracing)(s"$prefix-$Level1_First") + Flow + .usingEmit[Int](emit => + myTracing.byNameSpan( + flow + .flatMap { el => + { + val inner = supervisedError(em): + either: + val innerFlow = + em.pure( + Flow + .usingEmit[Int](emit => + myTracing.byNameSpan(flow.runToEmit(emit))( + s"$prefix-$Level1_First_Child", + beforeClose = _._1.addEvent(s"boomed at $el") + ) + ) + .buffer() + .map { + case 1 => em.pureError("ops").ok(); + case x => x + } + ) + innerFlow.ok() + inner + }.toOption.get + } + .runToEmit(emit) + )(s"$prefix-$Level1_First") + ) + .buffer() .runToList() } case _ => forkUser(spanned.runToList()) @@ -318,30 +363,44 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: val level0 = spanUnsafe(myTracing)(s"$prefix-$Level0") - val spanned = flow.span(myTracing)(s"$prefix-$Level1") + val spanned = Flow.usingEmit[Int](emit => myTracing.byNameSpan(flow.runToEmit(emit))(s"$prefix-$Level1")).buffer() try supervised { (1 to 10).map { case 1 => forkUser { - flow - .flatMap { el => - { - val inner = supervisedError(em): - either: - val innerFlow = - em.pure( - flow.span(myTracing)(s"$prefix-$Level1_First_Child", beforeClose = _._1.addEvent(s"boomed at $el")).map { - case 1 => em.pureError("ops").ok(); - case x => x - } - ) - innerFlow.ok() - inner - }.toOption.getOrElse(Flow.empty) - } - .span(myTracing)(s"$prefix-$Level1_First") + Flow + .usingEmit[Int](emit => + myTracing.byNameSpan( + flow + .flatMap { el => + { + val inner = supervisedError(em): + either: + val innerFlow = + em.pure( + Flow + .usingEmit[Int](emit => + myTracing.byNameSpan(flow.runToEmit(emit))( + s"$prefix-$Level1_First_Child", + beforeClose = _._1.addEvent(s"boomed at $el") + ) + ) + .buffer() + .map { + case 1 => em.pureError("ops").ok(); + case x => x + } + ) + innerFlow.ok() + inner + }.toOption.getOrElse(Flow.empty) + } + .runToEmit(emit) + )(s"$prefix-$Level1_First") + ) + .buffer() .runToList() } case _ => forkUser(spanned.runToList()) @@ -359,7 +418,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: val level0 = spanUnsafe(myTracing)(s"$prefix-$Level0") - val spanned = flow.span(myTracing)(s"$prefix-$Level1") + val spanned = Flow.usingEmit[Int](emit => myTracing.byNameSpan(flow.runToEmit(emit))(s"$prefix-$Level1")).buffer() try supervisedError(em) { @@ -368,21 +427,35 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: (1 to 10).map { case 1 => forkUser { - flow - .flatMap { el => - { - supervisedError(em): - val innerFlow = - em.pure( - flow.span(myTracing)(s"$prefix-$Level1_First_Child", beforeClose = _._1.addEvent(s"boomed at $el")).map { - case 1 => em.pureError("ops").ok(); - case x => x - } - ) - innerFlow - }.toOption.get - } - .span(myTracing)(s"$prefix-$Level1_First") + Flow + .usingEmit[Int](emit => + myTracing.byNameSpan( + flow + .flatMap { el => + { + supervisedError(em): + val innerFlow = + em.pure( + Flow + .usingEmit[Int](emit => + myTracing.byNameSpan(flow.runToEmit(emit))( + s"$prefix-$Level1_First_Child", + beforeClose = _._1.addEvent(s"boomed at $el") + ) + ) + .buffer() + .map { + case 1 => em.pureError("ops").ok(); + case x => x + } + ) + innerFlow + }.toOption.get + } + .runToEmit(emit) + )(s"$prefix-$Level1_First") + ) + .buffer() .runToList() } case _ => forkUser(spanned.runToList()) @@ -400,8 +473,14 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: "make a span after buffer a sibling of the whole-run span" in new Fixture: val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") - flow - .span(myTracing)(s"$prefix-$Level1") + Flow + .usingEmit[Int](emit => + myTracing.byNameSpan( + flow + .runToEmit(emit) + )(s"$prefix-$Level1") + ) + .buffer() .buffer() .map(x => myTracing.byNameSpan(x)(s"$prefix-$Level1_Stage")) .runToList(): Unit @@ -428,7 +507,8 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: def readLines(file: Int): Flow[String] = Flow.fromValues(s"line of $file").tap(_ => sleep(b)) - def streamLinesFlow(file: Int) = readLines(file).span(myTracing)(s"$prefix-$Level1_First") + def streamLinesFlow(file: Int) = + Flow.usingEmit[String](emit => myTracing.byNameSpan(readLines(file).runToEmit(emit))(s"$prefix-$Level1_First")).buffer() (for file <- Flow.fromIterable(filesToDownload).mapSpanned(myTracing)(s"$prefix-$Level1")(download) @@ -449,8 +529,12 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: "count only its own flow in a flat for comprehension with spannedAsync" in new Fixture: (for x <- Flow.fromValues(1) - y <- Flow.fromValues(x).tap(_ => sleep(a)).span(myTracing)(s"$prefix-$Level1") - z <- Flow.fromValues(y).tap(_ => sleep(b)).span(myTracing)(s"$prefix-$Level1_First") + y <- Flow + .usingEmit[Int](emit => myTracing.byNameSpan(Flow.fromValues(x).tap(_ => sleep(a)).runToEmit(emit))(s"$prefix-$Level1")) + .buffer() + z <- Flow + .usingEmit[Int](emit => myTracing.byNameSpan(Flow.fromValues(y).tap(_ => sleep(b)).runToEmit(emit))(s"$prefix-$Level1_First")) + .buffer() yield z) .tap(_ => sleep(c)) .runToList(): Unit @@ -462,34 +546,23 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: assertAbout(durMs(z), bMs) assertAbout(startOffsetMs(y, z), aMs) - "nest the durations of a spannedAsync in a flatMapSpanned" in new Fixture: - Flow - .fromValues(1) - .flatMapSpanned(myTracing)(s"$prefix-$Level1")(x => - for - y <- Flow.fromValues(x).tap(_ => sleep(a)) - z <- Flow.fromValues(y).tap(_ => sleep(b)).span(myTracing)(s"$prefix-$Level1_First") - yield z - ) - .tap(_ => sleep(c)) - .runToList(): Unit + "nest the spans and durations of a spanStages inside a spanStages" in new Fixture: - val parent = spansOf(Level1).head - val child = spansOf(Level1_First).head - assert(child.getParentSpanId == parent.getSpanId) - assertAbout(durMs(parent), aMs + bMs) - assertAbout(durMs(child), bMs) - assertAbout(startOffsetMs(parent, child), aMs) - assert(child.getEndEpochNanos <= parent.getEndEpochNanos) - "nest the spans and durations of a flatMapSpanned inside a flatMapSpanned" in new Fixture: + flow + .spanStages(myTracing)(s"outerSpan1")( + _.map(...).filter(...).spanStages(myTracing)(s"inner")(_.map(...).buffer().retry(...).map(...)) + ) + .spanStages(myTracing(s"outerSpan2"))(_.map(...)) + .tap(_ => sleep(c)) //not included in spans + .runToList(): Unit + + + Flow .fromValues(1) - .flatMapSpanned(myTracing)(s"$prefix-$Level1")(x => - Flow - .fromValues(x) - .tap(_ => sleep(a)) - .flatMapSpanned(myTracing)(s"$prefix-$Level1_First")(y => Flow.fromValues(y).tap(_ => sleep(b))) + .spanStages(myTracing)(s"$prefix-$Level1")( + _.tap(_ => sleep(a)).spanStages(myTracing)(s"$prefix-$Level1_First")(_.tap(_ => sleep(b))) ) .tap(_ => sleep(c)) .runToList(): Unit @@ -502,11 +575,11 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: assertAbout(startOffsetMs(outer, inner), aMs) assert(inner.getEndEpochNanos <= outer.getEndEpochNanos) - "make chained flatMapSpanned spans siblings, each covering only its own flow" in new Fixture: + "make chained spanStages spans siblings, each covering only its own flow" in new Fixture: Flow .fromValues(1) - .flatMapSpanned(myTracing)(s"$prefix-$Level1")(x => Flow.fromValues(x).tap(_ => sleep(a))) - .flatMapSpanned(myTracing)(s"$prefix-$Level1_First")(y => Flow.fromValues(y).tap(_ => sleep(b))) + .spanStages(myTracing)(s"$prefix-$Level1")(_.tap(_ => sleep(a))) + .spanStages(myTracing)(s"$prefix-$Level1_First")(_.tap(_ => sleep(b))) .tap(_ => sleep(c)) .runToList(): Unit @@ -545,17 +618,17 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: assert(spansOf(Level0).count(_.getStatus.getStatusCode == StatusCode.ERROR) == 1) } - "flatMapSpanned" should { + "spanStages per element" should { def durationMs(span: io.opentelemetry.sdk.trace.data.SpanData) = (span.getEndEpochNanos - span.getStartEpochNanos) / 1000000 "open spans created in the body of the function, before it returns the flow, as children of the span" in new Fixture: val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") flow - .flatMapSpanned(myTracing)(s"$prefix-$Level1_First")(x => + .spanStages(myTracing)(s"$prefix-$Level1_First")(_.flatMap(x => myTracing.byNameSpan(())(s"$prefix-$Level1_First_Child") Flow.fromValues(x).map(y => myTracing.byNameSpan(y)(s"$prefix-$Level1_Stage")) - ) + )) .runToList(): Unit parent.close() @@ -573,10 +646,10 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: val inFlow = java.util.concurrent.ConcurrentLinkedQueue[Thread]() flow - .flatMapSpanned(myTracing)(s"$prefix-$Level1_First")(x => + .spanStages(myTracing)(s"$prefix-$Level1_First")(_.flatMap(x => inF.add(Thread.currentThread()): Unit Flow.fromValues(x).tap(_ => inFlow.add(Thread.currentThread()): Unit) - ) + )) .runToList(): Unit assert(inF.size == 3 && inF.toArray.forall(_ ne caller)) @@ -585,7 +658,9 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: "record the exception thrown by the function before it returns the flow on the span of the element" in new Fixture: val thrown = intercept[Exception]: flow - .flatMapSpanned(myTracing)(s"$prefix-$Level1_First")(x => if x == 2 then throw new Exception("boom") else Flow.fromValues(x)) + .spanStages(myTracing)(s"$prefix-$Level1_First")( + _.flatMap(x => if x == 2 then throw new Exception("boom") else Flow.fromValues(x)) + ) .runToList(): Unit // it reaches the caller through the fork, wrapped @@ -601,14 +676,14 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: "emit the same elements as flatMap" in new Fixture: val f = (x: Int) => Flow.fromValues(x, x * 10) - assert(flow.flatMapSpanned(myTracing)(s"$prefix-$Level1_First")(f).runToList() == flow.flatMap(f).runToList()) + assert(flow.spanStages(myTracing)(s"$prefix-$Level1_First")(_.flatMap(f)).runToList() == flow.flatMap(f).runToList()) "open a span per element, with the spans created in the inner flows as children and the following ones as siblings" in new Fixture: val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") flow - .flatMapSpanned(myTracing)(s"$prefix-$Level1_First")(x => - Flow.fromValues(1 to x*).map(y => myTracing.byNameSpan(y)(s"$prefix-$Level1_First_Child")) + .spanStages(myTracing)(s"$prefix-$Level1_First")( + _.mapConcat(x => 1 to x).map(y => myTracing.byNameSpan(y)(s"$prefix-$Level1_First_Child")) ) .map(x => myTracing.byNameSpan(x)(s"$prefix-$Level1")) .runToList(): Unit @@ -622,7 +697,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: assertNesting() "emit the elements of the inner flows in order" in new Fixture: - val result = flow.flatMapSpanned(myTracing)(s"$prefix-$Level0")(x => Flow.fromValues(x, x * 10)).runToList() + val result = flow.spanStages(myTracing)(s"$prefix-$Level0")(_.flatMap(x => Flow.fromValues(x, x * 10))).runToList() assert(result == List(1, 10, 2, 20, 3, 30)) assert(spansOf(Level0).size == 3) @@ -630,7 +705,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: "not count the time of the following stages" in new Fixture: val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") - flow.flatMapSpanned(myTracing)(s"$prefix-$Level1")(x => Flow.fromValues(x)).tap(_ => sleep(200.millis)).runToList(): Unit + flow.spanStages(myTracing)(s"$prefix-$Level1")(identity).tap(_ => sleep(200.millis)).runToList(): Unit parent.close() @@ -638,10 +713,93 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: assert(spansOf(Level1).size == 3) assert(spansOf(Level1).forall(durationMs(_) < 150)) + "count the time of the following stages, except for the last element, when the buffer is 0" in new Fixture: + val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") + + given BufferCapacity = BufferCapacity(0) + flow.spanStages(myTracing)(s"$prefix-$Level1")(_.mapConcat(x => List(x, x * 10))).tap(_ => sleep(200.millis)).runToList(): Unit + + parent.close() + + // the first element is handed over only once downstream is done with the previous one, the second one is handed over right away + assert(spansOf(Level1).size == 3) + assert(spansOf(Level1).forall(s => durationMs(s) >= 150 && durationMs(s) < 350)) + + "not count the time of the following stages when the inner flow has buffer + 1 elements" in new Fixture: + val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") + + given BufferCapacity = BufferCapacity(1) + flow.spanStages(myTracing)(s"$prefix-$Level1")(_.mapConcat(x => List(x, x * 10))).tap(_ => sleep(200.millis)).runToList(): Unit + + parent.close() + + assert(spansOf(Level1).size == 3) + assert(spansOf(Level1).forall(durationMs(_) < 150)) + + "count the time of the following stages when the inner flow has more than buffer + 1 elements" in new Fixture: + val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") + + given BufferCapacity = BufferCapacity(1) + flow + .spanStages(myTracing)(s"$prefix-$Level1")(_.mapConcat(x => List(x, x * 10, x * 100))) + .tap(_ => sleep(200.millis)) + .runToList(): Unit + + parent.close() + + assert(spansOf(Level1).size == 3) + assert(spansOf(Level1).forall(s => durationMs(s) >= 150 && durationMs(s) < 350)) + + "stack the buffers of nested spanStages, so the outer buffer isolates the inner span" in new Fixture: + val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") + + given BufferCapacity = BufferCapacity(1) + flow + .spanStages(myTracing)(s"$prefix-$Level1")(f => + given BufferCapacity = BufferCapacity(0) + f.spanStages(myTracing)(s"$prefix-$Level1_First")(_.mapConcat(y => List(y, y * 10, y * 100))) + ) + .tap(_ => sleep(200.millis)) + .runToList(): Unit + + parent.close() + + assert(spansOf(Level1_First).size == 3) + assert(spansOf(Level1_First).forall(durationMs(_) < 150)) + + "count the time of the following stages in the inner span when the outer buffer is 0 too" in new Fixture: + val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") + + extension [T](flow: Flow[T]) def traced[U](name: String)(f: Flow[T] => Flow[U]) = flow.spanStages(myTracing)(name)(f) + + given BufferCapacity = BufferCapacity(0) + flow + .spanStages(myTracing)(s"$prefix-$Level1"): + _.tap(println).spanStages(myTracing)(s"$prefix-$Level1_First"): + _.mapConcat(y => List(y, y * 10, y * 100)) + .tap(_ => sleep(200.millis)) + .runToList(): Unit + + parent.close() + + assert(spansOf(Level1_First).size == 3) + assert(spansOf(Level1_First).forall(s => durationMs(s) >= 150 && durationMs(s) < 350)) + + "not count the time of the following stages when the inner flow fits in the buffer" in new Fixture: + val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") + + given BufferCapacity = BufferCapacity(2) + flow.spanStages(myTracing)(s"$prefix-$Level1")(_.mapConcat(x => List(x, x * 10))).tap(_ => sleep(200.millis)).runToList(): Unit + + parent.close() + + assert(spansOf(Level1).size == 3) + assert(spansOf(Level1).forall(durationMs(_) < 150)) + "not mark the spans as failed when a following stage fails" in new Fixture: try flow - .flatMapSpanned(myTracing)(s"$prefix-$Level0")(x => Flow.fromValues(x)) + .spanStages(myTracing)(s"$prefix-$Level0")(identity) .map { case 2 => throw new Exception("boom"); case x => x } .runToList(): Unit catch case _ => () @@ -653,9 +811,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: "mark only the span of the inner flow that fails" in new Fixture: try flow - .flatMapSpanned(myTracing)(s"$prefix-$Level0")(x => - Flow.fromValues(x).map { case 2 => throw new Exception("boom"); case y => y } - ) + .spanStages(myTracing)(s"$prefix-$Level0")(_.map { case 2 => throw new Exception("boom"); case y => y }) .runToList(): Unit catch case _ => () @@ -664,7 +820,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: assert(spansOf(Level0).count(_.getStatus.getStatusCode == StatusCode.ERROR) == 1) "work with infinite inner flows" in new Fixture: - val result = flow.flatMapSpanned(myTracing)(s"$prefix-$Level0")(_ => Flow.iterate(1)(_ + 1)).take(5).runToList() + val result = flow.spanStages(myTracing)(s"$prefix-$Level0")(_.flatMap(_ => Flow.iterate(1)(_ + 1))).take(5).runToList() assert(result == List(1, 2, 3, 4, 5)) assert(spansOf(Level0).size == 1) @@ -675,12 +831,14 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: val events = new java.util.concurrent.ConcurrentLinkedQueue[String]() flow - .flatMapSpanned(myTracing)(s"$prefix-$Level0")(x => - Flow.usingEmit[Int] { emit => - events.add(s"start $x") - emit(x) - events.add(s"end $x") - } + .spanStages(myTracing)(s"$prefix-$Level0")( + _.flatMap(x => + Flow.usingEmit[Int] { emit => + events.add(s"start $x") + emit(x) + events.add(s"end $x") + } + ) ) .tap(x => events.add(s"got $x")) .runToList(): Unit @@ -693,6 +851,92 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: } } + "spanStages" should { + def durationMs(span: io.opentelemetry.sdk.trace.data.SpanData) = (span.getEndEpochNanos - span.getStartEpochNanos) / 1000000 + + "open a span per element, covering the stages on both sides of a buffer created by the function" in new Fixture: + flow + .spanStages(myTracing)(s"$prefix-$Level1")(_.tap(_ => sleep(200.millis)).buffer().tap(_ => sleep(300.millis))) + .tap(_ => sleep(600.millis)) + .runToList(): Unit + + val spans = spansOf(Level1).sortBy(_.getStartEpochNanos) + assert(spans.size == 3) + spans.foreach(s => assertAbout(durMs(s), 500)) + spans.foreach(s => assert(!spans.exists(_.getSpanId == s.getParentSpanId))) + + "open spans created in the body of the function as children of the span of the element" in new Fixture: + flow + .spanStages(myTracing)(s"$prefix-$Level1")(f => + myTracing.byNameSpan(())(s"$prefix-$Level1_First") + f + ) + .runToList(): Unit + + val parents = spansOf(Level1).map(_.getSpanId).toSet + assert(parents.size == 3) + assert(spansOf(Level1_First).size == 3 && spansOf(Level1_First).forall(c => parents.contains(c.getParentSpanId))) + + "open spans created by the stages as children of the span of the element" in new Fixture: + flow + .spanStages(myTracing)(s"$prefix-$Level1")(_.map(x => myTracing.byNameSpan(x)(s"$prefix-$Level1_First")).buffer()) + .runToList(): Unit + + val parents = spansOf(Level1).map(_.getSpanId).toSet + assert(spansOf(Level1_First).size == 3 && spansOf(Level1_First).forall(c => parents.contains(c.getParentSpanId))) + + "record the exception thrown by the function on the span of the element" in new Fixture: + val thrown = intercept[Exception]: + flow.spanStages(myTracing)(s"$prefix-$Level1")(_ => throw new Exception("boom")).runToList(): Unit + + assert(thrown.getMessage.contains("boom")) + val span = spansOf(Level1).head + assert(span.getStatus.getStatusCode == StatusCode.ERROR) + assert(span.getStatus.getDescription == "boom") + assert(span.getEvents.asScala.exists(_.getName == "exception")) + + "record the exception thrown by a stage of the function on the span of the element only" in new Fixture: + intercept[Exception]: + flow + .spanStages(myTracing)(s"$prefix-$Level1")(_.map { case 2 => throw new Exception("boom"); case x => x }) + .runToList(): Unit + + // the flow is stopped by the second element, so there is no third span + val spans = spansOf(Level1).sortBy(_.getStartEpochNanos) + assert(spans.size == 2) + assert(spans.head.getStatus.getStatusCode == StatusCode.UNSET) + assert(spans(1).getStatus.getStatusCode == StatusCode.ERROR) + assert(spans(1).getStatus.getDescription == "boom") + + "record the exception thrown by a stage after a buffer created by the function on the span of the element" in new Fixture: + intercept[Exception]: + flow + .spanStages(myTracing)(s"$prefix-$Level1")(_.buffer().map { case 2 => throw new Exception("boom"); case x => x }) + .runToList(): Unit + + val spans = spansOf(Level1).sortBy(_.getStartEpochNanos) + assert(spans.size == 2) + assert(spans.head.getStatus.getStatusCode == StatusCode.UNSET) + assert(spans(1).getStatus.getStatusCode == StatusCode.ERROR) + assert(spans(1).getStatus.getDescription == "boom") + + "not record the exception thrown by a stage after the function on the span" in new Fixture: + intercept[Exception]: + flow + .spanStages(myTracing)(s"$prefix-$Level1")(_.map(identity)) + .map { case 2 => throw new Exception("boom"); case x => x } + .runToList(): Unit + + assert(spansOf(Level1).nonEmpty) + assert(spansOf(Level1).forall(_.getStatus.getStatusCode == StatusCode.UNSET)) + + "not count the time of the following stages, as the element is handed over once" in new Fixture: + flow.spanStages(myTracing)(s"$prefix-$Level1")(_.map(_ + 1)).tap(_ => sleep(200.millis)).runToList(): Unit + + assert(spansOf(Level1).size == 3) + assert(spansOf(Level1).forall(durationMs(_) < 150)) + } + "cancelled" should { "end span when enclosing supervised scope fails before stream completion" in new Fixture: val semaphore = Semaphore(0) @@ -704,7 +948,11 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: throw new Exception("oops") } forkUser { - flow.span(myTracing)(s"$prefix-$Level0").tap { case 1 => semaphore.release(); case _ => sleep(100.seconds) }.runToList() + Flow + .usingEmit[Int](emit => myTracing.byNameSpan(flow.runToEmit(emit))(s"$prefix-$Level0")) + .buffer() + .tap { case 1 => semaphore.release(); case _ => sleep(100.seconds) } + .runToList() } } catch case err => () @@ -719,7 +967,11 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: supervised { val forkWithSpan = forkCancellable { - flow.span(myTracing)(s"$prefix-$Level0").tap { case 1 => semaphore.release(); case _ => sleep(100.seconds) }.runToList() + Flow + .usingEmit[Int](emit => myTracing.byNameSpan(flow.runToEmit(emit))(s"$prefix-$Level0")) + .buffer() + .tap { case 1 => semaphore.release(); case _ => sleep(100.seconds) } + .runToList() } semaphore.acquire() forkWithSpan.cancelNow() From 31b17155d2720302ddb6989c7c333e1ded4b6835 Mon Sep 17 00:00:00 2001 From: Pawel Sadlo Date: Fri, 23 Oct 2026 09:16:19 +0200 Subject: [PATCH 36/38] Flow tracing: take a Tracer instead of OxTracing, add spanEachDownstream OxTracing only wrapped a Tracer, so the flow operators (spanStages, spanEachDownstream, mapSpanned) and spanUnsafe now take the Tracer as the first argument. spanEachDownstream opens a span for each element and ends it once the stages placed after it are done, or at the first asynchronous boundary. Docs and scaladoc are rewritten around the operators. The unfinished spanStages test sketch is commented out, as it does not compile. Co-authored-by: Claude-Opus-4.8 via Claude-Code --- doc/integrations/otel-tracing.md | 145 +++++++------ .../scala/ox/flow/tracing/OxFlowTracing.scala | 48 ----- .../scala/ox/flow/tracing/flowTracing.scala | 69 ++++++- .../ox/telemetry/tracing/OxTracing.scala | 114 ----------- .../scala/ox/telemetry/tracing/tracing.scala | 90 ++++++++- otel/src/test/scala/OxTelemetryTest.scala | 191 +++++++++++------- 6 files changed, 346 insertions(+), 311 deletions(-) delete mode 100644 otel/src/main/scala/ox/flow/tracing/OxFlowTracing.scala delete mode 100644 otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala diff --git a/doc/integrations/otel-tracing.md b/doc/integrations/otel-tracing.md index 9e0fe5d1..ce32b6c5 100644 --- a/doc/integrations/otel-tracing.md +++ b/doc/integrations/otel-tracing.md @@ -13,102 +13,122 @@ Ox provides helpers for creating [OpenTelemetry](https://opentelemetry.io) spans operators, including [Flows](../streaming/flows.md). An opened span becomes a child of the current span, and becomes the current span itself, until it's closed. -Spans are created using an `OxTracing` instance, wrapping an OpenTelemetry `Tracer`: +Spans are created using an OpenTelemetry `Tracer`, passed as the first argument: ```scala mdoc:compile-only import io.opentelemetry.api.trace.Tracer -import ox.telemetry.tracing.OxTracing +import ox.telemetry.tracing.* -def tracing(tracer: Tracer): OxTracing = OxTracing(tracer) +def example(tracer: Tracer): Unit = + val opened = spanUnsafe(tracer)("my-span") + opened.close() ``` ## Propagating the context The current span is kept in the OpenTelemetry `Context`, which by default isn't propagated to new virtual threads, e.g. -forks created in [`supervised`](../structured-concurrency/fork-join.md) scopes. `OxTracing` therefore **requires** the -`PropagatingVirtualThreadFactory` from [otel-context](otel-context.md) to be used; without it, spans created in forks -won't be children of the span active when the fork was created. The factory can be set for the whole app when using -[`OxApp`](../utils/oxapp.md), or manually through `setOxThreadFactory`, before the thread factory is first used (that -is, before any scope is started): - -```scala mdoc:compile-only -import ox.* -import ox.otel.context.PropagatingVirtualThreadFactory +forks created in [`supervised`](../structured-concurrency/fork-join.md) scopes. Ox tracing therefore **requires** the +`PropagatingVirtualThreadFactory` from [otel-context](otel-context.md) to be used, which describes how to set it up; +without it, spans created in forks won't be children of the span active when the fork was created. -object MyApp extends OxApp: - override def settings: OxApp.Settings = OxApp.Settings.Default.copy( - threadFactory = Some(PropagatingVirtualThreadFactory()) - ) +## Spans around flows - def run(args: Vector[String])(using Ox): ExitCode = ExitCode.Success -``` +[Flows](../streaming/flows.md) can open a span for each element. The span is a child of the current span, and if the code +within it throws, the exception is recorded on the span, and its status is set to `ERROR`. -## Spans around flows +| Operator | The span of an element covers | +|---------------------------------------|--------------------------------------------------------------------------------------| +| `spanEachDownstream(tracer)("name")` | running the element through all the following stages, up to the first async boundary | +| `spanStages(tracer)("name")(stages)` | running the element through `stages`, isolated from the following stages by a buffer | +| `mapSpanned(tracer)("name")(f)` | the call of `f` | -`spanStages` runs each element of a [`Flow`](../streaming/flows.md) through the stages added by the given function, -within a span opened for that element. The span is opened when the element enters the stages, and ended when the last -stage finishes processing everything derived from it. Elements are processed one after another, so each one gets its own -span, and the flow returned by the function is run for each element separately: stateful stages, e.g. `grouped`, don't -carry their state across elements. +`spanEachDownstream` covers the stages placed after it, up to the first asynchronous boundary, e.g. `buffer`. As there is +no buffer, the synchronous stages are called from within the span, so their duration is counted: -If the stages fail, the exception is recorded on the span of the element, and its status is set to `ERROR`. An -interruption and control-flow exits (`boundary.break`) end the span normally, as they aren't failures. The function is -called within the span, so spans opened by it, and by the stages, are the span's children, and if it throws, the -exception is recorded as well. +```scala mdoc:compile-only +import io.opentelemetry.api.trace.Tracer +import ox.flow.Flow +import ox.telemetry.tracing.* -The stages and the span run in a fork, and the results are passed to the stages placed after `spanStages` through a -buffer, of the size given by the `BufferCapacity` in scope. Because of that boundary, the stages placed after it are not -counted, spans created there are not the span's children, and their failures are not recorded on it. This holds as long -as the stages emit at most `BufferCapacity + 1` elements for one input element, as they are processed without waiting -for downstream. Beyond that, the span also includes the time of waiting for the following stages to free up space in the -buffer. The buffers of the stages placed after it add up, so the more of them, the later it happens. To include more -stages in the span, put them inside the function. +def downstream(tracer: Tracer, flow: Flow[Int]): List[Int] = + flow + .spanEachDownstream(tracer)("element") + .map(_ + 1) // covered by the span + .buffer() // the span ends here + .map(_ * 2) // not covered + .runToList() +``` -In the example below, the `process-numbers` spans are created in a fork, and thanks to the context being propagated, -they are children of the `request` span: +The span covers also the stages placed after a `flatMap` that the span was created in, as it passes the elements on from +within the span: ```scala mdoc:compile-only -import ox.* +import io.opentelemetry.api.trace.Tracer import ox.flow.Flow import ox.telemetry.tracing.* -def example(tracing: OxTracing): Unit = - val result = resourceScope { - useCloseableInScope(spanUnsafe(tracing)("request")): Unit - supervised { - fork(Flow.fromValues(1, 2, 3).spanStages(tracing)("process-numbers")(_.map(_ + 1)).runToList()).join() - } - } - println(result) +def inFlatMap(tracer: Tracer, flow: Flow[Int]): List[Int] = + flow + .flatMap(x => Flow.fromValues(x, x * 10).spanEachDownstream(tracer)("element").map(_ + 1)) // covered by the span + .map(_ * 2) // also covered + .runToList() ``` -Optional parameters are `spanKind`, `attributes` and `links`, as well as a `beforeClose` callback, called right before -the span is closed, e.g. to set attributes or add events. The callback receives the span and the exception thrown by the -stages, if any, and has to return the span. If it throws, the exception is added as suppressed to the one thrown by the -stages; the span is closed in either case: +`spanStages` runs each element through the stages added by the given function. The spans can be nested, and the stages +placed after `spanStages` are not part of the span: ```scala mdoc:compile-only +import io.opentelemetry.api.trace.Tracer import ox.flow.Flow import ox.telemetry.tracing.* -def withAttributes(tracing: OxTracing): List[Int] = - Flow - .fromValues(1, 2, 3) - .spanStages(tracing)("process-numbers", beforeClose = s => s.span.setAttribute("failed", s.errorOpt.isDefined))( - _.map(_ + 1).filter(_ % 2 == 0) +def stages(tracer: Tracer, flow: Flow[Int]): List[Int] = + flow + .spanStages(tracer)("outer")( + _.map(_ + 1) + .spanStages(tracer)("inner")(_.filter(_ % 2 == 0)) // a child of "outer" ) + .map(_ * 2) // not covered by any span + .runToList() +``` + +The stages and the span run in a fork, and the results are passed on through a buffer, of the size given by the +`BufferCapacity` in scope. The following stages are not counted as long as the stages emit at most `BufferCapacity + 1` +elements for one input element; beyond that, the span also includes the time of waiting for free space in the buffer. + +`mapSpanned` is `map`, where each call of the function is run within a span: + +```scala mdoc:compile-only +import io.opentelemetry.api.trace.Tracer +import ox.flow.Flow +import ox.telemetry.tracing.* + +def mapped(tracer: Tracer, flow: Flow[Int]): List[Int] = + flow + .mapSpanned(tracer)("increment")(_ + 1) .runToList() ``` -`mapSpanned` is `map`, where each call of the function is run within a span. Unlike `spanStages`, it doesn't use a fork -and a buffer, so the span covers only the call of the function: +All the operators accept optional parameters, which reflect the OpenTelemetry span builder: `spanKind`, `attributes` and +`links`. + +They also accept a `beforeClose` callback. It's called right before the span is closed, and +receives the span, and the failure of the code covered by the span, if any. It can be used to modify the span right before its closed. ```scala mdoc:compile-only +import io.opentelemetry.api.trace.Tracer import ox.flow.Flow import ox.telemetry.tracing.* -def perElement(tracing: OxTracing, flow: Flow[Int]): Unit = - flow.mapSpanned(tracing)("increment")(_ + 1).runDrain() +def withCallback(tracer: Tracer, flow: Flow[Int]): List[Int] = + flow + .spanStages(tracer)( + "process", + beforeClose = s => + s.errorOpt.foreach(e => s.span.setAttribute("error.type", e.getClass.getSimpleName)) + s.span.addEvent("done") + )(_.map(_ + 1)) + .runToList() ``` ## Manual spans @@ -119,13 +139,14 @@ span unrestored; use it with [`useCloseableInScope`](../utils/resources.md), so `currentSpanUnsafe` returns the current span, e.g. to add attributes: ```scala mdoc:compile-only +import io.opentelemetry.api.trace.Tracer import ox.* import ox.telemetry.tracing.* -def manual(tracing: OxTracing): Unit = +def manual(tracer: Tracer): Unit = resourceScope { - val opened = useCloseableInScope(spanUnsafe(tracing)("manual-span")) + val opened = useCloseableInScope(spanUnsafe(tracer)("manual-span")) opened.span.setAttribute("key", "value") - currentSpanUnsafe(tracing).addEvent("working"): Unit + currentSpanUnsafe().addEvent("working"): Unit } ``` diff --git a/otel/src/main/scala/ox/flow/tracing/OxFlowTracing.scala b/otel/src/main/scala/ox/flow/tracing/OxFlowTracing.scala deleted file mode 100644 index d9b1fd84..00000000 --- a/otel/src/main/scala/ox/flow/tracing/OxFlowTracing.scala +++ /dev/null @@ -1,48 +0,0 @@ -package ox.flow.tracing - -import io.opentelemetry.api.common.Attributes -import io.opentelemetry.api.trace.{Span, SpanContext, SpanKind} -import ox.channels.BufferCapacity -import ox.flow.Flow -import ox.telemetry.tracing.{OxTracing, SpanWithError} - -private[ox] trait OxFlowTracing: - self: OxTracing => - - /** Runs each element of `flow` through the stages added by `f` within its own span: from the element entering the stages, to the last - * stage finishing the processing of everything derived from it. Elements are processed one after another. `f` is called within the span - * of each element, so spans opened by its body are children of that span, and if it throws, the exception is recorded on it. Stages - * created by `f`, including async ones (e.g. `buffer`), are covered by the span. The span is ended when the stages complete, fail or are - * interrupted; errors are recorded in it as events. - * - * The span is separated from the stages placed after `spanStages` by an async boundary, so their processing time is counted only as far - * as the `BufferCapacity` allows. - * - * @param beforeClose - * callback that will be executed right before closing a span - */ - def spanStages[T, U](flow: Flow[T])( - spanName: String, - spanKind: SpanKind = SpanKind.INTERNAL, - attributes: Attributes = Attributes.empty(), - links: Seq[SpanContext] = Seq.empty, - beforeClose: SpanWithError => Span = _._1 - )(f: Flow[T] => Flow[U])(using BufferCapacity): Flow[U] = - flow.flatMap: t => - val spanned = Flow.usingEmitInline[U]: emit => - self.byNameSpan(f(Flow.fromValues(t)).runToEmit(emit))(spanName, spanKind, attributes, links, beforeClose) - spanned.buffer() - - /** As [[ox.flow.Flow.map]] but spans around execution of mapping function Span is made child of span which is current when executing a - * flow - */ - def mapSpanned[T, U](flow: Flow[T])( - spanName: String, - spanKind: SpanKind = SpanKind.INTERNAL, - attributes: Attributes = Attributes.empty(), - links: Seq[SpanContext] = Seq.empty, - beforeClose: SpanWithError => Span = _._1 - )(f: T => U): Flow[U] = - flow.map(t => self.byNameSpan(f(t))(spanName, spanKind, attributes, links, beforeClose)) - -end OxFlowTracing diff --git a/otel/src/main/scala/ox/flow/tracing/flowTracing.scala b/otel/src/main/scala/ox/flow/tracing/flowTracing.scala index dda78bbe..f6d11e9e 100644 --- a/otel/src/main/scala/ox/flow/tracing/flowTracing.scala +++ b/otel/src/main/scala/ox/flow/tracing/flowTracing.scala @@ -1,28 +1,79 @@ package ox.flow.tracing import io.opentelemetry.api.common.Attributes -import io.opentelemetry.api.trace.{Span, SpanContext, SpanKind} +import io.opentelemetry.api.trace.{Span, SpanContext, SpanKind, Tracer} import ox.channels.BufferCapacity -import ox.flow.Flow -import ox.telemetry.tracing.SpanWithError +import ox.flow.{Flow, FlowEmit} +import ox.telemetry.tracing.{SpanWithError, byNameSpan} extension [T](flow: Flow[T]) - /** Runs each element through the stages added by `f` within its own span, see [[ox.flow.tracing.OxFlowTracing.spanStages]]. */ - def spanStages[U](oxTracing: OxFlowTracing)( + /** Runs each element of this flow through the stages added by `f`, within its own span. The span is made a child of the current span, and + * becomes the current one until it ends, when the previously current span is restored. Elements are processed one after another. + * + * The span is opened when the element enters the stages, and ends when the last stage created by `f` is done processing everything + * derived from the element. `f` is called within the span, so spans opened by it and by the stages are children of the span. + * + * The span is separated from the following stages by an async boundary, so their processing time is not counted, as long as the stages + * emit at most `BufferCapacity + 1` elements for one element of this flow. Beyond that, the span also includes the time of waiting for + * the following stages to free up space in the buffer. + * + * If `f` or any of the stages throws, the span's status is set to `ERROR` and an appropriate event is added to it. + * + * @param beforeClose + * callback executed right before closing the span, which receives the span and the failure of the stages, if any. If it throws, the + * exception is added as suppressed to the one thrown by the stages, or thrown if the stages succeeded. + */ + def spanStages[U](tracer: Tracer)( spanName: String, spanKind: SpanKind = SpanKind.INTERNAL, attributes: Attributes = Attributes.empty(), links: Seq[SpanContext] = Seq.empty, beforeClose: SpanWithError => Span = _.span )(f: Flow[T] => Flow[U])(using BufferCapacity): Flow[U] = - oxTracing.spanStages(flow)(spanName, spanKind, attributes, links, beforeClose)(f) + flow.flatMap: t => + val spanned = Flow.usingEmitInline[U]: emit => + byNameSpan(tracer)(f(Flow.fromValues(t)).runToEmit(emit))(spanName, spanKind, attributes, links, beforeClose) + spanned.buffer() - /** As [[ox.flow.Flow.map]], with each call of `f` within a span, see [[ox.flow.tracing.OxFlowTracing.mapSpanned]]. */ - def mapSpanned[U](oxTracing: OxFlowTracing)( + /** Returns a flow, which for each element of this flow opens a new span. The span is made a child of the current span, and becomes the + * current one until it ends, when the previously current span is restored. + * + * The span ends when the downstream stages are done processing the element, at the first asynchronous boundary (e.g. `buffer`), or if + * any of the following stages throws. + * + * If a following stage throws, the span's status is set to `ERROR` and an appropriate event is added to it. + * + * @param beforeClose + * callback executed right before closing the span, which receives the span and the failure of the following stage, if any. If it + * throws, the exception is added as suppressed to the one thrown by the stage, or thrown if the stages succeeded. + */ + def spanEachDownstream(tracer: Tracer)( spanName: String, spanKind: SpanKind = SpanKind.INTERNAL, attributes: Attributes = Attributes.empty(), links: Seq[SpanContext] = Seq.empty, beforeClose: SpanWithError => Span = _.span - )(f: T => U): Flow[U] = oxTracing.mapSpanned(flow)(spanName, spanKind, attributes, links, beforeClose)(f) + ): Flow[T] = Flow.usingEmitInline: emit => + flow.runToEmit( + FlowEmit.fromInline: t => + byNameSpan(tracer)(emit(t))(spanName, spanKind, attributes, links, beforeClose) + ) + + /** As [[ox.flow.Flow.map]], but each call of `f` is run within a new span. The span is made a child of the current span, and becomes the + * current one until it ends, when the previously current span is restored. + * + * If `f` throws, the span's status is set to `ERROR` and an appropriate event is added to it. + * + * @param beforeClose + * callback executed right before closing the span, which receives the span and the failure of `f`, if any. If it throws, the exception + * is added as suppressed to the one thrown by `f`, or thrown if `f` succeeded. + */ + def mapSpanned[U](tracer: Tracer)( + spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty, + beforeClose: SpanWithError => Span = _.span + )(f: T => U): Flow[U] = + flow.map(t => byNameSpan(tracer)(f(t))(spanName, spanKind, attributes, links, beforeClose)) end extension diff --git a/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala b/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala deleted file mode 100644 index 82c96861..00000000 --- a/otel/src/main/scala/ox/telemetry/tracing/OxTracing.scala +++ /dev/null @@ -1,114 +0,0 @@ -package ox.telemetry.tracing - -import io.opentelemetry.api.common.Attributes -import io.opentelemetry.api.trace.{Span, SpanContext, SpanKind, StatusCode, Tracer} -import io.opentelemetry.context.Context -import ox.flow.tracing.OxFlowTracing - -import java.nio.channels.ClosedByInterruptException -import java.util.concurrent.ExecutionException -import scala.util.boundary -import scala.util.control.ControlThrowable - -sealed trait OxTracing extends OxFlowTracing: - - protected def tracer: Tracer - - /** Returns the current span. This is not unsafe per se, but the caller is responsible for using the span properly, e.g. not ending a span - * that is managed elsewhere. - */ - def currentSpanUnsafe(): Span = Span.fromContext(Context.current()) - - /** Opens a new [[Span]] as a child of the current span. The new span becomes the current one. - * - * Unsafe, because the caller is responsible for closing the returned [[AutoCloseableSpan]], which ends the span and restores the - * previously current one. Scoped usage, e.g. [[ox.useCloseableInScope]], guarantees that. - * - * @return - * An [[AutoCloseableSpan]], which closes the opened span. - */ - def spanUnsafe( - spanName: String, - spanKind: SpanKind = SpanKind.INTERNAL, - attributes: Attributes = Attributes.empty(), - links: Seq[SpanContext] = Seq.empty - ): AutoCloseableSpan = - val previousContext = Context.current() - val builder = tracer.spanBuilder(spanName).setSpanKind(spanKind).setAllAttributes(attributes).setParent(previousContext) - val withLinks = links.foldLeft(builder)((b, l) => b.addLink(l)) - val started = withLinks.startSpan() - val scope = previousContext.`with`(started).makeCurrent() - - AutoCloseableSpan.Impl(started, scope) - end spanUnsafe - - /** Evaluates `t` within a new [[Span]]. The span is opened before `t` is evaluated, as a child of the current span, and becomes the - * current one. It is ended once `t` completes, successfully or not, and the previously current span is restored. - * - * If `t` throws, the exception is recorded on the span and the span's status is set to [[io.opentelemetry.api.trace.StatusCode.ERROR]], - * before `beforeClose` is called. The exception is then re-thrown. This doesn't apply to [[InterruptedException]] and - * [[java.nio.channels.ClosedByInterruptException]] (an interruption during I/O), [[scala.util.control.ControlThrowable]]s, as well as - * `boundary.break`: they are not failures, but a way of exiting early, so the span is ended without an error status, and `beforeClose` - * doesn't receive them, as if `t` succeeded. The same applies to a [[java.util.concurrent.ExecutionException]] wrapping one of them, as - * when a cancelled fork is joined. Fatal errors, e.g. [[OutOfMemoryError]], are failures and are recorded. - * - * To span a [[ox.flow.Flow]], use [[OxFlowTracing.spanStages]]. - * - * @param beforeClose - * Run right before the span is closed, e.g. to set attributes or add events. The second element of the argument is the failure of `t`, - * if any, that is the exception it threw, other than the ones which are not failures. If `beforeClose` itself throws, the exception is - * added as suppressed to the one thrown by `t`, including the ones which are not failures, or thrown if `t` succeeded. The span is - * closed in either case. - */ - private[ox] def byNameSpan[T](t: => T)( - spanName: String, - spanKind: SpanKind = SpanKind.INTERNAL, - attributes: Attributes = Attributes.empty(), - links: Seq[SpanContext] = Seq.empty, - beforeClose: SpanWithError => Span = _.span - ): T = - var error: Throwable | Null = null - var actionableError: Throwable | Null = null - var finalizer: AutoCloseableSpan | Null = null - try - val finalize = spanUnsafe(spanName, spanKind, attributes, links) - finalizer = finalize - t - catch - case e: Throwable => - error = e - if shouldRecord(e) then - actionableError = e - if finalizer ne null then - finalizer.nn.span.recordException(e) - finalizer.nn.span.setStatus(StatusCode.ERROR, e.getMessage): Unit - throw e - finally - try if finalizer ne null then beforeClose(SpanWithError(finalizer.nn.span, actionableError)): Unit - catch - case e: Throwable => - if error ne null then error.nn.addSuppressed(e) - else throw e - finally if finalizer ne null then finalizer.nn.close() - end try - end try - - end byNameSpan - - private def shouldRecord(e: Throwable): Boolean = e match - case _: InterruptedException | _: ClosedByInterruptException | _: ControlThrowable | _: boundary.Break[?] => false - case e: ExecutionException if e.getCause ne null => shouldRecord(e.getCause) - case _ => true -end OxTracing - -object OxTracing: - - /** Creates tracing, which keeps spans in the OpenTelemetry [[io.opentelemetry.context.Context]]. For the context to be propagated to - * forks, the [[ox.otel.context.PropagatingVirtualThreadFactory]] is required: it has to be set as the Ox thread factory, e.g. using - * [[ox.OxApp.Settings.threadFactory]] or [[ox.setOxThreadFactory]]. - */ - def apply(tracer: Tracer): OxTracing = Impl(tracer) - - private final class Impl(val tracer: Tracer) extends OxTracing - -end OxTracing diff --git a/otel/src/main/scala/ox/telemetry/tracing/tracing.scala b/otel/src/main/scala/ox/telemetry/tracing/tracing.scala index c8e045a0..0950bbee 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/tracing.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/tracing.scala @@ -1,18 +1,92 @@ package ox.telemetry.tracing import io.opentelemetry.api.common.Attributes -import io.opentelemetry.api.trace.{Span, SpanContext, SpanKind} -import ox.telemetry.tracing.OxTracing +import io.opentelemetry.api.trace.{Span, SpanContext, SpanKind, StatusCode, Tracer} +import io.opentelemetry.context.Context -export ox.flow.tracing.{mapSpanned, spanStages} +import java.nio.channels.ClosedByInterruptException +import java.util.concurrent.ExecutionException +import scala.util.boundary +import scala.util.control.ControlThrowable -/** Returns the current span, see [[OxTracing.currentSpanUnsafe]]. */ -def currentSpanUnsafe(oxTracing: OxTracing) = oxTracing.currentSpanUnsafe() +export ox.flow.tracing.{mapSpanned, spanEachDownstream, spanStages} -/** Opens a new span, which has to be closed by the caller, see [[OxTracing.spanUnsafe]]. */ -def spanUnsafe(oxTracing: OxTracing)( +/** Returns the current span. This is not unsafe per se, but the caller is responsible for using the span properly, e.g. not ending a span + * that is managed elsewhere. + */ +def currentSpanUnsafe(): Span = Span.fromContext(Context.current()) + +/** Opens a new [[Span]] as a child of the current span. The new span becomes the current one. + * + * Unsafe, because the caller is responsible for closing the returned [[AutoCloseableSpan]], which ends the span and restores the + * previously current one. Scoped usage, e.g. [[ox.useCloseableInScope]], guarantees that. + * + * For the context to be propagated to forks, the [[ox.otel.context.PropagatingVirtualThreadFactory]] is required: it has to be set as the + * Ox thread factory, e.g. using [[ox.OxApp.Settings.threadFactory]] or [[ox.setOxThreadFactory]]. + * + * @return + * An [[AutoCloseableSpan]], which closes the opened span. + */ +def spanUnsafe(tracer: Tracer)( spanName: String, spanKind: SpanKind = SpanKind.INTERNAL, attributes: Attributes = Attributes.empty(), links: Seq[SpanContext] = Seq.empty -): AutoCloseableSpan = oxTracing.spanUnsafe(spanName, spanKind, attributes, links) +): AutoCloseableSpan = + val previousContext = Context.current() + val builder = tracer.spanBuilder(spanName).setSpanKind(spanKind).setAllAttributes(attributes).setParent(previousContext) + val withLinks = links.foldLeft(builder)((b, l) => b.addLink(l)) + val started = withLinks.startSpan() + val scope = previousContext.`with`(started).makeCurrent() + + AutoCloseableSpan.Impl(started, scope) +end spanUnsafe + +/** Evaluates `t` within a new span, which is made a child of the current span, and becomes the current one. The span is ended once `t` + * completes, and the previously current span is restored. + * + * If `t` throws, the exception is recorded on the span as an event, and the span's status is set to `ERROR`. Interruptions, + * [[scala.util.control.ControlThrowable]]s and `boundary.break` are not failures: they end the span normally. + * + * @param beforeClose + * callback executed right before closing the span, which receives the span and the failure of `t`, if any. If it throws, the exception + * is added as suppressed to the one thrown by `t`, or thrown if `t` succeeded. + */ +private[ox] def byNameSpan[T](tracer: Tracer)(t: => T)( + spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty, + beforeClose: SpanWithError => Span = _.span +): T = + var error: Throwable | Null = null + var actionableError: Throwable | Null = null + var finalizer: AutoCloseableSpan | Null = null + try + val finalize = spanUnsafe(tracer)(spanName, spanKind, attributes, links) + finalizer = finalize + t + catch + case e: Throwable => + error = e + if shouldRecord(e) then + actionableError = e + if finalizer ne null then + finalizer.nn.span.recordException(e) + finalizer.nn.span.setStatus(StatusCode.ERROR, e.getMessage): Unit + throw e + finally + try if finalizer ne null then beforeClose(SpanWithError(finalizer.nn.span, actionableError)): Unit + catch + case e: Throwable => + if error ne null then error.nn.addSuppressed(e) + else throw e + finally if finalizer ne null then finalizer.nn.close() + end try + end try +end byNameSpan + +private def shouldRecord(e: Throwable): Boolean = e match + case _: InterruptedException | _: ClosedByInterruptException | _: ControlThrowable | _: boundary.Break[?] => false + case e: ExecutionException if e.getCause ne null => shouldRecord(e.getCause) + case _ => true diff --git a/otel/src/test/scala/OxTelemetryTest.scala b/otel/src/test/scala/OxTelemetryTest.scala index 8d701356..37f2ae13 100644 --- a/otel/src/test/scala/OxTelemetryTest.scala +++ b/otel/src/test/scala/OxTelemetryTest.scala @@ -1,7 +1,7 @@ package ox.telemetry import io.opentelemetry.api.common.{AttributeKey, Attributes} -import io.opentelemetry.api.trace.{Span, SpanContext, SpanKind, StatusCode} +import io.opentelemetry.api.trace.{StatusCode, Tracer} import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter import io.opentelemetry.sdk.OpenTelemetrySdk import io.opentelemetry.sdk.resources.Resource @@ -56,7 +56,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: val tracer = openTelemetry.getTracer("test") - val myTracing: OxTracing = OxTracing(tracer) + val myTracing: Tracer = tracer enum Level: case Level0 @@ -105,12 +105,12 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: "Flow.span" when { "run normally" should { "not open a span until the flow is run" in new Fixture: - Flow.usingEmit[Int](emit => myTracing.byNameSpan(flow.runToEmit(emit))(s"$prefix-$Level0")).buffer(): Unit + Flow.usingEmit[Int](emit => byNameSpan(myTracing)(flow.runToEmit(emit))(s"$prefix-$Level0")).buffer(): Unit assert(testSpans.isEmpty) assertNesting() "open a single span for a multi-element flow" in new Fixture: - Flow.usingEmit[Int](emit => myTracing.byNameSpan(flow.runToEmit(emit))(s"$prefix-$Level0")).buffer().runDrain() + Flow.usingEmit[Int](emit => byNameSpan(myTracing)(flow.runToEmit(emit))(s"$prefix-$Level0")).buffer().runDrain() assert(spansOf(Level0).size == 1) assert(testSpans.size == 1) @@ -120,12 +120,12 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: supervised { val spanned1 = Flow - .usingEmit[Int](emit => myTracing.byNameSpan(flow.runToEmit(emit))(s"$prefix-$Level1")) + .usingEmit[Int](emit => byNameSpan(myTracing)(flow.runToEmit(emit))(s"$prefix-$Level1")) .buffer() .map(_ + 100) - .tap(i => currentSpanUnsafe(myTracing).addEvent(i.toString)) + .tap(i => currentSpanUnsafe().addEvent(i.toString)) - Flow.usingEmit[Int](emit => myTracing.byNameSpan(spanned1.runToEmit(emit))(s"$prefix-$Level0")).buffer().runDrain() + Flow.usingEmit[Int](emit => byNameSpan(myTracing)(spanned1.runToEmit(emit))(s"$prefix-$Level0")).buffer().runDrain() assert(spansOf(Level0).size == 1) assert(spansOf(Level1).size == 1) @@ -135,10 +135,10 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: "open one span per span() call when spans are nested" in new Fixture: supervised { val spanned1 = - (Flow.usingEmit[Int](emit => myTracing.byNameSpan(flow.runToEmit(emit))(s"$prefix-$Level1")).buffer() ++ Flow.fromValues(4, 2)) - .tap(i => currentSpanUnsafe(myTracing).addEvent(i.toString)) + (Flow.usingEmit[Int](emit => byNameSpan(myTracing)(flow.runToEmit(emit))(s"$prefix-$Level1")).buffer() ++ Flow.fromValues(4, 2)) + .tap(i => currentSpanUnsafe().addEvent(i.toString)) - Flow.usingEmit[Int](emit => myTracing.byNameSpan(spanned1.runToEmit(emit))(s"$prefix-$Level0")).buffer().runDrain() + Flow.usingEmit[Int](emit => byNameSpan(myTracing)(spanned1.runToEmit(emit))(s"$prefix-$Level0")).buffer().runDrain() assert(spansOf(Level0).size == 1) assert(spansOf(Level1).size == 1) @@ -147,7 +147,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: } "open a single span when downstream uses mapPar" in new Fixture: - Flow.usingEmit[Int](emit => myTracing.byNameSpan(flow.runToEmit(emit))(s"$prefix-$Level0")).buffer().mapPar(3)(_ + 1).runDrain() + Flow.usingEmit[Int](emit => byNameSpan(myTracing)(flow.runToEmit(emit))(s"$prefix-$Level0")).buffer().mapPar(3)(_ + 1).runDrain() assert(testSpans.size == 1) @@ -159,9 +159,9 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: "used under parent spans" should { "use the current span as parent" in new Fixture: - val parent = myTracing.spanUnsafe(s"$prefix-$Level0") + val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") - Flow.usingEmit[Int](emit => myTracing.byNameSpan(flow.runToEmit(emit))(s"$prefix-$Level1")).buffer().runToList(): Unit + Flow.usingEmit[Int](emit => byNameSpan(myTracing)(flow.runToEmit(emit))(s"$prefix-$Level1")).buffer().runToList(): Unit parent.close() @@ -170,10 +170,10 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: assertNesting() "open one child span per element when flatMapped under a parent span" in new Fixture: - val spanned = Flow.usingEmit[Int](emit => myTracing.byNameSpan(flow.runToEmit(emit))(s"$prefix-$Level1")).buffer() + val spanned = Flow.usingEmit[Int](emit => byNameSpan(myTracing)(flow.runToEmit(emit))(s"$prefix-$Level1")).buffer() Flow - .usingEmit[Int](emit => myTracing.byNameSpan(flow.flatMap { _ => spanned }.runToEmit(emit))(s"$prefix-$Level0")) + .usingEmit[Int](emit => byNameSpan(myTracing)(flow.flatMap { _ => spanned }.runToEmit(emit))(s"$prefix-$Level0")) .buffer() .runToList(): Unit @@ -182,7 +182,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: assertNesting() "parent spans of concurrently run flows under the enclosing span" in new Fixture: - val spanned = Flow.usingEmit[Int](emit => myTracing.byNameSpan(flow.runToEmit(emit))(s"$prefix-$Level1")).buffer() + val spanned = Flow.usingEmit[Int](emit => byNameSpan(myTracing)(flow.runToEmit(emit))(s"$prefix-$Level1")).buffer() val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") @@ -197,22 +197,24 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: assertNesting() "nest spans across forks and inner supervised scopes" in new Fixture: - val spanned = Flow.usingEmit[Int](emit => myTracing.byNameSpan(flow.runToEmit(emit))(s"$prefix-$Level1")).buffer() + val spanned = Flow.usingEmit[Int](emit => byNameSpan(myTracing)(flow.runToEmit(emit))(s"$prefix-$Level1")).buffer() - myTracing.byNameSpan(supervised { + byNameSpan(myTracing)(supervised { (1 to 10).map { case 1 => forkUser( Flow .usingEmit[Int](emit => - myTracing.byNameSpan( + byNameSpan(myTracing)( flow .flatMap { el => val innerFlow = supervised { Flow .usingEmit[Int](emit => - myTracing - .byNameSpan(flow.runToEmit(emit))(s"$prefix-$Level1_First_Child", beforeClose = _._1.addEvent("boomed")) + byNameSpan(myTracing)(flow.runToEmit(emit))( + s"$prefix-$Level1_First_Child", + beforeClose = _._1.addEvent("boomed") + ) ) .buffer() } @@ -241,7 +243,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: try Flow .usingEmit[Int](emit => - myTracing.byNameSpan( + byNameSpan(myTracing)( flow .tap { case 2 => throw new Exception("boom"); case _ => () } .runToEmit(emit) @@ -266,11 +268,11 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: assertNesting() "keep the flow span as a child of the recovery span when failure is recovered" in new Fixture: - val spanned = Flow.usingEmit[Int](emit => myTracing.byNameSpan(flow.runToEmit(emit))(s"$prefix-$Level1")).buffer() + val spanned = Flow.usingEmit[Int](emit => byNameSpan(myTracing)(flow.runToEmit(emit))(s"$prefix-$Level1")).buffer() Flow .usingEmit[Int](emit => - myTracing.byNameSpan( + byNameSpan(myTracing)( spanned .map { case 2 => throw new Exception("boom"); case x => x } .recover(_ => 5) @@ -285,7 +287,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: assertNesting() "end spans of concurrent flows when one of them fails" in new Fixture: - val spanned = Flow.usingEmit[Int](emit => myTracing.byNameSpan(flow.runToEmit(emit))(s"$prefix-$Level1")).buffer() + val spanned = Flow.usingEmit[Int](emit => byNameSpan(myTracing)(flow.runToEmit(emit))(s"$prefix-$Level1")).buffer() val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") @@ -308,7 +310,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: val level0 = spanUnsafe(myTracing)(s"$prefix-$Level0") - val spanned = Flow.usingEmit[Int](emit => myTracing.byNameSpan(flow.runToEmit(emit))(s"$prefix-$Level1")).buffer() + val spanned = Flow.usingEmit[Int](emit => byNameSpan(myTracing)(flow.runToEmit(emit))(s"$prefix-$Level1")).buffer() try supervised { @@ -317,7 +319,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: forkUser { Flow .usingEmit[Int](emit => - myTracing.byNameSpan( + byNameSpan(myTracing)( flow .flatMap { el => { @@ -327,7 +329,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: em.pure( Flow .usingEmit[Int](emit => - myTracing.byNameSpan(flow.runToEmit(emit))( + byNameSpan(myTracing)(flow.runToEmit(emit))( s"$prefix-$Level1_First_Child", beforeClose = _._1.addEvent(s"boomed at $el") ) @@ -363,7 +365,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: val level0 = spanUnsafe(myTracing)(s"$prefix-$Level0") - val spanned = Flow.usingEmit[Int](emit => myTracing.byNameSpan(flow.runToEmit(emit))(s"$prefix-$Level1")).buffer() + val spanned = Flow.usingEmit[Int](emit => byNameSpan(myTracing)(flow.runToEmit(emit))(s"$prefix-$Level1")).buffer() try supervised { @@ -372,7 +374,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: forkUser { Flow .usingEmit[Int](emit => - myTracing.byNameSpan( + byNameSpan(myTracing)( flow .flatMap { el => { @@ -382,7 +384,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: em.pure( Flow .usingEmit[Int](emit => - myTracing.byNameSpan(flow.runToEmit(emit))( + byNameSpan(myTracing)(flow.runToEmit(emit))( s"$prefix-$Level1_First_Child", beforeClose = _._1.addEvent(s"boomed at $el") ) @@ -418,7 +420,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: val level0 = spanUnsafe(myTracing)(s"$prefix-$Level0") - val spanned = Flow.usingEmit[Int](emit => myTracing.byNameSpan(flow.runToEmit(emit))(s"$prefix-$Level1")).buffer() + val spanned = Flow.usingEmit[Int](emit => byNameSpan(myTracing)(flow.runToEmit(emit))(s"$prefix-$Level1")).buffer() try supervisedError(em) { @@ -429,7 +431,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: forkUser { Flow .usingEmit[Int](emit => - myTracing.byNameSpan( + byNameSpan(myTracing)( flow .flatMap { el => { @@ -438,7 +440,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: em.pure( Flow .usingEmit[Int](emit => - myTracing.byNameSpan(flow.runToEmit(emit))( + byNameSpan(myTracing)(flow.runToEmit(emit))( s"$prefix-$Level1_First_Child", beforeClose = _._1.addEvent(s"boomed at $el") ) @@ -475,14 +477,14 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: Flow .usingEmit[Int](emit => - myTracing.byNameSpan( + byNameSpan(myTracing)( flow .runToEmit(emit) )(s"$prefix-$Level1") ) .buffer() .buffer() - .map(x => myTracing.byNameSpan(x)(s"$prefix-$Level1_Stage")) + .map(x => byNameSpan(myTracing)(x)(s"$prefix-$Level1_Stage")) .runToList(): Unit parent.close() @@ -508,7 +510,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: def readLines(file: Int): Flow[String] = Flow.fromValues(s"line of $file").tap(_ => sleep(b)) def streamLinesFlow(file: Int) = - Flow.usingEmit[String](emit => myTracing.byNameSpan(readLines(file).runToEmit(emit))(s"$prefix-$Level1_First")).buffer() + Flow.usingEmit[String](emit => byNameSpan(myTracing)(readLines(file).runToEmit(emit))(s"$prefix-$Level1_First")).buffer() (for file <- Flow.fromIterable(filesToDownload).mapSpanned(myTracing)(s"$prefix-$Level1")(download) @@ -530,10 +532,10 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: (for x <- Flow.fromValues(1) y <- Flow - .usingEmit[Int](emit => myTracing.byNameSpan(Flow.fromValues(x).tap(_ => sleep(a)).runToEmit(emit))(s"$prefix-$Level1")) + .usingEmit[Int](emit => byNameSpan(myTracing)(Flow.fromValues(x).tap(_ => sleep(a)).runToEmit(emit))(s"$prefix-$Level1")) .buffer() z <- Flow - .usingEmit[Int](emit => myTracing.byNameSpan(Flow.fromValues(y).tap(_ => sleep(b)).runToEmit(emit))(s"$prefix-$Level1_First")) + .usingEmit[Int](emit => byNameSpan(myTracing)(Flow.fromValues(y).tap(_ => sleep(b)).runToEmit(emit))(s"$prefix-$Level1_First")) .buffer() yield z) .tap(_ => sleep(c)) @@ -548,17 +550,14 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: "nest the spans and durations of a spanStages inside a spanStages" in new Fixture: + // flow + // .spanStages(myTracing)(s"outerSpan1")( + // _.map(...).filter(...).spanStages(myTracing)(s"inner")(_.map(...).buffer().retry(...).map(...)) + // ) + // .spanStages(myTracing(s"outerSpan2"))(_.map(...)) + // .tap(_ => sleep(c)) //not included in spans + // .runToList(): Unit - flow - .spanStages(myTracing)(s"outerSpan1")( - _.map(...).filter(...).spanStages(myTracing)(s"inner")(_.map(...).buffer().retry(...).map(...)) - ) - .spanStages(myTracing(s"outerSpan2"))(_.map(...)) - .tap(_ => sleep(c)) //not included in spans - .runToList(): Unit - - - Flow .fromValues(1) .spanStages(myTracing)(s"$prefix-$Level1")( @@ -592,13 +591,65 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: } + "spanEachDownstream" should { + "open a span per element, around the stages placed after it" in new Fixture: + val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") + + flow + .spanEachDownstream(myTracing)(s"$prefix-$Level1_First") + .map(x => byNameSpan(myTracing)(x)(s"$prefix-$Level1_First_Child")) + .runToList(): Unit + + parent.close() + + assert(spansOf(Level1_First).size == 3) + assert(spansOf(Level1_First_Child).size == 3) + assertNesting() + + "not cover the stages placed before it" in new Fixture: + val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") + + flow + .map(x => byNameSpan(myTracing)(x)(s"$prefix-$Level1")) + .spanEachDownstream(myTracing)(s"$prefix-$Level1_First") + .runToList(): Unit + + parent.close() + + assert(spansOf(Level1).size == 3) + assert(spansOf(Level1_First).size == 3) + assertNesting() + + "count the stages placed after it, also outside of the flatMap it is used in" in new Fixture: + Flow + .fromValues(1, 2, 3) + .flatMap(x => Flow.fromValues(x).spanEachDownstream(myTracing)(s"$prefix-$Level1").tap(_ => sleep(200.millis))) + .tap(_ => sleep(200.millis)) + .runToList(): Unit + + assert(spansOf(Level1).size == 3) + spansOf(Level1).foreach(s => assertAbout(durMs(s), 400)) + + "end the span at a buffer placed after it" in new Fixture: + Flow + .fromValues(1, 2, 3) + .spanEachDownstream(myTracing)(s"$prefix-$Level1") + .tap(_ => sleep(200.millis)) + .buffer() + .tap(_ => sleep(200.millis)) + .runToList(): Unit + + assert(spansOf(Level1).size == 3) + spansOf(Level1).foreach(s => assertAbout(durMs(s), 200)) + } + "mapSpanned" should { "open a span around each call of the mapping function only" in new Fixture: val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") flow - .mapSpanned(myTracing)(s"$prefix-$Level1_First")(x => myTracing.byNameSpan(x + 1)(s"$prefix-$Level1_First_Child")) - .map(x => myTracing.byNameSpan(x)(s"$prefix-$Level1")) + .mapSpanned(myTracing)(s"$prefix-$Level1_First")(x => byNameSpan(myTracing)(x + 1)(s"$prefix-$Level1_First_Child")) + .map(x => byNameSpan(myTracing)(x)(s"$prefix-$Level1")) .runToList(): Unit parent.close() @@ -626,8 +677,8 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: flow .spanStages(myTracing)(s"$prefix-$Level1_First")(_.flatMap(x => - myTracing.byNameSpan(())(s"$prefix-$Level1_First_Child") - Flow.fromValues(x).map(y => myTracing.byNameSpan(y)(s"$prefix-$Level1_Stage")) + byNameSpan(myTracing)(())(s"$prefix-$Level1_First_Child") + Flow.fromValues(x).map(y => byNameSpan(myTracing)(y)(s"$prefix-$Level1_Stage")) )) .runToList(): Unit @@ -683,9 +734,9 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: flow .spanStages(myTracing)(s"$prefix-$Level1_First")( - _.mapConcat(x => 1 to x).map(y => myTracing.byNameSpan(y)(s"$prefix-$Level1_First_Child")) + _.mapConcat(x => 1 to x).map(y => byNameSpan(myTracing)(y)(s"$prefix-$Level1_First_Child")) ) - .map(x => myTracing.byNameSpan(x)(s"$prefix-$Level1")) + .map(x => byNameSpan(myTracing)(x)(s"$prefix-$Level1")) .runToList(): Unit parent.close() @@ -868,7 +919,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: "open spans created in the body of the function as children of the span of the element" in new Fixture: flow .spanStages(myTracing)(s"$prefix-$Level1")(f => - myTracing.byNameSpan(())(s"$prefix-$Level1_First") + byNameSpan(myTracing)(())(s"$prefix-$Level1_First") f ) .runToList(): Unit @@ -879,7 +930,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: "open spans created by the stages as children of the span of the element" in new Fixture: flow - .spanStages(myTracing)(s"$prefix-$Level1")(_.map(x => myTracing.byNameSpan(x)(s"$prefix-$Level1_First")).buffer()) + .spanStages(myTracing)(s"$prefix-$Level1")(_.map(x => byNameSpan(myTracing)(x)(s"$prefix-$Level1_First")).buffer()) .runToList(): Unit val parents = spansOf(Level1).map(_.getSpanId).toSet @@ -949,7 +1000,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: } forkUser { Flow - .usingEmit[Int](emit => myTracing.byNameSpan(flow.runToEmit(emit))(s"$prefix-$Level0")) + .usingEmit[Int](emit => byNameSpan(myTracing)(flow.runToEmit(emit))(s"$prefix-$Level0")) .buffer() .tap { case 1 => semaphore.release(); case _ => sleep(100.seconds) } .runToList() @@ -968,7 +1019,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: supervised { val forkWithSpan = forkCancellable { Flow - .usingEmit[Int](emit => myTracing.byNameSpan(flow.runToEmit(emit))(s"$prefix-$Level0")) + .usingEmit[Int](emit => byNameSpan(myTracing)(flow.runToEmit(emit))(s"$prefix-$Level0")) .buffer() .tap { case 1 => semaphore.release(); case _ => sleep(100.seconds) } .runToList() @@ -985,7 +1036,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: "not mark the span as failed when the code exits with a boundary break" in new Fixture: val result = scala.util.boundary: - myTracing.byNameSpan(scala.util.boundary.break(1))(s"$prefix-$Level0") + byNameSpan(myTracing)(scala.util.boundary.break(1))(s"$prefix-$Level0") 2 assert(result == 1) @@ -1000,11 +1051,11 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: s.span scala.util.boundary: - myTracing.byNameSpan(scala.util.boundary.break(1))(s"$prefix-$Level0", beforeClose = record) + byNameSpan(myTracing)(scala.util.boundary.break(1))(s"$prefix-$Level0", beforeClose = record) intercept[scala.util.control.ControlThrowable]: - myTracing.byNameSpan(throw new scala.util.control.ControlThrowable {})(s"$prefix-$Level0", beforeClose = record) + byNameSpan(myTracing)(throw new scala.util.control.ControlThrowable {})(s"$prefix-$Level0", beforeClose = record) intercept[InterruptedException]: - myTracing.byNameSpan(throw new InterruptedException())(s"$prefix-$Level0", beforeClose = record) + byNameSpan(myTracing)(throw new InterruptedException())(s"$prefix-$Level0", beforeClose = record) assert(seen == List(None, None, None)) assert(spansOf(Level0).size == 3) @@ -1013,7 +1064,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: "treat an interruption during I/O as not a failure" in new Fixture: var seen: Option[Throwable] = Some(new Exception("unset")) intercept[java.nio.channels.ClosedByInterruptException]: - myTracing.byNameSpan(throw new java.nio.channels.ClosedByInterruptException())( + byNameSpan(myTracing)(throw new java.nio.channels.ClosedByInterruptException())( s"$prefix-$Level0", beforeClose = s => seen = s.errorOpt @@ -1025,14 +1076,14 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: "record the closing of a channel by another thread as a failure" in new Fixture: intercept[java.nio.channels.AsynchronousCloseException]: - myTracing.byNameSpan(throw new java.nio.channels.AsynchronousCloseException())(s"$prefix-$Level0") + byNameSpan(myTracing)(throw new java.nio.channels.AsynchronousCloseException())(s"$prefix-$Level0") assert(spansOf(Level0).head.getStatus.getStatusCode == StatusCode.ERROR) "treat an ExecutionException wrapping an interruption as not a failure" in new Fixture: var seen: Option[Throwable] = Some(new Exception("unset")) intercept[java.util.concurrent.ExecutionException]: - myTracing.byNameSpan(throw new java.util.concurrent.ExecutionException(new InterruptedException()))( + byNameSpan(myTracing)(throw new java.util.concurrent.ExecutionException(new InterruptedException()))( s"$prefix-$Level0", beforeClose = s => seen = s.errorOpt @@ -1044,20 +1095,20 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: "treat an ExecutionException wrapping a failure as a failure" in new Fixture: intercept[java.util.concurrent.ExecutionException]: - myTracing.byNameSpan(throw new java.util.concurrent.ExecutionException(new Exception("boom")))(s"$prefix-$Level0") + byNameSpan(myTracing)(throw new java.util.concurrent.ExecutionException(new Exception("boom")))(s"$prefix-$Level0") assert(spansOf(Level0).head.getStatus.getStatusCode == StatusCode.ERROR) "record fatal errors as failures" in new Fixture: intercept[LinkageError]: - myTracing.byNameSpan(throw new LinkageError("broken"))(s"$prefix-$Level0") + byNameSpan(myTracing)(throw new LinkageError("broken"))(s"$prefix-$Level0") assert(spansOf(Level0).head.getStatus.getStatusCode == StatusCode.ERROR) "pass a failure to beforeClose" in new Fixture: var seen: Option[Throwable] = None intercept[Exception]: - myTracing.byNameSpan(throw new Exception("boom"))( + byNameSpan(myTracing)(throw new Exception("boom"))( s"$prefix-$Level0", beforeClose = s => seen = s.errorOpt @@ -1070,7 +1121,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: val interruption = new InterruptedException() val thrown = intercept[InterruptedException]: - myTracing.byNameSpan(throw interruption)(s"$prefix-$Level0", beforeClose = _ => throw new Exception("from beforeClose")) + byNameSpan(myTracing)(throw interruption)(s"$prefix-$Level0", beforeClose = _ => throw new Exception("from beforeClose")) assert(thrown eq interruption) assert(thrown.getSuppressed.exists(_.getMessage == "from beforeClose")) From 235acb439f5552b359824eaafb2a61b704a9f97b Mon Sep 17 00:00:00 2001 From: Pawel Sadlo Date: Fri, 23 Oct 2026 09:25:48 +0200 Subject: [PATCH 37/38] Docs: add a section per flow tracing operator Co-authored-by: Claude-Opus-4.8 via Claude-Code --- doc/integrations/otel-tracing.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/doc/integrations/otel-tracing.md b/doc/integrations/otel-tracing.md index ce32b6c5..4a17b6a0 100644 --- a/doc/integrations/otel-tracing.md +++ b/doc/integrations/otel-tracing.md @@ -42,6 +42,8 @@ within it throws, the exception is recorded on the span, and its status is set t | `spanStages(tracer)("name")(stages)` | running the element through `stages`, isolated from the following stages by a buffer | | `mapSpanned(tracer)("name")(f)` | the call of `f` | +### spanEachDownstream + `spanEachDownstream` covers the stages placed after it, up to the first asynchronous boundary, e.g. `buffer`. As there is no buffer, the synchronous stages are called from within the span, so their duration is counted: @@ -74,6 +76,8 @@ def inFlatMap(tracer: Tracer, flow: Flow[Int]): List[Int] = .runToList() ``` +### spanStages + `spanStages` runs each element through the stages added by the given function. The spans can be nested, and the stages placed after `spanStages` are not part of the span: @@ -96,6 +100,8 @@ The stages and the span run in a fork, and the results are passed on through a b `BufferCapacity` in scope. The following stages are not counted as long as the stages emit at most `BufferCapacity + 1` elements for one input element; beyond that, the span also includes the time of waiting for free space in the buffer. +### mapSpanned + `mapSpanned` is `map`, where each call of the function is run within a span: ```scala mdoc:compile-only @@ -109,6 +115,8 @@ def mapped(tracer: Tracer, flow: Flow[Int]): List[Int] = .runToList() ``` +### Optional parameters + All the operators accept optional parameters, which reflect the OpenTelemetry span builder: `spanKind`, `attributes` and `links`. From f70ac1c00edeee99c3dfd263167d3434c345e28a Mon Sep 17 00:00:00 2001 From: Pawel Sadlo Date: Fri, 23 Oct 2026 14:00:39 +0200 Subject: [PATCH 38/38] Flow tracing: span and spanError as extensions, break-aware error mode support span and spanError are public extensions on by-name code: code.span(tracer)("name"), code.spanError(tracer, errorMode)("name"). spanError records application errors returned by the code, or carried by a boundary.break (e.g. `.ok()` in `either`), on the span, and passes them to beforeClose. An error of a break thrown by beforeClose is recorded as well, or suppressed into the application error returned by the code. spanEachDownstream is renamed to spanEach, mapSpanned is removed, and SpanWithError is a case class carrying the defect and the application error. Co-authored-by: Claude-Opus-4.8 via Claude-Code --- doc/integrations/otel-tracing.md | 66 ++- .../scala/ox/flow/tracing/flowTracing.scala | 30 +- .../ox/telemetry/tracing/SpanWithError.scala | 11 +- .../scala/ox/telemetry/tracing/tracing.scala | 150 ++++-- otel/src/test/scala/OxTelemetryTest.scala | 475 +++++++++++------- 5 files changed, 455 insertions(+), 277 deletions(-) diff --git a/doc/integrations/otel-tracing.md b/doc/integrations/otel-tracing.md index 4a17b6a0..36f0a1e8 100644 --- a/doc/integrations/otel-tracing.md +++ b/doc/integrations/otel-tracing.md @@ -36,15 +36,14 @@ without it, spans created in forks won't be children of the span active when the [Flows](../streaming/flows.md) can open a span for each element. The span is a child of the current span, and if the code within it throws, the exception is recorded on the span, and its status is set to `ERROR`. -| Operator | The span of an element covers | -|---------------------------------------|--------------------------------------------------------------------------------------| -| `spanEachDownstream(tracer)("name")` | running the element through all the following stages, up to the first async boundary | -| `spanStages(tracer)("name")(stages)` | running the element through `stages`, isolated from the following stages by a buffer | -| `mapSpanned(tracer)("name")(f)` | the call of `f` | +| Operator | The span of an element covers | +|--------------------------------------|--------------------------------------------------------------------------------------| +| `spanEach(tracer)("name")` | running the element through all the following stages, up to the first async boundary | +| `spanStages(tracer)("name")(stages)` | running the element through `stages`, isolated from the following stages by a buffer | -### spanEachDownstream +### spanEach -`spanEachDownstream` covers the stages placed after it, up to the first asynchronous boundary, e.g. `buffer`. As there is +`spanEach` covers the stages placed after it, up to the first asynchronous boundary, e.g. `buffer`. As there is no buffer, the synchronous stages are called from within the span, so their duration is counted: ```scala mdoc:compile-only @@ -54,7 +53,7 @@ import ox.telemetry.tracing.* def downstream(tracer: Tracer, flow: Flow[Int]): List[Int] = flow - .spanEachDownstream(tracer)("element") + .spanEach(tracer)("element") .map(_ + 1) // covered by the span .buffer() // the span ends here .map(_ * 2) // not covered @@ -71,7 +70,7 @@ import ox.telemetry.tracing.* def inFlatMap(tracer: Tracer, flow: Flow[Int]): List[Int] = flow - .flatMap(x => Flow.fromValues(x, x * 10).spanEachDownstream(tracer)("element").map(_ + 1)) // covered by the span + .flatMap(x => Flow.fromValues(x, x * 10).spanEach(tracer)("element").map(_ + 1)) // covered by the span .map(_ * 2) // also covered .runToList() ``` @@ -100,28 +99,13 @@ The stages and the span run in a fork, and the results are passed on through a b `BufferCapacity` in scope. The following stages are not counted as long as the stages emit at most `BufferCapacity + 1` elements for one input element; beyond that, the span also includes the time of waiting for free space in the buffer. -### mapSpanned - -`mapSpanned` is `map`, where each call of the function is run within a span: - -```scala mdoc:compile-only -import io.opentelemetry.api.trace.Tracer -import ox.flow.Flow -import ox.telemetry.tracing.* - -def mapped(tracer: Tracer, flow: Flow[Int]): List[Int] = - flow - .mapSpanned(tracer)("increment")(_ + 1) - .runToList() -``` - ### Optional parameters All the operators accept optional parameters, which reflect the OpenTelemetry span builder: `spanKind`, `attributes` and `links`. They also accept a `beforeClose` callback. It's called right before the span is closed, and -receives the span, and the failure of the code covered by the span, if any. It can be used to modify the span right before its closed. +receives the span, and the exception thrown by the code covered by the span as `defectOpt`, if any. It can be used to modify the span right before its closed. ```scala mdoc:compile-only import io.opentelemetry.api.trace.Tracer @@ -133,12 +117,42 @@ def withCallback(tracer: Tracer, flow: Flow[Int]): List[Int] = .spanStages(tracer)( "process", beforeClose = s => - s.errorOpt.foreach(e => s.span.setAttribute("error.type", e.getClass.getSimpleName)) + s.defectOpt.foreach(e => s.span.setAttribute("error.type", e.getClass.getSimpleName)) s.span.addEvent("done") )(_.map(_ + 1)) .runToList() ``` +## Spans around code + +`span` evaluates the given code within a span, which is ended once the code completes. If it throws, the exception is +recorded on the span. Interruptions and `boundary.break` are not failures, so they end the span normally. + +`spanError` additionally takes an [`ErrorMode`](../basics/error-handling.md), so that an application error, e.g. a +`Left`, is recorded on the span as well: its status is set to `ERROR`, and an `exception` event is added. The error is not +thrown, but returned as is. An error carried by a `boundary.break`, e.g. by `.ok()` in an `either` block, is recorded +in the same way, and the break is propagated: + +```scala mdoc:compile-only +import io.opentelemetry.api.trace.Tracer +import ox.{EitherMode, either} +import ox.either.ok +import ox.telemetry.tracing.* + +def parse(s: String): Either[String, Int] = s.toIntOption.toRight(s"not a number: $s") + +def returned(tracer: Tracer): Either[String, Int] = + println("working").span(tracer)("log") + parse("abc").spanError(tracer, EitherMode[String]())("parse") // recorded, and returned as a Left + +def withOk(tracer: Tracer): Either[String, Int] = + either: + Right(parse("abc").ok() + 1).spanError(tracer, EitherMode[String]())("parse").ok() // recorded, and the break propagated +``` + +Both accept the optional parameters and the `beforeClose` callback described above. In `spanError`, its argument also has +the application error as `errorOpt`; without an error mode, all the failures are exceptions, so it's always empty. + ## Manual spans `spanUnsafe` opens a span, which has to be closed by the caller. Closing the returned `AutoCloseableSpan` ends the span diff --git a/otel/src/main/scala/ox/flow/tracing/flowTracing.scala b/otel/src/main/scala/ox/flow/tracing/flowTracing.scala index f6d11e9e..6bf7c975 100644 --- a/otel/src/main/scala/ox/flow/tracing/flowTracing.scala +++ b/otel/src/main/scala/ox/flow/tracing/flowTracing.scala @@ -4,7 +4,7 @@ import io.opentelemetry.api.common.Attributes import io.opentelemetry.api.trace.{Span, SpanContext, SpanKind, Tracer} import ox.channels.BufferCapacity import ox.flow.{Flow, FlowEmit} -import ox.telemetry.tracing.{SpanWithError, byNameSpan} +import ox.telemetry.tracing.{SpanWithError, span} extension [T](flow: Flow[T]) /** Runs each element of this flow through the stages added by `f`, within its own span. The span is made a child of the current span, and @@ -28,11 +28,11 @@ extension [T](flow: Flow[T]) spanKind: SpanKind = SpanKind.INTERNAL, attributes: Attributes = Attributes.empty(), links: Seq[SpanContext] = Seq.empty, - beforeClose: SpanWithError => Span = _.span + beforeClose: SpanWithError[Nothing] => Span = _.span )(f: Flow[T] => Flow[U])(using BufferCapacity): Flow[U] = flow.flatMap: t => val spanned = Flow.usingEmitInline[U]: emit => - byNameSpan(tracer)(f(Flow.fromValues(t)).runToEmit(emit))(spanName, spanKind, attributes, links, beforeClose) + f(Flow.fromValues(t)).runToEmit(emit).span(tracer)(spanName, spanKind, attributes, links, beforeClose) spanned.buffer() /** Returns a flow, which for each element of this flow opens a new span. The span is made a child of the current span, and becomes the @@ -47,33 +47,15 @@ extension [T](flow: Flow[T]) * callback executed right before closing the span, which receives the span and the failure of the following stage, if any. If it * throws, the exception is added as suppressed to the one thrown by the stage, or thrown if the stages succeeded. */ - def spanEachDownstream(tracer: Tracer)( + def spanEach(tracer: Tracer)( spanName: String, spanKind: SpanKind = SpanKind.INTERNAL, attributes: Attributes = Attributes.empty(), links: Seq[SpanContext] = Seq.empty, - beforeClose: SpanWithError => Span = _.span + beforeClose: SpanWithError[Nothing] => Span = _.span ): Flow[T] = Flow.usingEmitInline: emit => flow.runToEmit( FlowEmit.fromInline: t => - byNameSpan(tracer)(emit(t))(spanName, spanKind, attributes, links, beforeClose) + span(emit(t))(tracer)(spanName, spanKind, attributes, links, beforeClose) ) - - /** As [[ox.flow.Flow.map]], but each call of `f` is run within a new span. The span is made a child of the current span, and becomes the - * current one until it ends, when the previously current span is restored. - * - * If `f` throws, the span's status is set to `ERROR` and an appropriate event is added to it. - * - * @param beforeClose - * callback executed right before closing the span, which receives the span and the failure of `f`, if any. If it throws, the exception - * is added as suppressed to the one thrown by `f`, or thrown if `f` succeeded. - */ - def mapSpanned[U](tracer: Tracer)( - spanName: String, - spanKind: SpanKind = SpanKind.INTERNAL, - attributes: Attributes = Attributes.empty(), - links: Seq[SpanContext] = Seq.empty, - beforeClose: SpanWithError => Span = _.span - )(f: T => U): Flow[U] = - flow.map(t => byNameSpan(tracer)(f(t))(spanName, spanKind, attributes, links, beforeClose)) end extension diff --git a/otel/src/main/scala/ox/telemetry/tracing/SpanWithError.scala b/otel/src/main/scala/ox/telemetry/tracing/SpanWithError.scala index bd98b9a6..48f70e02 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/SpanWithError.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/SpanWithError.scala @@ -2,10 +2,7 @@ package ox.telemetry.tracing import io.opentelemetry.api.trace.Span -opaque type SpanWithError <: (Span, Option[Throwable]) = (Span, Option[Throwable]) -object SpanWithError: - def apply(span: Span, errorOpt: Option[Throwable]): SpanWithError = (span, errorOpt) - def apply(span: Span, errorOpt: Throwable): SpanWithError = (span, Option(errorOpt)) - extension (s: SpanWithError) - def span = s._1 - def errorOpt = s._2 +/** A span, together with the exception thrown by the code it covers, if any, and the application error it returned, if any, in the sense of + * an [[ox.ErrorMode]]. Without an error mode, all the failures are exceptions. + */ +final case class SpanWithError[+E](span: Span, defectOpt: Option[Throwable], errorOpt: Option[E]) diff --git a/otel/src/main/scala/ox/telemetry/tracing/tracing.scala b/otel/src/main/scala/ox/telemetry/tracing/tracing.scala index 0950bbee..288cc0e0 100644 --- a/otel/src/main/scala/ox/telemetry/tracing/tracing.scala +++ b/otel/src/main/scala/ox/telemetry/tracing/tracing.scala @@ -1,15 +1,16 @@ package ox.telemetry.tracing -import io.opentelemetry.api.common.Attributes +import io.opentelemetry.api.common.{AttributeKey, Attributes} import io.opentelemetry.api.trace.{Span, SpanContext, SpanKind, StatusCode, Tracer} import io.opentelemetry.context.Context +import ox.{ErrorMode, NoErrorMode} import java.nio.channels.ClosedByInterruptException import java.util.concurrent.ExecutionException -import scala.util.boundary +import scala.util.{Try, boundary} import scala.util.control.ControlThrowable -export ox.flow.tracing.{mapSpanned, spanEachDownstream, spanStages} +export ox.flow.tracing.{spanEach, spanStages} /** Returns the current span. This is not unsafe per se, but the caller is responsible for using the span properly, e.g. not ending a span * that is managed elsewhere. @@ -52,41 +53,114 @@ end spanUnsafe * callback executed right before closing the span, which receives the span and the failure of `t`, if any. If it throws, the exception * is added as suppressed to the one thrown by `t`, or thrown if `t` succeeded. */ -private[ox] def byNameSpan[T](tracer: Tracer)(t: => T)( - spanName: String, - spanKind: SpanKind = SpanKind.INTERNAL, - attributes: Attributes = Attributes.empty(), - links: Seq[SpanContext] = Seq.empty, - beforeClose: SpanWithError => Span = _.span -): T = - var error: Throwable | Null = null - var actionableError: Throwable | Null = null - var finalizer: AutoCloseableSpan | Null = null - try - val finalize = spanUnsafe(tracer)(spanName, spanKind, attributes, links) - finalizer = finalize - t - catch - case e: Throwable => - error = e - if shouldRecord(e) then - actionableError = e - if finalizer ne null then - finalizer.nn.span.recordException(e) - finalizer.nn.span.setStatus(StatusCode.ERROR, e.getMessage): Unit - throw e - finally - try if finalizer ne null then beforeClose(SpanWithError(finalizer.nn.span, actionableError)): Unit - catch - case e: Throwable => - if error ne null then error.nn.addSuppressed(e) - else throw e - finally if finalizer ne null then finalizer.nn.close() +extension [T](t: => T) + def span(tracer: Tracer)( + spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty, + beforeClose: SpanWithError[Nothing] => Span = _.span + ): T = + spanError[Nothing, [X] =>> X, T](t)(tracer, NoErrorMode)(spanName, spanKind, attributes, links, beforeClose) +end extension + +/** As [[span]], but `t` can also report an application error, in the sense of the given [[ox.ErrorMode]], e.g. a `Left`. Such an error is + * not thrown, so it's returned as is, after being recorded on the span: the span's status is set to `ERROR`, with the error's string + * representation as the description, and an `exception` event is added, with the error's type and message, or the exception itself, if the + * error is a [[Throwable]]. + * + * An application error carried by a `boundary.break`, e.g. the one used by `either`'s `.ok()`, is recorded in the same way, and the break + * is propagated. + * + * @param beforeClose + * callback executed right before closing the span, which receives the span, and the exception thrown by `t` or the application error it + * returned, if any. If it throws, the exception is added as suppressed to the one thrown by `t`, or to the application error, as defined + * by the error mode, or thrown if `t` succeeded. An application error carried by a `boundary.break` thrown by `beforeClose` is added to + * the application error returned by `t` as a suppressed error; if `t` succeeded, it is recorded on the span, and the break is + * propagated. + */ +extension [E, F[_], T](t: => F[T]) + def spanError(tracer: Tracer, em: ErrorMode[E, F])( + spanName: String, + spanKind: SpanKind = SpanKind.INTERNAL, + attributes: Attributes = Attributes.empty(), + links: Seq[SpanContext] = Seq.empty, + beforeClose: SpanWithError[E] => Span = (s: SpanWithError[E]) => s.span + ): F[T] = + val finalizer = spanUnsafe(tracer)(spanName, spanKind, attributes, links) + var thrown: Throwable | Null = null + var result: F[T] | Null = null + var actionableThrown: Option[Throwable] = None + var applicationError: Option[E] = None + try + try result = t + catch + case e: Throwable => + thrown = e + breakError(em, e) match + case Some(error) => + applicationError = Some(error) + recordApplicationError(finalizer.span, error) + case None => + if shouldRecordDefect(e) then + actionableThrown = Some(e) + finalizer.span.recordException(e) + finalizer.span.setStatus(StatusCode.ERROR, e.getMessage): Unit + end match + end try + + if thrown eq null then + val r = result.nn + if em.isError(r) then + val e = em.getError(r) + applicationError = Some(e) + recordApplicationError(finalizer.span, e) + + try beforeClose(SpanWithError(finalizer.span, actionableThrown, applicationError)): Unit + catch + case e: Throwable => + if thrown ne null then thrown.nn.addSuppressed(e) + else if applicationError.isDefined then + result = breakError(em, e) match + case Some(error) => em.addSuppressedError(result.nn, error) + case None => em.addSuppressedException(result.nn, e) + else + breakError(em, e).foreach(recordApplicationError(finalizer.span, _)) + throw e + end try + finally finalizer.close() end try - end try -end byNameSpan -private def shouldRecord(e: Throwable): Boolean = e match + if thrown ne null then throw thrown.nn else result.nn + end spanError +end extension + +/** The application error carried by a `boundary.break`, e.g. the one used by `either`'s `.ok()`, if the value is an error in the given + * mode. + */ +private def breakError[E, F[_]](em: ErrorMode[E, F], e: Throwable): Option[E] = e match + case b: boundary.Break[?] => + try + val casted = b.value.asInstanceOf[F[E]] + if em.isError(casted) then Some(em.getError(casted)) else None + catch case _: ClassCastException => None + case _ => None + +private def recordApplicationError(span: Span, e: Any): Unit = + e match + case t: Throwable => + span.recordException(t) + span.setStatus(StatusCode.ERROR, t.getMessage): Unit + case other => + val message = s"$other" + val tpe = Option(other).fold("null")(_.getClass.getName) + span.addEvent( + "exception", + Attributes.of(AttributeKey.stringKey("exception.type"), tpe, AttributeKey.stringKey("exception.message"), message) + ): Unit + span.setStatus(StatusCode.ERROR, message): Unit + +private def shouldRecordDefect(e: Throwable): Boolean = e match case _: InterruptedException | _: ClosedByInterruptException | _: ControlThrowable | _: boundary.Break[?] => false - case e: ExecutionException if e.getCause ne null => shouldRecord(e.getCause) - case _ => true + case e: ExecutionException if e.getCause ne null => shouldRecordDefect(e.getCause) + case _ => true diff --git a/otel/src/test/scala/OxTelemetryTest.scala b/otel/src/test/scala/OxTelemetryTest.scala index 37f2ae13..7d6e943f 100644 --- a/otel/src/test/scala/OxTelemetryTest.scala +++ b/otel/src/test/scala/OxTelemetryTest.scala @@ -22,6 +22,7 @@ import ox.telemetry.tracing.* import java.util.UUID import java.util.concurrent.Semaphore import scala.collection.JavaConverters.asScalaBufferConverter +import scala.util.boundary import scala.util.boundary.Label class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: @@ -105,12 +106,12 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: "Flow.span" when { "run normally" should { "not open a span until the flow is run" in new Fixture: - Flow.usingEmit[Int](emit => byNameSpan(myTracing)(flow.runToEmit(emit))(s"$prefix-$Level0")).buffer(): Unit + Flow.usingEmit[Int](emit => flow.runToEmit(emit).span(myTracing)(s"$prefix-$Level0")).buffer(): Unit assert(testSpans.isEmpty) assertNesting() "open a single span for a multi-element flow" in new Fixture: - Flow.usingEmit[Int](emit => byNameSpan(myTracing)(flow.runToEmit(emit))(s"$prefix-$Level0")).buffer().runDrain() + Flow.usingEmit[Int](emit => flow.runToEmit(emit).span(myTracing)(s"$prefix-$Level0")).buffer().runDrain() assert(spansOf(Level0).size == 1) assert(testSpans.size == 1) @@ -120,12 +121,12 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: supervised { val spanned1 = Flow - .usingEmit[Int](emit => byNameSpan(myTracing)(flow.runToEmit(emit))(s"$prefix-$Level1")) + .usingEmit[Int](emit => flow.runToEmit(emit).span(myTracing)(s"$prefix-$Level1")) .buffer() .map(_ + 100) .tap(i => currentSpanUnsafe().addEvent(i.toString)) - Flow.usingEmit[Int](emit => byNameSpan(myTracing)(spanned1.runToEmit(emit))(s"$prefix-$Level0")).buffer().runDrain() + Flow.usingEmit[Int](emit => spanned1.runToEmit(emit).span(myTracing)(s"$prefix-$Level0")).buffer().runDrain() assert(spansOf(Level0).size == 1) assert(spansOf(Level1).size == 1) @@ -135,10 +136,10 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: "open one span per span() call when spans are nested" in new Fixture: supervised { val spanned1 = - (Flow.usingEmit[Int](emit => byNameSpan(myTracing)(flow.runToEmit(emit))(s"$prefix-$Level1")).buffer() ++ Flow.fromValues(4, 2)) + (Flow.usingEmit[Int](emit => flow.runToEmit(emit).span(myTracing)(s"$prefix-$Level1")).buffer() ++ Flow.fromValues(4, 2)) .tap(i => currentSpanUnsafe().addEvent(i.toString)) - Flow.usingEmit[Int](emit => byNameSpan(myTracing)(spanned1.runToEmit(emit))(s"$prefix-$Level0")).buffer().runDrain() + Flow.usingEmit[Int](emit => spanned1.runToEmit(emit).span(myTracing)(s"$prefix-$Level0")).buffer().runDrain() assert(spansOf(Level0).size == 1) assert(spansOf(Level1).size == 1) @@ -147,7 +148,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: } "open a single span when downstream uses mapPar" in new Fixture: - Flow.usingEmit[Int](emit => byNameSpan(myTracing)(flow.runToEmit(emit))(s"$prefix-$Level0")).buffer().mapPar(3)(_ + 1).runDrain() + Flow.usingEmit[Int](emit => flow.runToEmit(emit).span(myTracing)(s"$prefix-$Level0")).buffer().mapPar(3)(_ + 1).runDrain() assert(testSpans.size == 1) @@ -161,7 +162,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: "use the current span as parent" in new Fixture: val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") - Flow.usingEmit[Int](emit => byNameSpan(myTracing)(flow.runToEmit(emit))(s"$prefix-$Level1")).buffer().runToList(): Unit + Flow.usingEmit[Int](emit => flow.runToEmit(emit).span(myTracing)(s"$prefix-$Level1")).buffer().runToList(): Unit parent.close() @@ -170,10 +171,10 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: assertNesting() "open one child span per element when flatMapped under a parent span" in new Fixture: - val spanned = Flow.usingEmit[Int](emit => byNameSpan(myTracing)(flow.runToEmit(emit))(s"$prefix-$Level1")).buffer() + val spanned = Flow.usingEmit[Int](emit => flow.runToEmit(emit).span(myTracing)(s"$prefix-$Level1")).buffer() Flow - .usingEmit[Int](emit => byNameSpan(myTracing)(flow.flatMap { _ => spanned }.runToEmit(emit))(s"$prefix-$Level0")) + .usingEmit[Int](emit => (flow.flatMap { _ => spanned }.runToEmit(emit)).span(myTracing)(s"$prefix-$Level0")) .buffer() .runToList(): Unit @@ -182,7 +183,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: assertNesting() "parent spans of concurrently run flows under the enclosing span" in new Fixture: - val spanned = Flow.usingEmit[Int](emit => byNameSpan(myTracing)(flow.runToEmit(emit))(s"$prefix-$Level1")).buffer() + val spanned = Flow.usingEmit[Int](emit => flow.runToEmit(emit).span(myTracing)(s"$prefix-$Level1")).buffer() val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") @@ -197,31 +198,32 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: assertNesting() "nest spans across forks and inner supervised scopes" in new Fixture: - val spanned = Flow.usingEmit[Int](emit => byNameSpan(myTracing)(flow.runToEmit(emit))(s"$prefix-$Level1")).buffer() + val spanned = Flow.usingEmit[Int](emit => flow.runToEmit(emit).span(myTracing)(s"$prefix-$Level1")).buffer() - byNameSpan(myTracing)(supervised { + span(supervised { (1 to 10).map { case 1 => forkUser( Flow .usingEmit[Int](emit => - byNameSpan(myTracing)( - flow - .flatMap { el => - val innerFlow = supervised { - Flow - .usingEmit[Int](emit => - byNameSpan(myTracing)(flow.runToEmit(emit))( + (flow + .flatMap { el => + val innerFlow = supervised { + Flow + .usingEmit[Int](emit => + flow + .runToEmit(emit) + .span(myTracing)( s"$prefix-$Level1_First_Child", - beforeClose = _._1.addEvent("boomed") + beforeClose = _.span.addEvent("boomed") ) - ) - .buffer() - } - innerFlow + ) + .buffer() } - .runToEmit(emit) - )(s"$prefix-$Level1_First") + innerFlow + } + .runToEmit(emit)) + .span(myTracing)(s"$prefix-$Level1_First") ) .buffer() .runToList() @@ -229,7 +231,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: case _ => forkUser(spanned.runToList()) } - })(s"$prefix-$Level0") + })(myTracing)(s"$prefix-$Level0") assert(spansOf(Level1).size == 9) assert(spansOf(Level1_First).size == 1) @@ -243,16 +245,15 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: try Flow .usingEmit[Int](emit => - byNameSpan(myTracing)( - flow - .tap { case 2 => throw new Exception("boom"); case _ => () } - .runToEmit(emit) - )( - s"$prefix-$Level0", - beforeClose = { case (s, thr) => - s.addEvent(thr.toString) - } - ) + (flow + .tap { case 2 => throw new Exception("boom"); case _ => () } + .runToEmit(emit)) + .span(myTracing)( + s"$prefix-$Level0", + beforeClose = { case SpanWithError(s, thr, _) => + s.addEvent(thr.toString) + } + ) ) .buffer() .runToList() @@ -268,16 +269,15 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: assertNesting() "keep the flow span as a child of the recovery span when failure is recovered" in new Fixture: - val spanned = Flow.usingEmit[Int](emit => byNameSpan(myTracing)(flow.runToEmit(emit))(s"$prefix-$Level1")).buffer() + val spanned = Flow.usingEmit[Int](emit => flow.runToEmit(emit).span(myTracing)(s"$prefix-$Level1")).buffer() Flow .usingEmit[Int](emit => - byNameSpan(myTracing)( - spanned - .map { case 2 => throw new Exception("boom"); case x => x } - .recover(_ => 5) - .runToEmit(emit) - )(s"$prefix-$Level0") + (spanned + .map { case 2 => throw new Exception("boom"); case x => x } + .recover(_ => 5) + .runToEmit(emit)) + .span(myTracing)(s"$prefix-$Level0") ) .buffer() .runToList(): Unit @@ -287,7 +287,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: assertNesting() "end spans of concurrent flows when one of them fails" in new Fixture: - val spanned = Flow.usingEmit[Int](emit => byNameSpan(myTracing)(flow.runToEmit(emit))(s"$prefix-$Level1")).buffer() + val spanned = Flow.usingEmit[Int](emit => flow.runToEmit(emit).span(myTracing)(s"$prefix-$Level1")).buffer() val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") @@ -310,7 +310,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: val level0 = spanUnsafe(myTracing)(s"$prefix-$Level0") - val spanned = Flow.usingEmit[Int](emit => byNameSpan(myTracing)(flow.runToEmit(emit))(s"$prefix-$Level1")).buffer() + val spanned = Flow.usingEmit[Int](emit => flow.runToEmit(emit).span(myTracing)(s"$prefix-$Level1")).buffer() try supervised { @@ -319,33 +319,34 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: forkUser { Flow .usingEmit[Int](emit => - byNameSpan(myTracing)( - flow - .flatMap { el => - { - val inner = supervisedError(em): - either: - val innerFlow = - em.pure( - Flow - .usingEmit[Int](emit => - byNameSpan(myTracing)(flow.runToEmit(emit))( + (flow + .flatMap { el => + { + val inner = supervisedError(em): + either: + val innerFlow = + em.pure( + Flow + .usingEmit[Int](emit => + flow + .runToEmit(emit) + .span(myTracing)( s"$prefix-$Level1_First_Child", - beforeClose = _._1.addEvent(s"boomed at $el") + beforeClose = _.span.addEvent(s"boomed at $el") ) - ) - .buffer() - .map { - case 1 => em.pureError("ops").ok(); - case x => x - } - ) - innerFlow.ok() - inner - }.toOption.get - } - .runToEmit(emit) - )(s"$prefix-$Level1_First") + ) + .buffer() + .map { + case 1 => em.pureError("ops").ok(); + case x => x + } + ) + innerFlow.ok() + inner + }.toOption.get + } + .runToEmit(emit)) + .span(myTracing)(s"$prefix-$Level1_First") ) .buffer() .runToList() @@ -365,7 +366,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: val level0 = spanUnsafe(myTracing)(s"$prefix-$Level0") - val spanned = Flow.usingEmit[Int](emit => byNameSpan(myTracing)(flow.runToEmit(emit))(s"$prefix-$Level1")).buffer() + val spanned = Flow.usingEmit[Int](emit => flow.runToEmit(emit).span(myTracing)(s"$prefix-$Level1")).buffer() try supervised { @@ -374,33 +375,34 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: forkUser { Flow .usingEmit[Int](emit => - byNameSpan(myTracing)( - flow - .flatMap { el => - { - val inner = supervisedError(em): - either: - val innerFlow = - em.pure( - Flow - .usingEmit[Int](emit => - byNameSpan(myTracing)(flow.runToEmit(emit))( + (flow + .flatMap { el => + { + val inner = supervisedError(em): + either: + val innerFlow = + em.pure( + Flow + .usingEmit[Int](emit => + flow + .runToEmit(emit) + .span(myTracing)( s"$prefix-$Level1_First_Child", - beforeClose = _._1.addEvent(s"boomed at $el") + beforeClose = _.span.addEvent(s"boomed at $el") ) - ) - .buffer() - .map { - case 1 => em.pureError("ops").ok(); - case x => x - } - ) - innerFlow.ok() - inner - }.toOption.getOrElse(Flow.empty) - } - .runToEmit(emit) - )(s"$prefix-$Level1_First") + ) + .buffer() + .map { + case 1 => em.pureError("ops").ok(); + case x => x + } + ) + innerFlow.ok() + inner + }.toOption.getOrElse(Flow.empty) + } + .runToEmit(emit)) + .span(myTracing)(s"$prefix-$Level1_First") ) .buffer() .runToList() @@ -420,7 +422,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: val level0 = spanUnsafe(myTracing)(s"$prefix-$Level0") - val spanned = Flow.usingEmit[Int](emit => byNameSpan(myTracing)(flow.runToEmit(emit))(s"$prefix-$Level1")).buffer() + val spanned = Flow.usingEmit[Int](emit => flow.runToEmit(emit).span(myTracing)(s"$prefix-$Level1")).buffer() try supervisedError(em) { @@ -431,31 +433,32 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: forkUser { Flow .usingEmit[Int](emit => - byNameSpan(myTracing)( - flow - .flatMap { el => - { - supervisedError(em): - val innerFlow = - em.pure( - Flow - .usingEmit[Int](emit => - byNameSpan(myTracing)(flow.runToEmit(emit))( + (flow + .flatMap { el => + { + supervisedError(em): + val innerFlow = + em.pure( + Flow + .usingEmit[Int](emit => + flow + .runToEmit(emit) + .span(myTracing)( s"$prefix-$Level1_First_Child", - beforeClose = _._1.addEvent(s"boomed at $el") + beforeClose = _.span.addEvent(s"boomed at $el") ) - ) - .buffer() - .map { - case 1 => em.pureError("ops").ok(); - case x => x - } - ) - innerFlow - }.toOption.get - } - .runToEmit(emit) - )(s"$prefix-$Level1_First") + ) + .buffer() + .map { + case 1 => em.pureError("ops").ok(); + case x => x + } + ) + innerFlow + }.toOption.get + } + .runToEmit(emit)) + .span(myTracing)(s"$prefix-$Level1_First") ) .buffer() .runToList() @@ -477,14 +480,13 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: Flow .usingEmit[Int](emit => - byNameSpan(myTracing)( - flow - .runToEmit(emit) - )(s"$prefix-$Level1") + (flow + .runToEmit(emit)) + .span(myTracing)(s"$prefix-$Level1") ) .buffer() .buffer() - .map(x => byNameSpan(myTracing)(x)(s"$prefix-$Level1_Stage")) + .map(x => x.span(myTracing)(s"$prefix-$Level1_Stage")) .runToList(): Unit parent.close() @@ -500,7 +502,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: val c = 1200.millis val (aMs, bMs, cMs) = (a.toMillis, b.toMillis, c.toMillis) - "open a span per file with mapSpanned, covering only the download" in new Fixture: + "open a span per file with span in map, covering only the download" in new Fixture: val filesToDownload = Seq(1, 2) def download(file: Int): Int = @@ -510,10 +512,10 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: def readLines(file: Int): Flow[String] = Flow.fromValues(s"line of $file").tap(_ => sleep(b)) def streamLinesFlow(file: Int) = - Flow.usingEmit[String](emit => byNameSpan(myTracing)(readLines(file).runToEmit(emit))(s"$prefix-$Level1_First")).buffer() + Flow.usingEmit[String](emit => readLines(file).runToEmit(emit).span(myTracing)(s"$prefix-$Level1_First")).buffer() (for - file <- Flow.fromIterable(filesToDownload).mapSpanned(myTracing)(s"$prefix-$Level1")(download) + file <- Flow.fromIterable(filesToDownload).map(file => download(file).span(myTracing)(s"$prefix-$Level1")) line <- streamLinesFlow(file).recoverWith(_ => streamLinesFlow(file)) yield line).tap(_ => sleep(c)).runToList(): Unit @@ -532,10 +534,10 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: (for x <- Flow.fromValues(1) y <- Flow - .usingEmit[Int](emit => byNameSpan(myTracing)(Flow.fromValues(x).tap(_ => sleep(a)).runToEmit(emit))(s"$prefix-$Level1")) + .usingEmit[Int](emit => Flow.fromValues(x).tap(_ => sleep(a)).runToEmit(emit).span(myTracing)(s"$prefix-$Level1")) .buffer() z <- Flow - .usingEmit[Int](emit => byNameSpan(myTracing)(Flow.fromValues(y).tap(_ => sleep(b)).runToEmit(emit))(s"$prefix-$Level1_First")) + .usingEmit[Int](emit => Flow.fromValues(y).tap(_ => sleep(b)).runToEmit(emit).span(myTracing)(s"$prefix-$Level1_First")) .buffer() yield z) .tap(_ => sleep(c)) @@ -591,13 +593,13 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: } - "spanEachDownstream" should { + "spanEach" should { "open a span per element, around the stages placed after it" in new Fixture: val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") flow - .spanEachDownstream(myTracing)(s"$prefix-$Level1_First") - .map(x => byNameSpan(myTracing)(x)(s"$prefix-$Level1_First_Child")) + .spanEach(myTracing)(s"$prefix-$Level1_First") + .map(x => x.span(myTracing)(s"$prefix-$Level1_First_Child")) .runToList(): Unit parent.close() @@ -610,8 +612,8 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") flow - .map(x => byNameSpan(myTracing)(x)(s"$prefix-$Level1")) - .spanEachDownstream(myTracing)(s"$prefix-$Level1_First") + .map(x => x.span(myTracing)(s"$prefix-$Level1")) + .spanEach(myTracing)(s"$prefix-$Level1_First") .runToList(): Unit parent.close() @@ -623,7 +625,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: "count the stages placed after it, also outside of the flatMap it is used in" in new Fixture: Flow .fromValues(1, 2, 3) - .flatMap(x => Flow.fromValues(x).spanEachDownstream(myTracing)(s"$prefix-$Level1").tap(_ => sleep(200.millis))) + .flatMap(x => Flow.fromValues(x).spanEach(myTracing)(s"$prefix-$Level1").tap(_ => sleep(200.millis))) .tap(_ => sleep(200.millis)) .runToList(): Unit @@ -633,7 +635,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: "end the span at a buffer placed after it" in new Fixture: Flow .fromValues(1, 2, 3) - .spanEachDownstream(myTracing)(s"$prefix-$Level1") + .spanEach(myTracing)(s"$prefix-$Level1") .tap(_ => sleep(200.millis)) .buffer() .tap(_ => sleep(200.millis)) @@ -643,30 +645,139 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: spansOf(Level1).foreach(s => assertAbout(durMs(s), 200)) } - "mapSpanned" should { - "open a span around each call of the mapping function only" in new Fixture: - val parent = spanUnsafe(myTracing)(s"$prefix-$Level0") + "spanError" should { + val em = EitherMode[String]() - flow - .mapSpanned(myTracing)(s"$prefix-$Level1_First")(x => byNameSpan(myTracing)(x + 1)(s"$prefix-$Level1_First_Child")) - .map(x => byNameSpan(myTracing)(x)(s"$prefix-$Level1")) - .runToList(): Unit + "return the result and not mark the span as failed when t succeeds" in new Fixture: + val result = Right(1).spanError(myTracing, em)(s"$prefix-$Level0") - parent.close() + assert(result == Right(1)) + val span = spansOf(Level0).head + assert(span.getStatus.getStatusCode == StatusCode.UNSET) + assert(span.getEvents.isEmpty) - assert(spansOf(Level1_First).size == 3) - // created by the function, so they are children of the per-element spans - assert(spansOf(Level1_First_Child).size == 3) - // created by the following stage, after the per-element spans were closed, so they are siblings - assert(spansOf(Level1).size == 3) - assertNesting() + "record an application error on the span and return it as is" in new Fixture: + val result = (Left("bad"): Either[String, Int]).spanError(myTracing, em)(s"$prefix-$Level0") - "mark the span as failed when the mapping function fails" in new Fixture: - try flow.mapSpanned(myTracing)(s"$prefix-$Level0") { case 2 => throw new Exception("boom"); case x => x }.runToList(): Unit - catch case _ => () + assert(result == Left("bad")) + val span = spansOf(Level0).head + assert(span.getStatus.getStatusCode == StatusCode.ERROR) + assert(span.getStatus.getDescription == "bad") + val event = span.getEvents.asScala.find(_.getName == "exception").get + assert(event.getAttributes.get(AttributeKey.stringKey("exception.message")) == "bad") + assert(event.getAttributes.get(AttributeKey.stringKey("exception.type")) == "java.lang.String") + + "record an application error which is an exception as an exception" in new Fixture: + val result = (Left(new Exception("boom")): Either[Exception, Int]).spanError(myTracing, EitherMode[Exception]())( + s"$prefix-$Level0" + ) + + assert(result.isLeft) + val span = spansOf(Level0).head + assert(span.getStatus.getStatusCode == StatusCode.ERROR) + assert(span.getStatus.getDescription == "boom") + val event = span.getEvents.asScala.find(_.getName == "exception").get + assert(event.getAttributes.get(AttributeKey.stringKey("exception.type")) == "java.lang.Exception") - assert(spansOf(Level0).size == 2) - assert(spansOf(Level0).count(_.getStatus.getStatusCode == StatusCode.ERROR) == 1) + "pass the application error to beforeClose" in new Fixture: + var seen: SpanWithError[String] | Null = null + + (Left("bad"): Either[String, Int]).spanError(myTracing, em)( + s"$prefix-$Level0", + beforeClose = s => + seen = s + s.span + ): Unit + + assert(seen.nn.errorOpt == Some("bad")) + assert(seen.nn.defectOpt.isEmpty) + + "record a thrown exception and pass it to beforeClose" in new Fixture: + var seen: SpanWithError[String] | Null = null + + val thrown = intercept[Exception]: + ((throw new Exception("boom")): Either[String, Int]).spanError(myTracing, em)( + s"$prefix-$Level0", + beforeClose = s => + seen = s + s.span + ): Unit + + assert(thrown.getMessage == "boom") + assert(seen.nn.defectOpt.map(_.getMessage) == Some("boom")) + assert(seen.nn.errorOpt.isEmpty) + val span = spansOf(Level0).head + assert(span.getStatus.getStatusCode == StatusCode.ERROR) + assert(span.getStatus.getDescription == "boom") + + "record an application error carried by a break, and propagate the break" in new Fixture: + var seen: SpanWithError[String] | Null = null + + val result: Either[String, Int] = either: + ({ + val x: Int = (Left("bad"): Either[String, Int]).ok() + Right(x) + }).spanError(myTracing, em)( + s"$prefix-$Level0", + beforeClose = s => + seen = s + s.span + ).ok() + + assert(result == Left("bad")) + val span = spansOf(Level0).head + assert(span.getStatus.getStatusCode == StatusCode.ERROR) + assert(span.getStatus.getDescription == "bad") + assert(seen.nn.errorOpt == Some("bad")) + assert(seen.nn.defectOpt.isEmpty) + + "record an application error carried by a break thrown by beforeClose, and propagate the break, when t succeeds" in new Fixture: + val result: Either[String, Either[String, Int]] = either: + (Right(1): Either[String, Int]).spanError(myTracing, em)( + s"$prefix-$Level0", + beforeClose = s => + (Left("cb"): Either[String, Unit]).ok() + s.span + ) + + assert(result == Left("cb")) + val span = spansOf(Level0).head + assert(span.getStatus.getStatusCode == StatusCode.ERROR) + assert(span.getStatus.getDescription == "cb") + + "return the application error of t when beforeClose breaks with another one" in new Fixture: + val result: Either[String, Either[String, Int]] = either: + (Left("bad"): Either[String, Int]).spanError(myTracing, em)( + s"$prefix-$Level0", + beforeClose = s => + (Left("cb"): Either[String, Unit]).ok() + s.span + ) + + assert(result == Right(Left("bad"))) + val span = spansOf(Level0).head + assert(span.getStatus.getDescription == "bad") + + "not mark the span as failed when a break carries a value which is not an error" in new Fixture: + val result = boundary[Either[String, Int]]: + (boundary.break(Right(1)): Either[String, Int]).spanError(myTracing, em)(s"$prefix-$Level0") + + assert(result == Right(1)) + assert(spansOf(Level0).head.getStatus.getStatusCode == StatusCode.UNSET) + + "return the application error, and close the span, when beforeClose throws" in new Fixture: + val result = + (Left("bad"): Either[String, Int]).spanError(myTracing, em)(s"$prefix-$Level0", beforeClose = _ => throw new Exception("cb")) + + assert(result == Left("bad")) + assert(spansOf(Level0).size == 1) + + "throw from beforeClose, and close the span, when t succeeds" in new Fixture: + val thrown = intercept[Exception]: + (Right(1): Either[String, Int]).spanError(myTracing, em)(s"$prefix-$Level0", beforeClose = _ => throw new Exception("cb")) + + assert(thrown.getMessage == "cb") + assert(spansOf(Level0).size == 1) } "spanStages per element" should { @@ -677,8 +788,8 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: flow .spanStages(myTracing)(s"$prefix-$Level1_First")(_.flatMap(x => - byNameSpan(myTracing)(())(s"$prefix-$Level1_First_Child") - Flow.fromValues(x).map(y => byNameSpan(myTracing)(y)(s"$prefix-$Level1_Stage")) + (()).span(myTracing)(s"$prefix-$Level1_First_Child") + Flow.fromValues(x).map(y => y.span(myTracing)(s"$prefix-$Level1_Stage")) )) .runToList(): Unit @@ -734,9 +845,9 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: flow .spanStages(myTracing)(s"$prefix-$Level1_First")( - _.mapConcat(x => 1 to x).map(y => byNameSpan(myTracing)(y)(s"$prefix-$Level1_First_Child")) + _.mapConcat(x => 1 to x).map(y => y.span(myTracing)(s"$prefix-$Level1_First_Child")) ) - .map(x => byNameSpan(myTracing)(x)(s"$prefix-$Level1")) + .map(x => x.span(myTracing)(s"$prefix-$Level1")) .runToList(): Unit parent.close() @@ -919,7 +1030,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: "open spans created in the body of the function as children of the span of the element" in new Fixture: flow .spanStages(myTracing)(s"$prefix-$Level1")(f => - byNameSpan(myTracing)(())(s"$prefix-$Level1_First") + (()).span(myTracing)(s"$prefix-$Level1_First") f ) .runToList(): Unit @@ -930,7 +1041,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: "open spans created by the stages as children of the span of the element" in new Fixture: flow - .spanStages(myTracing)(s"$prefix-$Level1")(_.map(x => byNameSpan(myTracing)(x)(s"$prefix-$Level1_First")).buffer()) + .spanStages(myTracing)(s"$prefix-$Level1")(_.map(x => x.span(myTracing)(s"$prefix-$Level1_First")).buffer()) .runToList(): Unit val parents = spansOf(Level1).map(_.getSpanId).toSet @@ -1000,7 +1111,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: } forkUser { Flow - .usingEmit[Int](emit => byNameSpan(myTracing)(flow.runToEmit(emit))(s"$prefix-$Level0")) + .usingEmit[Int](emit => flow.runToEmit(emit).span(myTracing)(s"$prefix-$Level0")) .buffer() .tap { case 1 => semaphore.release(); case _ => sleep(100.seconds) } .runToList() @@ -1019,7 +1130,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: supervised { val forkWithSpan = forkCancellable { Flow - .usingEmit[Int](emit => byNameSpan(myTracing)(flow.runToEmit(emit))(s"$prefix-$Level0")) + .usingEmit[Int](emit => flow.runToEmit(emit).span(myTracing)(s"$prefix-$Level0")) .buffer() .tap { case 1 => semaphore.release(); case _ => sleep(100.seconds) } .runToList() @@ -1036,7 +1147,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: "not mark the span as failed when the code exits with a boundary break" in new Fixture: val result = scala.util.boundary: - byNameSpan(myTracing)(scala.util.boundary.break(1))(s"$prefix-$Level0") + scala.util.boundary.break(1).span(myTracing)(s"$prefix-$Level0") 2 assert(result == 1) @@ -1046,16 +1157,16 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: "not pass a boundary break, a control throwable or an interruption to beforeClose" in new Fixture: var seen: List[Option[Throwable]] = Nil - def record(s: SpanWithError) = - seen = s.errorOpt :: seen + def record(s: SpanWithError[Nothing]) = + seen = s.defectOpt :: seen s.span scala.util.boundary: - byNameSpan(myTracing)(scala.util.boundary.break(1))(s"$prefix-$Level0", beforeClose = record) + scala.util.boundary.break(1).span(myTracing)(s"$prefix-$Level0", beforeClose = record) intercept[scala.util.control.ControlThrowable]: - byNameSpan(myTracing)(throw new scala.util.control.ControlThrowable {})(s"$prefix-$Level0", beforeClose = record) + (throw new scala.util.control.ControlThrowable {}).span(myTracing)(s"$prefix-$Level0", beforeClose = record) intercept[InterruptedException]: - byNameSpan(myTracing)(throw new InterruptedException())(s"$prefix-$Level0", beforeClose = record) + (throw new InterruptedException()).span(myTracing)(s"$prefix-$Level0", beforeClose = record) assert(seen == List(None, None, None)) assert(spansOf(Level0).size == 3) @@ -1064,10 +1175,10 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: "treat an interruption during I/O as not a failure" in new Fixture: var seen: Option[Throwable] = Some(new Exception("unset")) intercept[java.nio.channels.ClosedByInterruptException]: - byNameSpan(myTracing)(throw new java.nio.channels.ClosedByInterruptException())( + (throw new java.nio.channels.ClosedByInterruptException()).span(myTracing)( s"$prefix-$Level0", beforeClose = s => - seen = s.errorOpt + seen = s.defectOpt s.span ) @@ -1076,17 +1187,17 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: "record the closing of a channel by another thread as a failure" in new Fixture: intercept[java.nio.channels.AsynchronousCloseException]: - byNameSpan(myTracing)(throw new java.nio.channels.AsynchronousCloseException())(s"$prefix-$Level0") + (throw new java.nio.channels.AsynchronousCloseException()).span(myTracing)(s"$prefix-$Level0") assert(spansOf(Level0).head.getStatus.getStatusCode == StatusCode.ERROR) "treat an ExecutionException wrapping an interruption as not a failure" in new Fixture: var seen: Option[Throwable] = Some(new Exception("unset")) intercept[java.util.concurrent.ExecutionException]: - byNameSpan(myTracing)(throw new java.util.concurrent.ExecutionException(new InterruptedException()))( + (throw new java.util.concurrent.ExecutionException(new InterruptedException())).span(myTracing)( s"$prefix-$Level0", beforeClose = s => - seen = s.errorOpt + seen = s.defectOpt s.span ) @@ -1095,23 +1206,23 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: "treat an ExecutionException wrapping a failure as a failure" in new Fixture: intercept[java.util.concurrent.ExecutionException]: - byNameSpan(myTracing)(throw new java.util.concurrent.ExecutionException(new Exception("boom")))(s"$prefix-$Level0") + (throw new java.util.concurrent.ExecutionException(new Exception("boom"))).span(myTracing)(s"$prefix-$Level0") assert(spansOf(Level0).head.getStatus.getStatusCode == StatusCode.ERROR) "record fatal errors as failures" in new Fixture: intercept[LinkageError]: - byNameSpan(myTracing)(throw new LinkageError("broken"))(s"$prefix-$Level0") + (throw new LinkageError("broken")).span(myTracing)(s"$prefix-$Level0") assert(spansOf(Level0).head.getStatus.getStatusCode == StatusCode.ERROR) "pass a failure to beforeClose" in new Fixture: var seen: Option[Throwable] = None intercept[Exception]: - byNameSpan(myTracing)(throw new Exception("boom"))( + (throw new Exception("boom")).span(myTracing)( s"$prefix-$Level0", beforeClose = s => - seen = s.errorOpt + seen = s.defectOpt s.span ) @@ -1121,7 +1232,7 @@ class OxTelemetryTest extends AnyWordSpec with Matchers with BeforeAndAfterAll: val interruption = new InterruptedException() val thrown = intercept[InterruptedException]: - byNameSpan(myTracing)(throw interruption)(s"$prefix-$Level0", beforeClose = _ => throw new Exception("from beforeClose")) + (throw interruption).span(myTracing)(s"$prefix-$Level0", beforeClose = _ => throw new Exception("from beforeClose")) assert(thrown eq interruption) assert(thrown.getSuppressed.exists(_.getMessage == "from beforeClose"))