@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.
- package/guides/compile-time-resource-safety-with-scope.md +16 -17
- package/guides/getting-started-with-mux.md +1507 -0
- package/guides/query-dsl-extending.md +161 -102
- package/guides/query-dsl-fluent-builder.md +217 -157
- package/guides/query-dsl-reified-optics.md +12 -10
- package/guides/query-dsl-sql.md +246 -165
- package/guides/telemetry-guide.md +1069 -0
- package/guides/zio-schema-migration.md +29 -22
- package/index.md +292 -50
- package/package.json +1 -1
- package/plans/config-follow-up-prs.md +188 -0
- package/plans/config-pr-assessment-roadmap.md +310 -0
- package/reference/MuxDataFlow.jsx +250 -0
- package/reference/async.md +651 -0
- package/reference/chunk.md +3533 -308
- package/reference/codegen/case-class.md +436 -0
- package/reference/codegen/emitter-config.md +383 -0
- package/reference/codegen/examples.md +664 -0
- package/reference/codegen/field.md +316 -0
- package/reference/codegen/index.md +317 -0
- package/reference/codegen/scala-emitter.md +392 -0
- package/reference/codegen/scala-file.md +276 -0
- package/reference/codegen/sealed-trait.md +408 -0
- package/reference/codegen/type-definition.md +340 -0
- package/reference/codegen/type-ref.md +201 -0
- package/reference/combinators.md +347 -117
- package/reference/config.md +158 -0
- package/reference/context.md +4 -4
- package/reference/datastar.md +346 -0
- package/reference/docs.md +1461 -345
- package/reference/endpoint/auth-type.md +146 -0
- package/reference/endpoint/endpoint.md +297 -0
- package/reference/endpoint/http-codec.md +249 -0
- package/reference/endpoint/index.md +825 -0
- package/reference/endpoint/path-codec.md +237 -0
- package/reference/endpoint/route-pattern.md +196 -0
- package/reference/endpoint/route-tree.md +111 -0
- package/reference/endpoint/segment-codec.md +212 -0
- package/reference/html.md +1120 -0
- package/reference/htmx/attribute-values.md +359 -0
- package/reference/htmx/hx-encoding.md +111 -0
- package/reference/htmx/hx-params.md +204 -0
- package/reference/htmx/hx-swap.md +276 -0
- package/reference/htmx/hx-sync.md +251 -0
- package/reference/htmx/hx-target.md +314 -0
- package/reference/htmx/hx-trigger.md +457 -0
- package/reference/htmx/hx-url-update.md +239 -0
- package/reference/htmx/index.md +855 -0
- package/reference/http-model/index.md +47 -0
- package/reference/http-model/model.md +1481 -0
- package/reference/http-model/schema.md +747 -0
- package/reference/maybe.md +826 -0
- package/reference/media-type.md +2 -2
- package/reference/mux.mdx +823 -0
- package/reference/openapi.md +1351 -0
- package/reference/resource-management/defer-handle.md +1 -1
- package/reference/resource-management/resource.md +31 -2
- package/reference/resource-management/scope.md +28 -12
- package/reference/resource-management/wire.md +3 -7
- package/reference/ringbuffer/MpmcDiagram.jsx +717 -0
- package/reference/ringbuffer/MpscDiagram.jsx +618 -0
- package/reference/ringbuffer/SpmcDiagram.jsx +680 -0
- package/reference/ringbuffer/SpscDiagram.jsx +677 -0
- package/reference/ringbuffer/advanced.mdx +109 -0
- package/reference/ringbuffer/index.mdx +145 -0
- package/reference/ringbuffer/mpmc.mdx +151 -0
- package/reference/ringbuffer/mpsc.mdx +132 -0
- package/reference/ringbuffer/spmc.mdx +108 -0
- package/reference/ringbuffer/spsc.mdx +344 -0
- package/reference/{allows.md → schema/allows.md} +4 -4
- package/reference/{binding-resolver.md → schema/binding-resolver.md} +1 -1
- package/reference/{binding.md → schema/binding.md} +2 -3
- package/reference/schema/built-in-codecs/avro.md +451 -0
- package/reference/schema/built-in-codecs/bson.md +480 -0
- package/reference/schema/built-in-codecs/csv.md +564 -0
- package/reference/schema/built-in-codecs/index.md +77 -0
- package/reference/schema/built-in-codecs/json/index.md +295 -0
- package/reference/schema/built-in-codecs/json/json-config.md +217 -0
- package/reference/{json-patch.md → schema/built-in-codecs/json/json-patch.md} +5 -5
- package/reference/{json-schema.md → schema/built-in-codecs/json/json-schema.md} +14 -47
- package/reference/schema/built-in-codecs/json/json-selection.md +322 -0
- package/reference/{json.md → schema/built-in-codecs/json/json.md} +32 -64
- package/reference/schema/built-in-codecs/messagepack.md +508 -0
- package/reference/schema/built-in-codecs/thrift.md +433 -0
- package/reference/schema/built-in-codecs/toon.md +1078 -0
- package/reference/{xml.md → schema/built-in-codecs/xml.md} +13 -9
- package/reference/schema/built-in-codecs/yaml.md +552 -0
- package/reference/{codec.md → schema/codec.md} +10 -10
- package/reference/{dynamic-optic.md → schema/dynamic-optic.md} +151 -5
- package/reference/{dynamic-schema.md → schema/dynamic-schema.md} +8 -8
- package/reference/schema/format.md +92 -0
- package/reference/schema/index.md +50 -0
- package/reference/schema/migration.md +297 -0
- package/reference/{modifier.md → schema/modifier.md} +58 -7
- package/reference/{optics.md → schema/optics.md} +2 -2
- package/reference/{patch.md → schema/patch.md} +1 -1
- package/{path-interpolator.md → reference/schema/path-interpolator.md} +165 -72
- package/reference/{schema-evolution → schema/schema-evolution}/as.md +8 -8
- package/reference/{schema-evolution → schema/schema-evolution}/index.md +2 -2
- package/reference/{schema-evolution → schema/schema-evolution}/into.md +8 -8
- package/reference/{schema-expr.md → schema/schema-expr.md} +110 -175
- package/reference/{schema.md → schema/schema.md} +12 -0
- package/reference/{structural-types.md → schema/structural-types.md} +1 -1
- package/reference/{type-class-derivation.md → schema/type-class-derivation.md} +63 -1
- package/reference/smithy.md +533 -0
- package/reference/sql/db-codec-deriver.md +71 -0
- package/reference/sql/db-codec.md +687 -0
- package/reference/sql/db-con.md +271 -0
- package/reference/sql/db-connection.md +153 -0
- package/reference/sql/db-param-writer.md +77 -0
- package/reference/sql/db-param.md +66 -0
- package/reference/sql/db-result-reader.md +146 -0
- package/reference/sql/db-tx.md +82 -0
- package/reference/sql/db-value.md +41 -0
- package/reference/sql/ddl.md +85 -0
- package/reference/sql/frag.md +254 -0
- package/reference/sql/index.md +341 -0
- package/reference/sql/repo.md +600 -0
- package/reference/sql/sql-dialect.md +73 -0
- package/reference/sql/sql-logger.md +62 -0
- package/reference/sql/sql-name-mapper.md +70 -0
- package/reference/sql/table-metadata.md +134 -0
- package/reference/sql/table.md +448 -0
- package/reference/sql/transactor-zio.md +399 -0
- package/reference/sql/transactor.md +353 -0
- package/reference/sql-zio.md +112 -0
- package/reference/streams/concurrent-operators.md +106 -0
- package/reference/streams/index.md +653 -0
- package/reference/streams/pipeline.md +718 -0
- package/reference/streams/reader.md +1284 -0
- package/reference/streams/scala-2-compatibility.md +55 -0
- package/reference/streams/sink.md +1426 -0
- package/reference/streams/stream.md +2526 -0
- package/reference/streams/writer.md +1045 -0
- package/reference/streams/zero-boxing.md +275 -0
- package/reference/telemetry.md +693 -0
- package/reference/typeid.md +5 -19
- package/sidebars.js +238 -43
- package/reference/formats.md +0 -694
- package/reference/http-model.md +0 -1716
- package/reference/streams.md +0 -989
- package/ringbuffer.md +0 -249
- /package/reference/{json-differ.md → schema/built-in-codecs/json/json-differ.md} +0 -0
- /package/reference/{dynamic-value.md → schema/dynamic-value.md} +0 -0
- /package/reference/{lazy.md → schema/lazy.md} +0 -0
- /package/reference/{reflect.md → schema/reflect.md} +0 -0
- /package/reference/{registers.md → schema/registers.md} +0 -0
- /package/reference/{schema-error.md → schema/schema-error.md} +0 -0
- /package/reference/{syntax.md → schema/syntax.md} +0 -0
- /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
|