@zio.dev/zio-blocks 0.0.33 → 0.0.55

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