@zio.dev/zio-blocks 0.0.33 → 0.0.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/guides/compile-time-resource-safety-with-scope.md +16 -17
  2. package/guides/getting-started-with-mux.md +1507 -0
  3. package/guides/query-dsl-extending.md +161 -102
  4. package/guides/query-dsl-fluent-builder.md +217 -157
  5. package/guides/query-dsl-reified-optics.md +12 -10
  6. package/guides/query-dsl-sql.md +246 -165
  7. package/guides/telemetry-guide.md +1069 -0
  8. package/guides/zio-schema-migration.md +29 -22
  9. package/index.md +292 -50
  10. package/package.json +1 -1
  11. package/plans/config-follow-up-prs.md +188 -0
  12. package/plans/config-pr-assessment-roadmap.md +310 -0
  13. package/reference/MuxDataFlow.jsx +250 -0
  14. package/reference/async.md +651 -0
  15. package/reference/chunk.md +3533 -308
  16. package/reference/codegen/case-class.md +436 -0
  17. package/reference/codegen/emitter-config.md +383 -0
  18. package/reference/codegen/examples.md +664 -0
  19. package/reference/codegen/field.md +316 -0
  20. package/reference/codegen/index.md +317 -0
  21. package/reference/codegen/scala-emitter.md +392 -0
  22. package/reference/codegen/scala-file.md +276 -0
  23. package/reference/codegen/sealed-trait.md +408 -0
  24. package/reference/codegen/type-definition.md +340 -0
  25. package/reference/codegen/type-ref.md +201 -0
  26. package/reference/combinators.md +347 -117
  27. package/reference/config.md +158 -0
  28. package/reference/context.md +4 -4
  29. package/reference/datastar.md +346 -0
  30. package/reference/docs.md +1461 -345
  31. package/reference/endpoint/auth-type.md +146 -0
  32. package/reference/endpoint/endpoint.md +297 -0
  33. package/reference/endpoint/http-codec.md +249 -0
  34. package/reference/endpoint/index.md +825 -0
  35. package/reference/endpoint/path-codec.md +237 -0
  36. package/reference/endpoint/route-pattern.md +196 -0
  37. package/reference/endpoint/route-tree.md +111 -0
  38. package/reference/endpoint/segment-codec.md +212 -0
  39. package/reference/html.md +1120 -0
  40. package/reference/htmx/attribute-values.md +359 -0
  41. package/reference/htmx/hx-encoding.md +111 -0
  42. package/reference/htmx/hx-params.md +204 -0
  43. package/reference/htmx/hx-swap.md +276 -0
  44. package/reference/htmx/hx-sync.md +251 -0
  45. package/reference/htmx/hx-target.md +314 -0
  46. package/reference/htmx/hx-trigger.md +457 -0
  47. package/reference/htmx/hx-url-update.md +239 -0
  48. package/reference/htmx/index.md +855 -0
  49. package/reference/http-model/index.md +47 -0
  50. package/reference/http-model/model.md +1481 -0
  51. package/reference/http-model/schema.md +747 -0
  52. package/reference/maybe.md +826 -0
  53. package/reference/media-type.md +2 -2
  54. package/reference/mux.mdx +823 -0
  55. package/reference/openapi.md +1351 -0
  56. package/reference/resource-management/defer-handle.md +1 -1
  57. package/reference/resource-management/resource.md +31 -2
  58. package/reference/resource-management/scope.md +28 -12
  59. package/reference/resource-management/wire.md +3 -7
  60. package/reference/ringbuffer/MpmcDiagram.jsx +717 -0
  61. package/reference/ringbuffer/MpscDiagram.jsx +618 -0
  62. package/reference/ringbuffer/SpmcDiagram.jsx +680 -0
  63. package/reference/ringbuffer/SpscDiagram.jsx +677 -0
  64. package/reference/ringbuffer/advanced.mdx +109 -0
  65. package/reference/ringbuffer/index.mdx +145 -0
  66. package/reference/ringbuffer/mpmc.mdx +151 -0
  67. package/reference/ringbuffer/mpsc.mdx +132 -0
  68. package/reference/ringbuffer/spmc.mdx +108 -0
  69. package/reference/ringbuffer/spsc.mdx +344 -0
  70. package/reference/{allows.md → schema/allows.md} +4 -4
  71. package/reference/{binding-resolver.md → schema/binding-resolver.md} +1 -1
  72. package/reference/{binding.md → schema/binding.md} +2 -3
  73. package/reference/schema/built-in-codecs/avro.md +451 -0
  74. package/reference/schema/built-in-codecs/bson.md +480 -0
  75. package/reference/schema/built-in-codecs/csv.md +564 -0
  76. package/reference/schema/built-in-codecs/index.md +77 -0
  77. package/reference/schema/built-in-codecs/json/index.md +295 -0
  78. package/reference/schema/built-in-codecs/json/json-config.md +217 -0
  79. package/reference/{json-patch.md → schema/built-in-codecs/json/json-patch.md} +5 -5
  80. package/reference/{json-schema.md → schema/built-in-codecs/json/json-schema.md} +14 -47
  81. package/reference/schema/built-in-codecs/json/json-selection.md +322 -0
  82. package/reference/{json.md → schema/built-in-codecs/json/json.md} +32 -64
  83. package/reference/schema/built-in-codecs/messagepack.md +508 -0
  84. package/reference/schema/built-in-codecs/thrift.md +433 -0
  85. package/reference/schema/built-in-codecs/toon.md +1078 -0
  86. package/reference/{xml.md → schema/built-in-codecs/xml.md} +13 -9
  87. package/reference/schema/built-in-codecs/yaml.md +552 -0
  88. package/reference/{codec.md → schema/codec.md} +10 -10
  89. package/reference/{dynamic-optic.md → schema/dynamic-optic.md} +151 -5
  90. package/reference/{dynamic-schema.md → schema/dynamic-schema.md} +8 -8
  91. package/reference/schema/format.md +92 -0
  92. package/reference/schema/index.md +50 -0
  93. package/reference/schema/migration.md +297 -0
  94. package/reference/{modifier.md → schema/modifier.md} +58 -7
  95. package/reference/{optics.md → schema/optics.md} +2 -2
  96. package/reference/{patch.md → schema/patch.md} +1 -1
  97. package/{path-interpolator.md → reference/schema/path-interpolator.md} +165 -72
  98. package/reference/{schema-evolution → schema/schema-evolution}/as.md +8 -8
  99. package/reference/{schema-evolution → schema/schema-evolution}/index.md +2 -2
  100. package/reference/{schema-evolution → schema/schema-evolution}/into.md +8 -8
  101. package/reference/{schema-expr.md → schema/schema-expr.md} +110 -175
  102. package/reference/{schema.md → schema/schema.md} +12 -0
  103. package/reference/{structural-types.md → schema/structural-types.md} +1 -1
  104. package/reference/{type-class-derivation.md → schema/type-class-derivation.md} +63 -1
  105. package/reference/smithy.md +533 -0
  106. package/reference/sql/db-codec-deriver.md +71 -0
  107. package/reference/sql/db-codec.md +687 -0
  108. package/reference/sql/db-con.md +271 -0
  109. package/reference/sql/db-connection.md +153 -0
  110. package/reference/sql/db-param-writer.md +77 -0
  111. package/reference/sql/db-param.md +66 -0
  112. package/reference/sql/db-result-reader.md +146 -0
  113. package/reference/sql/db-tx.md +82 -0
  114. package/reference/sql/db-value.md +41 -0
  115. package/reference/sql/ddl.md +85 -0
  116. package/reference/sql/frag.md +254 -0
  117. package/reference/sql/index.md +341 -0
  118. package/reference/sql/repo.md +600 -0
  119. package/reference/sql/sql-dialect.md +73 -0
  120. package/reference/sql/sql-logger.md +62 -0
  121. package/reference/sql/sql-name-mapper.md +70 -0
  122. package/reference/sql/table-metadata.md +134 -0
  123. package/reference/sql/table.md +448 -0
  124. package/reference/sql/transactor-zio.md +399 -0
  125. package/reference/sql/transactor.md +353 -0
  126. package/reference/sql-zio.md +112 -0
  127. package/reference/streams/concurrent-operators.md +106 -0
  128. package/reference/streams/index.md +653 -0
  129. package/reference/streams/pipeline.md +718 -0
  130. package/reference/streams/reader.md +1284 -0
  131. package/reference/streams/scala-2-compatibility.md +55 -0
  132. package/reference/streams/sink.md +1426 -0
  133. package/reference/streams/stream.md +2526 -0
  134. package/reference/streams/writer.md +1045 -0
  135. package/reference/streams/zero-boxing.md +275 -0
  136. package/reference/telemetry.md +693 -0
  137. package/reference/typeid.md +5 -19
  138. package/sidebars.js +238 -43
  139. package/reference/formats.md +0 -694
  140. package/reference/http-model.md +0 -1716
  141. package/reference/streams.md +0 -989
  142. package/ringbuffer.md +0 -249
  143. /package/reference/{json-differ.md → schema/built-in-codecs/json/json-differ.md} +0 -0
  144. /package/reference/{dynamic-value.md → schema/dynamic-value.md} +0 -0
  145. /package/reference/{lazy.md → schema/lazy.md} +0 -0
  146. /package/reference/{reflect.md → schema/reflect.md} +0 -0
  147. /package/reference/{registers.md → schema/registers.md} +0 -0
  148. /package/reference/{schema-error.md → schema/schema-error.md} +0 -0
  149. /package/reference/{syntax.md → schema/syntax.md} +0 -0
  150. /package/reference/{validation.md → schema/validation.md} +0 -0
@@ -0,0 +1,1069 @@
1
+ ---
2
+ id: telemetry-guide
3
+ title: "Telemetry: Architecture, Patterns, and Real-World Usage"
4
+ ---
5
+
6
+ `zio-blocks-telemetry` is an effect-free, zero-allocation observability library that gives you structured logging, distributed tracing, and metrics without pulling in the OpenTelemetry SDK or any effect system. This guide explains why it works the way it does, shows what happens inside each operation, and walks through patterns for production systems.
7
+
8
+ If you're looking for API signatures and quick copy-paste snippets, the [Telemetry Reference](../reference/telemetry.md) has those. This guide focuses on the "how" and "why": architecture, design trade-offs, and the kind of understanding that helps when things go wrong at 3 AM.
9
+
10
+ ## Installation
11
+
12
+ ```scala
13
+ // Core: logging, tracing, metrics (JVM + JS)
14
+ libraryDependencies += "dev.zio" %% "zio-blocks-telemetry" % "0.0.51"
15
+
16
+ // OTLP JSON export over HTTP (JVM only)
17
+ libraryDependencies += "dev.zio" %% "zio-blocks-telemetry-otel" % "0.0.51"
18
+ ```
19
+
20
+ ---
21
+
22
+ ## Part 1: Architecture and Design Philosophy
23
+
24
+ ### Why effect-free?
25
+
26
+ Most Scala observability libraries wrap logging or tracing calls in an effect type. ZIO Logging needs `ZIO[R, E, Unit]`. Odin uses `F[Unit]`. Even scribe's direct API leans on Cats effect in production setups.
27
+
28
+ Effect-wrapping is a real cost. You can't call `log.info(...)` from a callback, a `Future`, a background thread, or a Java library without threading the effect runtime through. You can't log in a constructor, an `AutoCloseable.close()`, or any other place where effects are inconvenient.
29
+
30
+ `zio-blocks-telemetry` takes a different position: logging and metrics are inherently synchronous, low-level operations. They should work anywhere, with no ceremony. You import the package, call `log.info(...)`, and it works. The library is responsible for making that cheap, not for constraining where you can use it.
31
+
32
+ This is the same approach `logback` and `java.util.logging` take, but with structured attributes, zero boxing on the fast path, and a modern Scala macro interface.
33
+
34
+ ### The provider-instrument-processor pipeline
35
+
36
+ Every signal (log, span, metric) flows through the same three-stage pipeline:
37
+
38
+ ```
39
+ Call site
40
+ |
41
+ v
42
+ [Provider] -- creates and configures instruments --
43
+ | LoggerProvider
44
+ | TracerProvider
45
+ | MeterProvider
46
+ v
47
+ [Instrument] -- the thing you call at call sites --
48
+ | Logger (wraps LogRecord)
49
+ | Tracer (wraps Span)
50
+ | Counter / Histogram / Gauge
51
+ v
52
+ [Processor] -- receives the completed signal --
53
+ | LogRecordProcessor
54
+ | SpanProcessor
55
+ | MetricReader (pull-based)
56
+ v
57
+ [Exporter] -- sends data out of process --
58
+ | OtlpJsonLogExporter
59
+ | OtlpJsonTraceExporter
60
+ | OtlpJsonMetricExporter
61
+ v
62
+ OTLP collector / file / console
63
+ ```
64
+
65
+ Processors are composable: you can chain several. A `LoggerProvider` built with `.addLogRecordProcessor(console).addLogRecordProcessor(otelExporter)` will call both processors for every log record.
66
+
67
+ The `log` object and the `Global*` singletons sit in front of this pipeline. They hold a reference to the current provider (an `AtomicReference`) and forward calls through it. You can swap the provider at runtime via `install()` without touching any call sites.
68
+
69
+ ### Zero-allocation design
70
+
71
+ Two separate things must both be cheap: the fast path when a level is disabled, and the slow path when it's enabled.
72
+
73
+ **Disabled level fast path.** The first check in every `log.*` call is:
74
+
75
+ ```scala
76
+ if (severity.number >= log.globalMinLevel) { ... }
77
+ ```
78
+
79
+ `globalMinLevel` is a `@volatile Int`. Reading it costs roughly one memory fence. If the level is disabled, the compiler has already eliminated all argument evaluation (the arguments are passed by-name or inlined via macro). No string is built, no object is allocated.
80
+
81
+ **Enabled level slow path.** When a level is enabled, attribute pairs need to be assembled into a `LogRecord`. The library uses a pooled `AttributeBuilder` (one per thread, reused), backed by parallel primitive arrays. Primitive values (Long, Double, Boolean) are stored as unboxed longs using type discriminator bytes. A `(String, Long)` attribute doesn't box the Long.
82
+
83
+ For metrics, `Counter` uses a `ConcurrentHashMap[Attributes, LongAdder]`. `LongAdder` is Java's striped counter, designed for high-concurrency increment without contention. `Gauge` uses `AtomicLong` storing the double bits directly. `Histogram` uses `ReentrantLock` per attribute set because it needs to update count, sum, min, max, and bucket counts atomically.
84
+
85
+ **What actually allocates on the slow path:**
86
+ - `Attributes.empty` is a singleton: no allocation
87
+ - Attribute builders are pooled: no allocation per record
88
+ - The `LogRecord` itself is allocated (unavoidably), but only when the level is enabled
89
+ - `SpanContext` stores trace ID as two `Long` fields, not a `UUID` object
90
+
91
+ ### Global singletons vs explicit provider wiring
92
+
93
+ The three global entry points (`log`, `trace`, `metric`) work without any setup. On first use, each creates a sensible default:
94
+
95
+ - `log` creates a `ConsoleLogRecordProcessor` that writes to stdout
96
+ - `trace` creates an `InMemorySpanProcessor` that stores spans in a ring buffer (useful for testing)
97
+ - `metric` creates a basic `MeterProvider` with a `MetricReader`
98
+
99
+ For tests and exploratory code, the defaults are exactly right. For production, call `install()` once at startup to replace each default with your configured provider.
100
+
101
+ The global singletons are intentionally not safe to use across threads during `install()`. Call `install()` before starting any request processing. After installation, the `AtomicReference` read on every call is safe and cheap.
102
+
103
+ ---
104
+
105
+ ## Part 2: Logging Deep Dive
106
+
107
+ ### Getting started
108
+
109
+ The simplest possible start:
110
+
111
+ ```scala
112
+ import zio.blocks.telemetry._
113
+
114
+ log.info("server started")
115
+ ```
116
+
117
+ No imports beyond the package. No provider setup. Output goes to stdout as human-readable text. This is intentional: the zero-configuration default is production-safe for development.
118
+
119
+ Moving to a more realistic setup:
120
+
121
+ ```scala
122
+ import zio.blocks.telemetry._
123
+
124
+ // Structured attributes alongside the message
125
+ log.info("user signed in", "user_id" -> "u-7890", "method" -> "oauth2")
126
+
127
+ // Numbers are unboxed on the fast path
128
+ log.info("payment processed", "amount_cents" -> 4999L, "currency" -> "EUR")
129
+
130
+ // Exceptions get type, message, and stacktrace automatically
131
+ log.error("payment failed", new IllegalStateException("card declined"))
132
+
133
+ // Mix freely
134
+ log.warn(
135
+ "upstream degraded",
136
+ "service" -> "inventory",
137
+ "latency_ms" -> 850L,
138
+ new RuntimeException("timeout")
139
+ )
140
+ ```
141
+
142
+ Adding a file output at startup:
143
+
144
+ ```scala
145
+ import zio.blocks.telemetry._
146
+
147
+ // Human-readable text to one file, OTLP JSON to another
148
+ log.writer(TextLogFormatter, FileLogWriter("logs/app.log"))
149
+ log.writer(JsonLogFormatter, FileLogWriter("logs/app.jsonl"))
150
+ ```
151
+
152
+ Both outputs are active simultaneously. `writer()` is additive.
153
+
154
+ ### Macro mechanics explained
155
+
156
+ Every `log.*` method is a Scala inline macro. The macro generates something like this for `log.info("msg", "key" -> 42L)`:
157
+
158
+ ```
159
+ Conceptual expansion of:
160
+ log.info("request received", "user_id" -> userId, "attempt" -> 3)
161
+
162
+ Expands to (pseudocode):
163
+ if (Severity.Info.number >= log.globalMinLevel) {
164
+ val state = log.getState()
165
+ if (state != null && Severity.Info.number >= state.effectiveLevel(<current class name>)) {
166
+ val now = EpochClock.epochNanos()
167
+ val builder = AttributeBuilderPool.get()
168
+ .put("code.filepath", "<source file>")
169
+ .put("code.namespace", "<current package>")
170
+ .put("code.function", "<current method>")
171
+ .put("code.lineno", <line number>.toLong)
172
+ // for each enrichment argument, the macro calls the right builder method:
173
+ builder.put("user_id", userId) // resolved as (String, String)
174
+ builder.put("attempt", 3.toLong) // resolved as (String, Int) -> toLong
175
+ // reads annotations from scoped context
176
+ val annotations = LogAnnotations.get()
177
+ // reads span context from ContextStorage
178
+ val spanCtx = state.logger.currentSpanContext()
179
+ state.logger.emitRaw(now, Severity.Info, "INFO", "request received",
180
+ builder, traceIdHi, traceIdLo, spanId, traceFlags,
181
+ resource, scope, None)
182
+ }
183
+ }
184
+ ```
185
+
186
+ The important things the macro does:
187
+ - Captures `code.filepath`, `code.namespace`, `code.function`, `code.lineno` at compile time
188
+ - Resolves each enrichment argument type at compile time and calls the specific builder method (no runtime dispatch on the fast path)
189
+ - For rate-limited variants (`infoEvery`, `warnAtMost`), synthesizes a `val counter` or `val lastEmit` at the call site so each call site has independent state
190
+
191
+ For `*Every(N, msg)`, the macro synthesizes:
192
+
193
+ ```scala
194
+ // Synthesized at call site by the macro
195
+ val _counter_42 = new java.util.concurrent.atomic.AtomicLong(0L)
196
+
197
+ // At each call:
198
+ if (_counter_42.getAndIncrement() % N == 0) { log.emit(...) }
199
+ ```
200
+
201
+ For `*AtMost(windowMs, msg)`, it's:
202
+
203
+ ```scala
204
+ val _lastEmit_77 = new java.util.concurrent.atomic.AtomicLong(0L)
205
+
206
+ val now = System.currentTimeMillis()
207
+ val last = _lastEmit_77.get()
208
+ if (now - last >= windowMs && _lastEmit_77.compareAndSet(last, now)) { log.emit(...) }
209
+ ```
210
+
211
+ ### Custom enrichment types
212
+
213
+ The `LogEnrichment[A]` typeclass lets the macro delegate argument resolution to your code.
214
+
215
+ Say you want to log `UUID` values directly:
216
+
217
+ ```scala
218
+ import zio.blocks.telemetry._
219
+ import java.util.UUID
220
+
221
+ // The implicit must be in scope at log call sites
222
+ implicit val uuidEnrichment: LogEnrichment[(String, UUID)] =
223
+ new LogEnrichment[(String, UUID)] {
224
+ def enrich(record: LogRecord, value: (String, UUID)): LogRecord =
225
+ record.copy(
226
+ attributes = record.attributes ++ Attributes.of(
227
+ AttributeKey.string(value._1), value._2.toString
228
+ )
229
+ )
230
+ }
231
+
232
+ // Now this compiles and works
233
+ val requestId = UUID.randomUUID()
234
+ log.info("request started", "request_id" -> requestId)
235
+ ```
236
+
237
+ For `java.time.Instant`:
238
+
239
+ ```scala
240
+ import zio.blocks.telemetry._
241
+ import java.time.Instant
242
+
243
+ implicit val instantEnrichment: LogEnrichment[(String, Instant)] =
244
+ new LogEnrichment[(String, Instant)] {
245
+ def enrich(record: LogRecord, value: (String, Instant)): LogRecord =
246
+ record.copy(
247
+ attributes = record.attributes ++ Attributes.of(
248
+ AttributeKey.string(value._1), value._2.toString
249
+ )
250
+ )
251
+ }
252
+
253
+ log.info("event occurred", "timestamp" -> Instant.now())
254
+ ```
255
+
256
+ **Performance note.** The built-in enrichments for `(String, String)`, `(String, Long)`, `(String, Int)`, `(String, Double)`, and `(String, Boolean)` go through the attribute builder's primitive path: no `AttributeValue` boxing. Custom enrichments through `LogEnrichment` go through `record.copy(attributes = ...)`, which does allocate an `Attributes` merge. For very hot paths, prefer the built-in types.
257
+
258
+ ### Multiple outputs simultaneously
259
+
260
+ Every call to `log.writer(formatter, writer)` adds another output. The state is maintained inside the `log` object and composited into the active logger:
261
+
262
+ ```scala
263
+ import zio.blocks.telemetry._
264
+
265
+ // Setup at startup; all three are active simultaneously
266
+ log.writer(TextLogFormatter, FileLogWriter("logs/app.log"))
267
+ log.writer(JsonLogFormatter, FileLogWriter("logs/app.jsonl"))
268
+ // The OTEL exporter is a LogRecordProcessor, added via LoggerProvider (see Part 5)
269
+ ```
270
+
271
+ Internally, `log.writer(...)` creates a `FormattedLogRecordProcessor` (which pairs a formatter with a writer) and calls `rebuildState()`, which reconstructs the active `Logger` with the accumulated processor list. The original provider-based processors are preserved alongside the writer processors.
272
+
273
+ To remove all file writers:
274
+
275
+ ```scala
276
+ log.clearWriters()
277
+ ```
278
+
279
+ This shuts down each `FormattedLogRecordProcessor` cleanly and reverts to processor-only routing.
280
+
281
+ ### Log output format comparison
282
+
283
+ The same call:
284
+
285
+ ```scala
286
+ log.warn("slow query", "table" -> "orders", "duration_ms" -> 450L)
287
+ ```
288
+
289
+ **TextLogFormatter output:**
290
+ ```
291
+ 2026-05-14T09:30:00.123Z WARN [PaymentService.handleRequest:87] slow query {table="orders", duration_ms=450}
292
+ ```
293
+
294
+ **JsonLogFormatter output:**
295
+ ```json
296
+ {"timeUnixNano":"1747216200123000000","severityNumber":13,"severityText":"WARN","body":{"stringValue":"slow query"},"attributes":[{"key":"code.filepath","value":{"stringValue":"PaymentService.scala"}},{"key":"code.namespace","value":{"stringValue":"com.example.PaymentService"}},{"key":"code.function","value":{"stringValue":"handleRequest"}},{"key":"code.lineno","value":{"intValue":"87"}},{"key":"table","value":{"stringValue":"orders"}},{"key":"duration_ms","value":{"intValue":"450"}}]}
297
+ ```
298
+
299
+ The JSON format follows the OTLP log data model directly, making it compatible with any OTLP-aware log processor (Grafana Loki, OpenTelemetry Collector, etc.).
300
+
301
+ The text formatter caches the timestamp prefix per second (the part before milliseconds) using two `@volatile` fields, so it avoids repeated date formatting work on high-throughput paths.
302
+
303
+ ### Testing with telemetry
304
+
305
+ For unit tests, you usually want to assert that specific log calls happened. The cleanest approach is a custom `LogRecordProcessor`:
306
+
307
+ ```scala
308
+ import zio.blocks.telemetry._
309
+ import scala.collection.concurrent.TrieMap
310
+ import java.util.concurrent.CopyOnWriteArrayList
311
+
312
+ class CapturingLogProcessor extends LogRecordProcessor {
313
+ val records = new CopyOnWriteArrayList[LogRecord]()
314
+
315
+ def onEmit(record: LogRecord): Unit = records.add(record)
316
+ def shutdown(): Unit = records.clear()
317
+ def forceFlush(): Unit = ()
318
+ }
319
+
320
+ // In your test setup:
321
+ val capturing = new CapturingLogProcessor()
322
+
323
+ val loggerProvider = LoggerProvider.builder
324
+ .addLogRecordProcessor(capturing)
325
+ .build()
326
+
327
+ log.install(loggerProvider.get("test"))
328
+
329
+ // Call the code under test
330
+ MyService.processPayment(...)
331
+
332
+ // Assert on the captured records
333
+ val warns = capturing.records.toArray.collect {
334
+ case r: LogRecord if r.severity == Severity.Warn => r
335
+ }
336
+ assert(warns.exists(_.body.value.contains("payment")))
337
+ ```
338
+
339
+ Don't forget to restore state between tests:
340
+
341
+ ```scala
342
+ log.uninstall() // reverts to default console logger
343
+ ```
344
+
345
+ ---
346
+
347
+ ## Part 3: Distributed Tracing Deep Dive
348
+
349
+ ### Trace lifecycle
350
+
351
+ When you call `tracer.span("name") { span => ... }`, here's the complete sequence:
352
+
353
+ 1. **Read parent context.** `contextStorage.get()` returns `Option[SpanContext]`. If a span is already active on this thread (set by a parent `span` call), it becomes the parent.
354
+
355
+ 2. **Sampler decision.** `sampler.shouldSample(...)` is called with the parent context, new trace ID bits, span name, and kind. The result is one of `Drop`, `RecordOnly`, or `RecordAndSample`.
356
+
357
+ 3. **Drop path.** If `Drop`, your block runs with `Span.NoOp`. All `setAttribute`, `addEvent`, `setStatus` calls are no-ops. Zero allocation.
358
+
359
+ 4. **Record path.** A `RecordingSpan` is built with a new `SpanContext`. The span gets:
360
+ - The parent's `traceIdHi`/`traceIdLo` if a valid parent exists, or a fresh random pair
361
+ - A fresh random `spanId`
362
+ - `traceFlags` set to `sampled` (for `RecordAndSample`) or `none` (for `RecordOnly`)
363
+
364
+ 5. **Processors on start.** Each `SpanProcessor.onStart(span)` is called.
365
+
366
+ 6. **Context storage scoping.** `contextStorage.scoped(Some(span.spanContext)) { f(span) }` runs your block. While your block is executing, any inner `span` calls will see this span as their parent.
367
+
368
+ 7. **End and export.** When the block exits (normally or via exception), `span.end()` records the end timestamp. Then `SpanProcessor.onEnd(spanData)` is called on each processor. The OTLP exporter's `onEnd` enqueues the `SpanData` in the `BatchProcessor`.
369
+
370
+ ```
371
+ tracer.span("checkout") { span =>
372
+ |
373
+ +-- sampler.shouldSample() -> RecordAndSample
374
+ |
375
+ +-- SpanProcessor.onStart(span) [each processor]
376
+ |
377
+ +-- contextStorage.scoped(Some(span.spanContext)) {
378
+ | tracer.span("validate-cart") { ... } // sees checkout as parent
379
+ | tracer.span("charge-card") { ... } // sees checkout as parent
380
+ | }
381
+ |
382
+ +-- span.end()
383
+ |
384
+ +-- SpanProcessor.onEnd(spanData) [each processor]
385
+ |
386
+ +-- OtlpJsonTraceExporter.onEnd(spanData)
387
+ |
388
+ +-- BatchProcessor.enqueue(spanData)
389
+ |
390
+ (background flush every 5s or at maxBatchSize)
391
+ |
392
+ +-- HTTP POST /v1/traces to OTLP collector
393
+ ```
394
+
395
+ ### Sampling strategies
396
+
397
+ **`AlwaysOnSampler`** returns `RecordAndSample` for every span. Use this in development or when you have low enough traffic that 100% sampling is affordable. It's the default.
398
+
399
+ **`AlwaysOffSampler`** returns `Drop` for every span. Useful when you want tracing code paths to exist but not actually produce data, for instance in a test that doesn't care about traces.
400
+
401
+ **`ParentBasedSampler(root)`** defers to the parent's sampling decision. If there's no parent, it falls back to `root`. If there is a parent:
402
+ - Parent sampled (`traceFlags.isSampled == true`) → `RecordAndSample`
403
+ - Parent not sampled → `Drop`
404
+
405
+ This is the right choice for most production services. The gateway or entry service decides sampling, and all downstream services follow automatically. Configure it with `AlwaysOnSampler` as the root if you want the gateway to sample everything, or with a `RateLimitedSampler` (if you implement one) for head-based sampling:
406
+
407
+ ```scala
408
+ import zio.blocks.telemetry._
409
+
410
+ val tracerProvider = TracerProvider.builder
411
+ .setSampler(ParentBasedSampler(root = AlwaysOnSampler))
412
+ .addSpanProcessor(yourExporter)
413
+ .build()
414
+
415
+ trace.install(tracerProvider)
416
+ ```
417
+
418
+ ### SpanContext internals
419
+
420
+ ```scala
421
+ final case class SpanContext(
422
+ traceIdHi: Long, // high 64 bits of the 128-bit trace ID
423
+ traceIdLo: Long, // low 64 bits of the 128-bit trace ID
424
+ spanId: SpanId, // AnyVal wrapping a Long
425
+ traceFlags: TraceFlags, // AnyVal wrapping a Byte
426
+ traceState: String,
427
+ isRemote: Boolean
428
+ )
429
+ ```
430
+
431
+ The 128-bit trace ID is stored as two `Long` fields rather than a `UUID` object. A `UUID` would be 32 bytes of heap allocation per span, plus GC pressure. Two `Long` fields on a case class cost nothing beyond the case class itself, and they're JIT-friendly (no dereference).
432
+
433
+ A fresh trace ID is generated by `TraceId.random()`, which calls `ThreadLocalRandom.current().nextLong()` twice. `ThreadLocalRandom` is faster than `Random` in concurrent scenarios because it doesn't share state between threads.
434
+
435
+ `SpanId` is an `AnyVal` wrapping a single `Long`, similarly generated by `ThreadLocalRandom.current().nextLong()`. At runtime on the JVM, `AnyVal`s are unboxed wherever possible, so a `SpanId` in a `SpanContext` field costs nothing extra.
436
+
437
+ `traceIdHex` is computed on demand by `TraceId.toHex(traceIdHi, traceIdLo)`, which formats the two longs into 32 hex characters. No caching: hex formatting is only needed when building headers or log output.
438
+
439
+ ### Context propagation across process boundaries
440
+
441
+ Here's a complete round trip. The server receives an HTTP request with W3C `traceparent`, continues the trace, and the client injects context into an outbound call.
442
+
443
+ **HTTP server (receiving a trace):**
444
+
445
+ ```scala
446
+ package zio.blocks.telemetry.otel
447
+
448
+ import zio.blocks.telemetry._
449
+
450
+ // Simulate incoming HTTP headers
451
+ val incomingHeaders = Map(
452
+ "traceparent" -> "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
453
+ )
454
+
455
+ // Extract SpanContext from the traceparent header
456
+ val parentCtx: Option[SpanContext] =
457
+ W3CTraceContextPropagator.extract(incomingHeaders, (m, k) => m.get(k))
458
+
459
+ val tracer = trace.get("api-service")
460
+
461
+ // Wire the extracted context as the parent by scoping it in ContextStorage
462
+ val tracerProvider = TracerProvider.builder
463
+ .addSpanProcessor(yourExporter)
464
+ .build()
465
+
466
+ // Use the provider's context storage to set the remote parent
467
+ val contextStorage = tracerProvider.contextStorage
468
+
469
+ contextStorage.scoped(parentCtx) {
470
+ tracer.span("handle-checkout", SpanKind.Server) { span =>
471
+ span.setAttribute("http.method", "POST")
472
+ span.setAttribute("http.route", "/checkout")
473
+ // Your handler logic here
474
+ processCheckout()
475
+ }
476
+ }
477
+ ```
478
+
479
+ **HTTP client (injecting trace into outbound call):**
480
+
481
+ ```scala
482
+ package zio.blocks.telemetry.otel
483
+
484
+ import zio.blocks.telemetry._
485
+
486
+ val tracer = trace.get("api-service")
487
+
488
+ tracer.span("call-inventory", SpanKind.Client) { span =>
489
+ // Inject the current span's context into outbound headers
490
+ val headers = W3CTraceContextPropagator.inject(
491
+ span.spanContext,
492
+ Map.empty[String, String],
493
+ (carrier, k, v) => carrier + (k -> v)
494
+ )
495
+
496
+ // headers now contains: "traceparent" -> "00-<traceId>-<spanId>-01"
497
+ httpClient.post("http://inventory-service/check", headers)
498
+ }
499
+ ```
500
+
501
+ The `traceparent` header encodes: `{version}-{traceId32hex}-{spanId16hex}-{flags2hex}`. The inventory service receives this, calls `extract`, gets back a `SpanContext` with `isRemote = true`, and starts its own child span under the same trace.
502
+
503
+ For Zipkin/B3 compatibility, swap `W3CTraceContextPropagator` for `B3Propagator.single` or `B3Propagator.multi`.
504
+
505
+ ### Testing traces
506
+
507
+ The default `trace` provider stores spans in an in-memory processor:
508
+
509
+ ```scala
510
+ import zio.blocks.telemetry._
511
+
512
+ // Reset before each test
513
+ trace.clearSpans()
514
+
515
+ // Call code under test
516
+ val tracer = trace.get("test")
517
+ tracer.span("outer") { _ =>
518
+ tracer.span("inner") { span =>
519
+ span.setAttribute("result", "ok")
520
+ }
521
+ }
522
+
523
+ // Inspect what was recorded
524
+ val spans = trace.collectedSpans
525
+
526
+ assert(spans.size == 2)
527
+ val inner = spans.find(_.name == "inner").get
528
+ assert(inner.attributes.get(AttributeKey.string("result")) == Some("ok"))
529
+
530
+ // Check parent-child relationship
531
+ val outer = spans.find(_.name == "outer").get
532
+ assert(inner.parentSpanContext.spanId == outer.spanContext.spanId)
533
+ ```
534
+
535
+ `trace.collectedSpans` returns a `List[SpanData]` in the order spans ended. No setup needed; this works out of the box.
536
+
537
+ ### Span-log correlation explained
538
+
539
+ When you log inside an active span, the logger automatically attaches `traceId` and `spanId` to the record. This works because both the `Logger` and the `Tracer` share the same `ContextStorage[Option[SpanContext]]`.
540
+
541
+ At provider creation time:
542
+
543
+ ```scala
544
+ // TracerProvider.builder.build() creates a ContextStorage
545
+ val cs = ContextStorage.create[Option[SpanContext]](None)
546
+ val tracerProvider = new TracerProvider(resource, sampler, processors, cs)
547
+
548
+ // LoggerProvider can share the same ContextStorage
549
+ val loggerProvider = LoggerProvider.builder
550
+ .setContextStorage(cs) // <-- same instance
551
+ .build()
552
+ ```
553
+
554
+ When `tracer.span(...)` runs, it calls `contextStorage.scoped(Some(span.spanContext)) { ... }`. This scopes the span context for the duration of the block using `ScopedValue` (on JDK 25+) or `ThreadLocal`. Inside that block, `logger.currentSpanContext()` reads from the same storage and sees the active span. No explicit linkage code at the call site.
555
+
556
+ If you use the global singletons without explicitly sharing a `ContextStorage`, the default logger gets its own context storage, separate from the default tracer's. Span-log correlation only works automatically when you wire them to share one. The OTEL module's production setup example (Part 5) shows how to do this correctly.
557
+
558
+ ---
559
+
560
+ ## Part 4: Metrics Deep Dive
561
+
562
+ ### Instrument selection guide
563
+
564
+ | What you're measuring | Instrument | Example |
565
+ |---|---|---|
566
+ | Monotonically increasing total | `Counter` | Total requests served |
567
+ | Value that can go up or down | `UpDownCounter` | Active connections, queue depth |
568
+ | Distribution of values | `Histogram` | Request latency, payload size |
569
+ | Current point-in-time value | `Gauge` | CPU usage, memory used, pool size |
570
+
571
+ More specifically:
572
+
573
+ - Use **`Counter`** when you're counting things that only ever increase: requests processed, errors encountered, bytes sent. Negative deltas are silently ignored.
574
+ - Use **`UpDownCounter`** when the value can decrease: pending jobs in a queue (enqueue = +1, dequeue = -1), active WebSocket connections.
575
+ - Use **`Histogram`** when you care about the distribution, not just the total: latency percentiles (p50, p95, p99), request sizes. Histograms record min, max, sum, count, and configurable bucket counts.
576
+ - Use **`Gauge`** when you're sampling the current value of something external: a JVM heap usage check, a pool size read from a connection pool object. Last-write wins.
577
+
578
+ ```scala
579
+ import zio.blocks.telemetry._
580
+
581
+ val requests = metric.counter("http.requests.total")
582
+ val activeConns = metric.upDownCounter("http.connections.active")
583
+ val latency = metric.histogram("http.request.duration_ms")
584
+ val heapUsed = metric.gauge("jvm.heap.used_bytes")
585
+
586
+ // Counter: only increases
587
+ requests.add(1, "method" -> "GET", "status" -> "200")
588
+
589
+ // UpDownCounter: tracks net change
590
+ activeConns.add(1) // connection opened
591
+ activeConns.add(-1) // connection closed
592
+
593
+ // Histogram: records the value into the appropriate bucket
594
+ latency.record(42.5, "method" -> "GET")
595
+
596
+ // Gauge: always reflects the current value
597
+ heapUsed.record(Runtime.getRuntime.totalMemory() - Runtime.getRuntime.freeMemory())
598
+ ```
599
+
600
+ ### Labeled instruments with pre-bound attributes
601
+
602
+ When you record against the same label combination at high frequency, pre-binding avoids the `Attributes` lookup on every call:
603
+
604
+ ```scala
605
+ import zio.blocks.telemetry._
606
+
607
+ val requests = metric.counter("http.requests.total")
608
+
609
+ // Bind once, typically at initialization
610
+ val getOk = requests.bind(Attributes.builder.put("method", "GET").put("status", "200").build)
611
+ val get404 = requests.bind(Attributes.builder.put("method", "GET").put("status", "404").build)
612
+ val postOk = requests.bind(Attributes.builder.put("method", "POST").put("status", "201").build)
613
+
614
+ // Hot path: just add to the pre-bound LongAdder
615
+ def onGetOk(): Unit = getOk.add(1)
616
+ def onGet404(): Unit = get404.add(1)
617
+ def onPostOk(): Unit = postOk.add(1)
618
+ ```
619
+
620
+ A `BoundCounter` holds a direct reference to the `LongAdder` for that attribute set. The call `add(1)` is just `adder.add(1)`: no map lookup, no attribute construction.
621
+
622
+ The same pattern works for histograms and gauges:
623
+
624
+ ```scala
625
+ import zio.blocks.telemetry._
626
+
627
+ val latency = metric.histogram("http.request.duration_ms")
628
+ val getLatency = latency.bind(Attributes.builder.put("method", "GET").build)
629
+
630
+ val poolSize = metric.gauge("db.pool.size")
631
+ val primaryPool = poolSize.bind(Attributes.builder.put("pool", "primary").build)
632
+
633
+ // Hot paths
634
+ def recordGetLatency(ms: Double): Unit = getLatency.record(ms)
635
+ def updatePrimaryPoolSize(n: Double): Unit = primaryPool.record(n)
636
+ ```
637
+
638
+ ### MetricData and collection
639
+
640
+ `Counter.collect()`, `Histogram.collect()`, and `Gauge.collect()` snapshot the current state of the instrument without resetting it:
641
+
642
+ ```scala
643
+ import zio.blocks.telemetry._
644
+
645
+ val requests = metric.counter("http.requests.total")
646
+ requests.add(42, "method" -> "GET", "status" -> "200")
647
+ requests.add(7, "method" -> "POST", "status" -> "201")
648
+
649
+ val data = requests.collect()
650
+ // data: MetricData.SumData(List(
651
+ // SumDataPoint(Attributes{method="GET",status="200"}, startNanos, nowNanos, 42),
652
+ // SumDataPoint(Attributes{method="POST",status="201"}, startNanos, nowNanos, 7)
653
+ // ))
654
+ ```
655
+
656
+ Each `SumDataPoint` contains the full attribute set plus the accumulated value. The OTLP exporter's `collectFn` calls `.collect()` on each instrument you register:
657
+
658
+ ```scala
659
+ package zio.blocks.telemetry.otel
660
+
661
+ val metricExporter = new OtlpJsonMetricExporter(
662
+ config, resource, scope, sender,
663
+ () => Seq(
664
+ NamedMetric("http.requests.total", "Total HTTP requests", "1", requests.collect()),
665
+ NamedMetric("http.request.duration", "Request duration", "ms", latency.collect()),
666
+ NamedMetric("db.pool.size", "DB pool size", "1", poolSize.collect())
667
+ )
668
+ )
669
+ ```
670
+
671
+ The `collectFn` is called by the `BatchProcessor`'s flush task on the configured interval.
672
+
673
+ ### Thread safety internals
674
+
675
+ | Instrument | Internal structure | Thread safety |
676
+ |---|---|---|
677
+ | `Counter` | `ConcurrentHashMap[Attributes, LongAdder]` | Lock-free reads, CAS on first write per label set |
678
+ | `UpDownCounter` | same as Counter, allows negative | same |
679
+ | `Gauge` | `ConcurrentHashMap[Attributes, AtomicLong]` | Lock-free: `AtomicLong.set(doubleToLongBits(v))` |
680
+ | `Histogram` | `ConcurrentHashMap[Attributes, State]` + `ReentrantLock` per State | `ReentrantLock` per label set during `record` and `collect` |
681
+
682
+ `LongAdder` is the right choice for `Counter` because it reduces contention under high concurrency: rather than competing for a single `AtomicLong`, threads maintain per-stripe counters and sum them on read. At high add rates, `LongAdder` outperforms `AtomicLong.addAndGet` significantly.
683
+
684
+ `Histogram` needs a lock because updating count, sum, min, max, and bucket counts is a multi-field operation. No single atomic primitive covers all of them.
685
+
686
+ ---
687
+
688
+ ## Part 5: Production Setup
689
+
690
+ ### Full production wiring
691
+
692
+ The OTEL exporters live in `package zio.blocks.telemetry.otel` and are `private[otel]`. Your bootstrap code must be in the same package (or a class in that package):
693
+
694
+ ```scala
695
+ package zio.blocks.telemetry.otel
696
+
697
+ import zio.blocks.telemetry._
698
+ import java.util.concurrent.TimeUnit
699
+
700
+ object Telemetry {
701
+
702
+ def initialize(): (TracerProvider, LoggerProvider, MeterProvider, PlatformExecutor) = {
703
+ val config = ExporterConfig(
704
+ endpoint = sys.env.getOrElse("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318"),
705
+ headers = parseHeaders(sys.env.getOrElse("OTEL_EXPORTER_OTLP_HEADERS", "")),
706
+ timeout = java.time.Duration.ofSeconds(10),
707
+ maxQueueSize = 4096,
708
+ maxBatchSize = 512,
709
+ flushIntervalMillis = 5000L
710
+ )
711
+
712
+ val resource = Resource.create(
713
+ Attributes.builder
714
+ .put("service.name", sys.env.getOrElse("OTEL_SERVICE_NAME", "my-service"))
715
+ .put("service.version", sys.env.getOrElse("SERVICE_VERSION", "unknown"))
716
+ .put("deployment.environment", sys.env.getOrElse("ENVIRONMENT", "development"))
717
+ .build
718
+ )
719
+
720
+ val scope = InstrumentationScope("my-service")
721
+ val sender = HttpSender.jdk(java.time.Duration.ofSeconds(10))
722
+ val executor = PlatformExecutor.create()
723
+
724
+ // Shared ContextStorage so log records inside spans get traceId/spanId
725
+ val contextStorage = ContextStorage.create[Option[SpanContext]](None)
726
+
727
+ // --- Tracing ---
728
+ val traceExporter = new OtlpJsonTraceExporter(config, resource, scope, sender, executor)
729
+
730
+ val tracerProvider = TracerProvider.builder
731
+ .setSampler(ParentBasedSampler(root = AlwaysOnSampler))
732
+ .addSpanProcessor(traceExporter)
733
+ .setResource(resource)
734
+ .setContextStorage(contextStorage)
735
+ .build()
736
+
737
+ trace.install(tracerProvider)
738
+
739
+ // --- Logging ---
740
+ val logExporter = new OtlpJsonLogExporter(config, resource, scope, sender, executor)
741
+
742
+ val loggerProvider = LoggerProvider.builder
743
+ .addLogRecordProcessor(logExporter)
744
+ .setContextStorage(contextStorage) // same storage for correlation
745
+ .setResource(resource)
746
+ .build()
747
+
748
+ log.install(loggerProvider.get("my-service"), minSeverity = Severity.Info)
749
+
750
+ // Optional: also write to a local file as backup
751
+ log.writer(JsonLogFormatter, FileLogWriter("logs/app.jsonl"))
752
+
753
+ // --- Metrics ---
754
+ val meterProvider = MeterProvider.builder
755
+ .setResource(resource)
756
+ .build()
757
+
758
+ metric.install(meterProvider)
759
+
760
+ (tracerProvider, loggerProvider, meterProvider, executor)
761
+ }
762
+
763
+ // Call at JVM shutdown
764
+ def shutdown(
765
+ tracerProvider: TracerProvider,
766
+ loggerProvider: LoggerProvider,
767
+ meterProvider: MeterProvider,
768
+ executor: PlatformExecutor
769
+ ): Unit = {
770
+ // Flush and close in dependency order:
771
+ // 1. Stop accepting new data
772
+ tracerProvider.forceFlush()
773
+ loggerProvider.shutdown()
774
+ // 2. Shut down the providers (calls shutdown on each processor/exporter)
775
+ tracerProvider.shutdown()
776
+ meterProvider.shutdown()
777
+ // 3. Stop the scheduler last
778
+ executor.shutdown()
779
+ log.clearWriters()
780
+ }
781
+
782
+ private def parseHeaders(raw: String): Map[String, String] =
783
+ if (raw.isEmpty) Map.empty
784
+ else raw.split(',').flatMap { pair =>
785
+ pair.split('=') match {
786
+ case Array(k, v) => Some(k.trim -> v.trim)
787
+ case _ => None
788
+ }
789
+ }.toMap
790
+ }
791
+ ```
792
+
793
+ Use it from your main entry point:
794
+
795
+ ```scala
796
+ object Main {
797
+ def main(args: Array[String]): Unit = {
798
+ val (tracerProvider, loggerProvider, meterProvider, executor) =
799
+ zio.blocks.telemetry.otel.Telemetry.initialize()
800
+
801
+ sys.addShutdownHook {
802
+ zio.blocks.telemetry.otel.Telemetry.shutdown(
803
+ tracerProvider, loggerProvider, meterProvider, executor
804
+ )
805
+ }
806
+
807
+ log.info("application started")
808
+ // ... your app
809
+ }
810
+ }
811
+ ```
812
+
813
+ ### ExporterConfig tuning guide
814
+
815
+ The default values work for most services. Here's when to change them:
816
+
817
+ **High-throughput services (>1000 RPS):**
818
+ ```scala
819
+ ExporterConfig(
820
+ maxQueueSize = 8192, // larger buffer for spikes
821
+ maxBatchSize = 1024, // send bigger payloads less often
822
+ flushIntervalMillis = 5000L // keep at 5s; collector handles large batches fine
823
+ )
824
+ ```
825
+
826
+ **Low-latency services (real-time data required):**
827
+ ```scala
828
+ ExporterConfig(
829
+ maxQueueSize = 1024,
830
+ maxBatchSize = 128, // smaller batches = lower end-to-end latency
831
+ flushIntervalMillis = 1000L // flush more frequently
832
+ )
833
+ ```
834
+
835
+ **Development / debugging:**
836
+ ```scala
837
+ ExporterConfig(
838
+ maxQueueSize = 256,
839
+ maxBatchSize = 32,
840
+ flushIntervalMillis = 500L // see data almost immediately
841
+ )
842
+ ```
843
+
844
+ The `maxQueueSize` and `maxBatchSize` interact: if records arrive faster than you export, the queue fills. When full, the oldest items are dropped (and a message is written to stderr). `maxQueueSize` should be at least `maxBatchSize * 4` to absorb transient spikes without dropping.
845
+
846
+ `timeout` is per HTTP request. If your OTLP collector is slow or flaky, a low timeout means you drop data rather than blocking. 10 seconds is usually right for LAN deployments; 30 seconds for cross-region.
847
+
848
+ ### BatchProcessor behavior
849
+
850
+ `BatchProcessor` runs inside the `PlatformExecutor`, which uses a virtual-thread-per-task executor for export tasks. This means:
851
+
852
+ - The scheduled flush (every `flushIntervalMillis`) runs on a virtual thread
853
+ - Export HTTP calls and their retries run on virtual threads, so sleeping during retry backoff doesn't pin a platform thread
854
+ - Queue overflow drops the oldest item, prints to stderr, and continues
855
+
856
+ The retry schedule uses exponential backoff: attempt 0 waits 1s, attempt 1 waits 2s, attempt 2 waits 4s... up to 30s maximum, for `maxRetries` (default 5) total attempts. Non-retryable failures (e.g., 400 Bad Request from the collector) are dropped immediately.
857
+
858
+ On `shutdown()`, `BatchProcessor` cancels the periodic flush, does a final synchronous flush of all pending items, then shuts down the export executor. Retries still happen during shutdown.
859
+
860
+ ### Graceful shutdown order
861
+
862
+ Shut down in this order:
863
+
864
+ 1. **Stop accepting new work.** Close your HTTP listener or message consumer.
865
+ 2. **`forceFlush()` on `TracerProvider`.** Ensures all in-flight spans are exported.
866
+ 3. **`loggerProvider.shutdown()`.** Flushes the log exporter's batch queue.
867
+ 4. **`tracerProvider.shutdown()`.** Shuts down span processors.
868
+ 5. **`meterProvider.shutdown()`.** Shuts down metric reader.
869
+ 6. **`executor.shutdown()`.** Stops the scheduled executor.
870
+
871
+ Don't shut down the executor before the providers; the batch processor's retry threads need it.
872
+
873
+ ### Environment-based configuration
874
+
875
+ The standard OTLP environment variables map naturally:
876
+
877
+ ```scala
878
+ val config = ExporterConfig(
879
+ endpoint = sys.env.getOrElse("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318"),
880
+ headers = sys.env.get("OTEL_EXPORTER_OTLP_HEADERS")
881
+ .map(parseOtlpHeaders)
882
+ .getOrElse(Map.empty),
883
+ timeout = sys.env.get("OTEL_EXPORTER_OTLP_TIMEOUT")
884
+ .map(s => java.time.Duration.ofMillis(s.toLong))
885
+ .getOrElse(java.time.Duration.ofSeconds(30))
886
+ )
887
+
888
+ val serviceName = sys.env.getOrElse("OTEL_SERVICE_NAME", "my-service")
889
+
890
+ // OTLP headers format: "key1=value1,key2=value2"
891
+ def parseOtlpHeaders(raw: String): Map[String, String] =
892
+ raw.split(',').flatMap(_.split('=') match {
893
+ case Array(k, v) => Some(k.trim -> v.trim)
894
+ case _ => None
895
+ }).toMap
896
+ ```
897
+
898
+ For minimum log level:
899
+
900
+ ```scala
901
+ val minLevel = sys.env.get("LOG_LEVEL").flatMap {
902
+ case "TRACE" => Some(Severity.Trace)
903
+ case "DEBUG" => Some(Severity.Debug)
904
+ case "INFO" => Some(Severity.Info)
905
+ case "WARN" => Some(Severity.Warn)
906
+ case "ERROR" => Some(Severity.Error)
907
+ case _ => None
908
+ }.getOrElse(Severity.Info)
909
+
910
+ log.install(logger, minSeverity = minLevel)
911
+ ```
912
+
913
+ ---
914
+
915
+ ## Part 6: Integration Patterns
916
+
917
+ ### With HTTP frameworks
918
+
919
+ A typical middleware pattern extracts incoming trace context, starts a server span, and injects context into outbound calls. Here's the conceptual shape (adapting to your specific HTTP library):
920
+
921
+ ```scala
922
+ package zio.blocks.telemetry.otel
923
+
924
+ import zio.blocks.telemetry._
925
+
926
+ // Incoming request middleware
927
+ def traceIncoming[Req, Resp](
928
+ request: Req,
929
+ getHeader: (Req, String) => Option[String],
930
+ handle: Req => Resp
931
+ ): Resp = {
932
+ val parentCtx = W3CTraceContextPropagator.extract(request, getHeader)
933
+ val tracer = trace.get("http-server")
934
+
935
+ // Use the provider's contextStorage to scope the remote parent
936
+ // Then start a child span under it
937
+ tracer.span("http.request", SpanKind.Server) { span =>
938
+ // Attach standard HTTP attributes
939
+ // ... then run the handler
940
+ handle(request)
941
+ }
942
+ }
943
+
944
+ // Outbound call wrapper
945
+ def traceOutgoing[Resp](
946
+ name: String,
947
+ call: Map[String, String] => Resp
948
+ ): Resp = {
949
+ val tracer = trace.get("http-client")
950
+ tracer.span(name, SpanKind.Client) { span =>
951
+ val headers = W3CTraceContextPropagator.inject(
952
+ span.spanContext,
953
+ Map.empty[String, String],
954
+ (c, k, v) => c + (k -> v)
955
+ )
956
+ call(headers)
957
+ }
958
+ }
959
+ ```
960
+
961
+ For the incoming case, propagating the remote parent into the tracer's `ContextStorage` before starting the span requires access to the `TracerProvider`'s `contextStorage` field. This is why `contextStorage` is exposed on `TracerProvider`.
962
+
963
+ ### With existing Java logging (SLF4J/JUL)
964
+
965
+ There's no bridge today, but the shape would be a `SLF4JLogRecordProcessor` that consumes `LogRecord` from `zio-blocks-telemetry` and forwards it to the SLF4J backend, or vice versa. If you need Java library logs captured alongside your Scala logs, the simplest current approach is a Logback appender that writes to your file alongside `zio-blocks-telemetry`'s file output.
966
+
967
+ ### Cross-platform considerations
968
+
969
+ The core telemetry module (`zio-blocks-telemetry`) compiles for JVM and Scala.js. What works on both:
970
+
971
+ - `log.*`, `trace.*`, `metric.*`
972
+ - `TracerProvider`, `LoggerProvider`, `MeterProvider` builders
973
+ - `Sampler`, `SpanContext`, `Attributes`, `MetricData`
974
+ - `TextLogFormatter`, `JsonLogFormatter`
975
+ - All the in-memory processors and default providers
976
+
977
+ JVM-only:
978
+ - `FileLogWriter`: uses `FileChannel`, not available in JS
979
+ - `ContextStorage` using `ScopedValue`: JDK 25+ specific; on Scala.js, a simpler mutable-variable implementation is used
980
+ - `PlatformExecutor`: uses `ScheduledExecutorService` with virtual threads
981
+ - The entire `zio-blocks-telemetry-otel` module (OTLP HTTP export, `BatchProcessor`)
982
+
983
+ If you're writing cross-platform code that uses telemetry, keep your call sites in the shared module and put all provider wiring in JVM-specific code.
984
+
985
+ ---
986
+
987
+ ## Part 7: FAQ / Troubleshooting
988
+
989
+ **"Why don't I see any log output?"**
990
+
991
+ Two common causes:
992
+
993
+ 1. The global minimum level is filtering it out. Check `log.globalMinLevel`. Default is `Severity.Trace.number` (1), so everything passes. If you called `log.install(logger, minSeverity = Severity.Info)`, then trace and debug calls are suppressed.
994
+
995
+ 2. The logger was installed but the processor chain is empty. Verify your `LoggerProvider` builder has at least one processor:
996
+ ```scala
997
+ LoggerProvider.builder
998
+ .addLogRecordProcessor(new ConsoleLogRecordProcessor) // don't forget this
999
+ .build()
1000
+ ```
1001
+
1002
+ 3. A namespace override is suppressing your package. Check for `log.setMinSeverity(...)` calls.
1003
+
1004
+ **"Why is my log call not compiling?"**
1005
+
1006
+ You're probably passing a type that has no `LogEnrichment` instance. Common culprits:
1007
+ - `Float` (convert to `Double` with `.toDouble`)
1008
+ - `Short`, `Byte`, `Char` (convert to `Long` or `String`)
1009
+ - `UUID`, `Instant` (provide a `LogEnrichment[(String, YourType)]` implicit)
1010
+ - Custom case classes (same: provide an implicit)
1011
+
1012
+ The compiler error will say something like `no implicit value for LogEnrichment[(String, UUID)]`.
1013
+
1014
+ **"How do I log a UUID, Instant, or custom type?"**
1015
+
1016
+ Define an implicit in your package object or companion:
1017
+
1018
+ ```scala
1019
+ import zio.blocks.telemetry._
1020
+ import java.util.UUID
1021
+
1022
+ implicit val uuidEnrichment: LogEnrichment[(String, UUID)] =
1023
+ (record, kv) => record.copy(
1024
+ attributes = record.attributes ++ Attributes.of(AttributeKey.string(kv._1), kv._2.toString)
1025
+ )
1026
+ ```
1027
+
1028
+ Then `log.info("action", "id" -> someUuid)` compiles and works.
1029
+
1030
+ **"What's the overhead of a disabled log level?"**
1031
+
1032
+ Exactly one volatile read (`globalMinLevel`) plus, if the level passes that check, one array scan through the namespace overrides (usually empty, so O(1)). Both happen before any argument is evaluated. No string formatting, no object allocation, no method calls on your arguments.
1033
+
1034
+ **"Can I use this with ZIO, Cats Effect, or any other effect system?"**
1035
+
1036
+ Yes. The library is effect-free. `log.info(...)` is a plain synchronous call. Call it from anywhere: inside `ZIO.succeed`, inside `IO.apply`, inside a `Future`, inside a background thread. The only threading concern is that `ContextStorage` uses `ScopedValue` for span correlation, which is scoped to the current call stack. If you hop threads between starting a span and logging inside it, the log may not see the active span context. Most effect systems either stay on one thread or have ways to propagate context.
1037
+
1038
+ **"Why are the OTEL exporter classes private[otel]?"**
1039
+
1040
+ To enforce that the only way to construct and wire an `OtlpJsonTraceExporter` is from within the `zio.blocks.telemetry.otel` package. This prevents partial or incorrectly wired configurations from being assembled in arbitrary user code. Your bootstrap object, placed in that package, has full access. Everything downstream uses the installed global providers and never needs to import the exporter types.
1041
+
1042
+ **"How do I test my logging and tracing?"**
1043
+
1044
+ For logging: use a custom `LogRecordProcessor` that stores records (see "Testing with telemetry" in Part 2), install it via `log.install(...)`, run your code, assert on the captured records.
1045
+
1046
+ For tracing: use `trace.collectedSpans` and `trace.clearSpans()`. The default in-memory processor accumulates spans automatically.
1047
+
1048
+ **"What happens when the export queue fills up?"**
1049
+
1050
+ When `queueSize` exceeds `maxQueueSize`, `BatchProcessor.dropOldestIfOverCapacity()` polls the head of the queue (the oldest item), decrements the size, and prints a warning to stderr:
1051
+
1052
+ ```
1053
+ [zio-blocks-telemetry] BatchProcessor queue full (2048). Dropping oldest item.
1054
+ ```
1055
+
1056
+ The new item is still enqueued. This is a best-effort, head-dropping strategy. You won't get backpressure; callers are never blocked. If you see frequent drops, increase `maxQueueSize` or decrease `flushIntervalMillis`.
1057
+
1058
+ **"Is this virtual-thread safe?"**
1059
+
1060
+ Yes. `ContextStorage` on JDK 25+ uses `ScopedValue`, which is the JDK 25 native mechanism for per-virtual-thread context propagation. Each virtual thread inherits its `ScopedValue` bindings from the thread that spawns it, so if you start a virtual thread inside a span's block, the child thread sees the same span context. The `BatchProcessor` export threads also use virtual threads via `Executors.newVirtualThreadPerTaskExecutor()`, so retry sleeps don't pin platform threads.
1061
+
1062
+ ---
1063
+
1064
+ ## Where to Go Next
1065
+
1066
+ - **Complete API reference:** [Telemetry Reference](../reference/telemetry.md) for all types, methods, and parameter documentation
1067
+ - **Installation and quick start:** same reference doc, Installation section
1068
+ - **Context and dependency injection:** [Context Reference](../reference/context.md) for integrating `OtelContext` with the Context module
1069
+ - **Resource management:** [Compile-Time Resource Safety with Scope](compile-time-resource-safety-with-scope.md) for managing provider lifetimes with Scope