@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,216 @@
1
+ ---
2
+ id: custom-exporter
3
+ title: "Building an Exporter"
4
+ sidebar_label: "Building an Exporter"
5
+ description: "Encode telemetry into OTLP JSON, send it yourself, and interpret the result — the public pieces of the export path and how they fit together."
6
+ keywords:
7
+ - "OTLP JSON"
8
+ - "Custom Exporter"
9
+ - "Telemetry Export"
10
+ - "Export Result"
11
+ ---
12
+
13
+ The exporters that ship with this module are internal, but the pieces they are built from are not. `OtlpJsonEncoder` turns recorded signals into OTLP JSON bytes, [`HttpSender`](./index.md) puts bytes on the wire, and `ExportResult` classifies what came back. Together they are enough to export telemetry today, without waiting for a public exporter API. Supporting types: `NamedMetric`, `HttpResponse`. The public surface of the export path:
14
+
15
+ ```scala
16
+ object OtlpJsonEncoder {
17
+ def encodeTraces(spans: Seq[SpanData], resource: Resource, scope: InstrumentationScope): Array[Byte]
18
+ def encodeMetrics(metrics: Seq[NamedMetric], resource: Resource, scope: InstrumentationScope): Array[Byte]
19
+ def encodeLogs(logs: Seq[LogRecord], resource: Resource, scope: InstrumentationScope): Array[Byte]
20
+ }
21
+
22
+ sealed trait ExportResult
23
+
24
+ object ExportResult {
25
+ case object Success extends ExportResult
26
+ final case class Failure(retryable: Boolean, message: String) extends ExportResult
27
+
28
+ def fromHttpResponse(response: HttpResponse): ExportResult
29
+ }
30
+ ```
31
+
32
+ ## Motivation
33
+
34
+ `OtlpJsonTraceExporter`, `OtlpJsonLogExporter`, `OtlpJsonMetricExporter`, and the `BatchProcessor` behind them are all `private[otel]`, so there is no supported way to construct one. That is a real limitation, and the [module overview](./index.md) says so.
35
+
36
+ What it does not mean is that you cannot export. Three of the four pieces an exporter needs are public: the encoder that produces the payload, the sender that transmits it, and the result type that says whether to retry. Only the batching and retry loop is missing, and for many deployments that loop is a scheduled task you already have — a periodic flush from whatever scheduler your application runs.
37
+
38
+ Writing it yourself also buys control the internal exporter does not offer: your own retry policy, your own queue semantics, metrics about the exporter itself, and a payload you can inspect before it leaves the process.
39
+
40
+ ## The Encoder
41
+
42
+ `OtlpJsonEncoder` produces the OTLP protobuf-JSON mapping directly into a `StringBuilder`, with no JSON library involved. Each method takes the signals plus the `Resource` and `InstrumentationScope` that describe their origin, and returns UTF-8 bytes ready to POST.
43
+
44
+ ### Encoding Rules
45
+
46
+ The output follows the protobuf JSON mapping rather than a naive rendering, which matters if you plan to compare payloads or write assertions against them:
47
+
48
+ | Wire concern | Encoding |
49
+ | ------------------------- | ---------------------------------------------- |
50
+ | `traceId`, `spanId` bytes | Lowercase hex strings |
51
+ | `int64` / `uint64` | Quoted strings, not JSON numbers |
52
+ | Enums | Integers |
53
+ | Field names | camelCase |
54
+ | Control characters | `\uXXXX` escapes, including lone surrogates |
55
+
56
+ The quoted-integer rule is the one that surprises people: OTLP timestamps are `uint64`, so they appear as `"1755990000000000000"` rather than a bare number. A collector expects that; a hand-written comparison usually does not.
57
+
58
+ ### Encoding Traces
59
+
60
+ `OtlpJsonEncoder.encodeTraces` takes `SpanData` values — the same records the core module's in-memory processor collects:
61
+
62
+ ```scala
63
+ import zio.blocks.telemetry._
64
+ import zio.blocks.telemetry.otel._
65
+
66
+ val payload: Array[Byte] = OtlpJsonEncoder.encodeTraces(
67
+ trace.collectedSpans,
68
+ Resource.empty,
69
+ InstrumentationScope("checkout-service", Some("1.4.0"))
70
+ )
71
+ ```
72
+
73
+ `trace.collectedSpans` returns everything recorded so far, so a real exporter tracks what it has already sent rather than re-encoding the whole list on every flush.
74
+
75
+ ### Encoding Logs
76
+
77
+ `OtlpJsonEncoder.encodeLogs` takes `LogRecord` values, and is otherwise identical in shape:
78
+
79
+ ```scala
80
+ import zio.blocks.telemetry._
81
+ import zio.blocks.telemetry.otel._
82
+
83
+ def exportLogs(records: Seq[LogRecord]): Array[Byte] =
84
+ OtlpJsonEncoder.encodeLogs(records, Resource.empty, InstrumentationScope("checkout-service"))
85
+ ```
86
+
87
+ ### Encoding Metrics
88
+
89
+ Metrics need one extra step, because the encoder wants a name and the reader does not supply one. `NamedMetric` pairs the descriptor with the data:
90
+
91
+ ```scala
92
+ final case class NamedMetric(
93
+ name: String,
94
+ description: String,
95
+ unit: String,
96
+ data: MetricData
97
+ )
98
+ ```
99
+
100
+ `MetricReader#collectAllMetrics` returns `Seq[MetricData]`, and `MetricData` — `SumData`, `HistogramData`, or `GaugeData` — carries only data points. The name, description, and unit are not on it:
101
+
102
+ ```scala
103
+ import zio.blocks.telemetry._
104
+ import zio.blocks.telemetry.otel._
105
+
106
+ val collected: Seq[MetricData] = metric.reader.collectAllMetrics()
107
+
108
+ val named: Seq[NamedMetric] =
109
+ collected.map(data => NamedMetric("http.server.duration", "Request duration", "ms", data))
110
+
111
+ val payload = OtlpJsonEncoder.encodeMetrics(named, Resource.empty, InstrumentationScope("checkout-service"))
112
+ ```
113
+
114
+ :::warning[`MetricData` does not carry its own name]
115
+ `MetricReader#collectAllMetrics` returns metric data with no identifying name, and there is no public way to recover which instrument produced which element. Attaching the right name means tracking the correspondence yourself — record the instruments you created in the same order you read them back, or collect per-instrument rather than in bulk. Mapping a whole batch to one name, as above, is only correct when you registered exactly one instrument.
116
+ :::
117
+
118
+ `OtlpJsonEncoder.NamedMetric` is a type alias and value alias for the same case class, so either spelling compiles.
119
+
120
+ ## Interpreting the Response
121
+
122
+ `ExportResult` classifies an HTTP response into "delivered", "retry this", and "drop this". `ExportResult.fromHttpResponse` applies the standard OTLP rules, so you do not need to remember which status codes are worth a second attempt:
123
+
124
+ ```scala
125
+ import zio.blocks.telemetry.otel._
126
+
127
+ def result(status: Int): ExportResult =
128
+ ExportResult.fromHttpResponse(HttpResponse(status, Array.emptyByteArray, Map.empty))
129
+ ```
130
+
131
+ Any `2xx` is a success:
132
+
133
+ ```scala
134
+ result(200)
135
+ // res3: ExportResult = Success
136
+ result(204)
137
+ // res4: ExportResult = Success
138
+ ```
139
+
140
+ Four status codes are treated as retryable — `429`, `502`, `503`, and `504` — because each means "not now" rather than "not ever":
141
+
142
+ ```scala
143
+ result(429)
144
+ // res5: ExportResult = Failure(retryable = true, message = "HTTP 429")
145
+ result(503)
146
+ // res6: ExportResult = Failure(retryable = true, message = "HTTP 503")
147
+ ```
148
+
149
+ Everything else is a permanent failure, and retrying it only wastes the batch. A `400` means the collector rejected the payload itself, so the same bytes will be rejected again:
150
+
151
+ ```scala
152
+ result(400)
153
+ // res7: ExportResult = Failure(retryable = false, message = "HTTP 400")
154
+ result(401)
155
+ // res8: ExportResult = Failure(retryable = false, message = "HTTP 401")
156
+ ```
157
+
158
+ The `message` is a short diagnostic rather than the response body, so log the body separately when you need to know *why* a `400` was rejected.
159
+
160
+ A rejected export arrives as a non-`2xx` `HttpResponse`, not as an exception. A connection that never completes is different: `HttpSender.jdk` lets `IOException` out, so a custom loop must catch throwables and treat them as retryable itself — `ExportResult.fromHttpResponse` never sees them.
161
+
162
+ :::tip[Honour `Retry-After`]
163
+ `HttpResponse#firstHeader` looks a header up case-insensitively, which is what a `429` or `503` needs. `ExportResult` does not read it, so a retry loop that ignores `Retry-After` will keep hammering a collector that just asked it to wait.
164
+ :::
165
+
166
+ ## Putting It Together
167
+
168
+ A minimal exporter is a flush function: take what has accumulated, encode it, send it, and act on the result. This is the whole shape, with the retry decision made from `ExportResult`:
169
+
170
+ ```scala
171
+ import zio.blocks.telemetry._
172
+ import zio.blocks.telemetry.otel._
173
+ import java.time.Duration
174
+
175
+ val config = ExporterConfig(
176
+ endpoint = "https://otlp.example.com:4318",
177
+ headers = Map("Authorization" -> "Bearer <token>"),
178
+ timeout = Duration.ofSeconds(10)
179
+ )
180
+
181
+ val sender = HttpSender.jdk(config.timeout)
182
+ val scope = InstrumentationScope("checkout-service", Some("1.4.0"))
183
+
184
+ def flushTraces(spans: Seq[SpanData]): ExportResult =
185
+ if (spans.isEmpty) ExportResult.Success
186
+ else {
187
+ val body = OtlpJsonEncoder.encodeTraces(spans, Resource.empty, scope)
188
+ try ExportResult.fromHttpResponse(
189
+ sender.send(config.endpoint + "/v1/traces", config.headers + ("Content-Type" -> "application/json"), body)
190
+ )
191
+ catch {
192
+ case e: java.io.IOException => ExportResult.Failure(retryable = true, message = e.getMessage)
193
+ }
194
+ }
195
+ ```
196
+
197
+ Three details in there are easy to get wrong. The signal path is appended to the endpoint — `/v1/traces`, `/v1/logs`, or `/v1/metrics` — because `ExporterConfig#endpoint` is a base URL. `Content-Type: application/json` must be set, since the payload is the JSON mapping rather than protobuf. And the `IOException` catch is not optional: without it, a collector that is simply unreachable takes down whatever thread the flush runs on.
198
+
199
+ Call `HttpSender#shutdown` when the process is stopping. The JDK sender holds nothing that needs releasing, but a custom sender may, and a flush that never runs at shutdown loses whatever was still queued.
200
+
201
+ ## What You Are Reimplementing
202
+
203
+ The internal `BatchProcessor` does four things a hand-rolled flush does not, and they are worth deciding about explicitly rather than discovering later:
204
+
205
+ - **Bounded queueing.** It caps the queue at `ExporterConfig#maxQueueSize` and evicts the oldest record when full, so recording never blocks and memory never grows without bound. A naive accumulator has neither property.
206
+ - **Chunking.** It splits a drained queue into `ExporterConfig#maxBatchSize` pieces, so one flush after a traffic spike becomes several right-sized requests instead of one oversized one.
207
+ - **Interval flushing.** It flushes every `ExporterConfig#flushIntervalMillis` regardless of how full the batch is, which is what bounds how stale exported data can be.
208
+ - **Retry on retryable failures.** It re-queues a batch whose `ExportResult` was `Failure(retryable = true)` and drops one that was not.
209
+
210
+ `ExporterConfig` carries all three sizing fields even though nothing public reads them, so use them as the source of truth for your own loop rather than inventing separate numbers. See [the module overview](./index.md) for what each field means.
211
+
212
+ ## Integration Points
213
+
214
+ This page uses only public API: `OtlpJsonEncoder`, `NamedMetric`, `ExportResult`, `HttpResponse`, `HttpSender`, and `ExporterConfig` from this module, and `SpanData`, `LogRecord`, `MetricData`, `Resource`, and `InstrumentationScope` from the core telemetry module.
215
+
216
+ The signals themselves come from the core module's recording APIs — [`trace`](../tracing/index.md) for spans, [`log`](../logging/index.md) for records, and [`metric`](../metrics/index.md) for measurements. For trace context across service boundaries rather than export, see [the module overview](./index.md).
@@ -0,0 +1,212 @@
1
+ ---
2
+ id: index
3
+ title: "OTLP Export"
4
+ description: "Send recorded telemetry out of your process to an OpenTelemetry collector, and carry trace context across service boundaries."
5
+ keywords:
6
+ - "OpenTelemetry Protocol"
7
+ - "Telemetry Export"
8
+ - "Trace Propagation"
9
+ - "OTLP"
10
+ ---
11
+
12
+ The core telemetry module records signals inside your process. This module gets them out: it speaks OTLP over HTTP, so anything that accepts OpenTelemetry data — a collector, Jaeger, Tempo, a vendor's endpoint — can receive your traces, logs, and metrics.
13
+
14
+ It also carries trace context *between* processes. A trace that stops at your service boundary isn't much use, so a propagator reads and writes the standard headers that let two services contribute spans to the same trace.
15
+
16
+ ## Installation
17
+
18
+ Add the module to your build file:
19
+
20
+ ```scala
21
+ libraryDependencies += "dev.zio" %% "zio-blocks-telemetry-otel" % "0.0.55"
22
+ ```
23
+
24
+ ## Configuring the Endpoint and Batching
25
+
26
+ One `ExporterConfig` answers two questions for an exporter: where to send data, and how much to hold before sending it. Both matter for the same reason — a collector is a network hop away, so sending one span per request would add a round trip to every request you were trying to measure. Records accumulate and go out in batches instead.
27
+
28
+ ```scala
29
+ final case class ExporterConfig(
30
+ endpoint: String = "http://localhost:4318",
31
+ headers: Map[String, String] = Map.empty,
32
+ timeout: Duration = Duration.ofSeconds(30),
33
+ maxQueueSize: Int = 2048,
34
+ maxBatchSize: Int = 512,
35
+ flushIntervalMillis: Long = 5000
36
+ )
37
+ ```
38
+
39
+ Every field has a default, so `ExporterConfig()` is valid and points at a collector on localhost — the usual local development setup. `endpoint` is the collector's base URL, `headers` carries whatever it needs to accept you (typically an API key or bearer token), and `timeout` bounds a single send attempt:
40
+
41
+ ```scala
42
+ import zio.blocks.telemetry.otel._
43
+ import java.time.Duration
44
+
45
+ val config = ExporterConfig(
46
+ endpoint = "https://otlp.example.com:4318",
47
+ headers = Map("Authorization" -> "Bearer <token>"),
48
+ timeout = Duration.ofSeconds(10)
49
+ )
50
+ ```
51
+
52
+ Headers are treated as sensitive: `toString` prints their count, never their contents, so a config logged at startup can't leak a token.
53
+
54
+ The three batching fields trade latency against overhead. `flushIntervalMillis` is how long a partial batch waits before going out anyway — lower it to see data sooner, raise it to send fewer, larger requests. `maxBatchSize` is how many records go in one request; filling a batch does not trigger a send, it only chunks what the next flush drains, so the interval is what determines when data leaves. `maxQueueSize` is how many records may be waiting at once: once the queue is over that limit each new record evicts the oldest one still queued, so recording never blocks and the queue never grows without bound. The defaults suit a service with steady moderate traffic; a high-throughput one should raise `maxQueueSize` before it starts dropping.
55
+
56
+ ## Sending the Bytes
57
+
58
+ `HttpSender` is the one piece of the export path that actually touches the network. An exporter hands it a URL, headers, and a body of OTLP bytes; it performs the request and returns the response.
59
+
60
+ It's a separate trait so the network is replaceable. Real deployments need things a fixed HTTP client can't know about — an outbound proxy, request signing, a corporate TLS setup — and tests need the opposite: no network at all.
61
+
62
+ ```scala
63
+ trait HttpSender {
64
+ def send(url: String, headers: Map[String, String], body: Array[Byte]): HttpResponse
65
+ def shutdown(): Unit
66
+ }
67
+
68
+ object HttpSender {
69
+ def jdk(timeout: Duration = Duration.ofSeconds(30)): HttpSender
70
+ }
71
+
72
+ final case class HttpResponse(statusCode: Int, body: Array[Byte], headers: Map[String, Seq[String]]) {
73
+ def firstHeader(name: String): Option[String]
74
+ }
75
+ ```
76
+
77
+ `HttpSender.jdk` wraps `java.net.http.HttpClient`, which ships with the JVM, so nothing is added to your dependencies. The timeout applies to both connecting and completing a request. `shutdown()` is where a sender releases what it holds — the JDK one holds nothing that needs closing, so its implementation does nothing:
78
+
79
+ ```scala
80
+ import zio.blocks.telemetry.otel._
81
+ import java.time.Duration
82
+
83
+ val sender = HttpSender.jdk(Duration.ofSeconds(10))
84
+ ```
85
+
86
+ `send` reports a rejected export as an `HttpResponse` with a non-`2xx` status rather than as an exception, so an exporter can decide whether a failure is worth retrying. A connection that never completes still throws — `HttpSender.jdk` lets `IOException` out — and the batching layer treats such a throw as a retryable failure. Its `firstHeader(name)` looks a header up case-insensitively, which is what you need for a `Retry-After` on a `429` or `503`.
87
+
88
+ To write your own, implement the two methods: return a `2xx` status for a send the exporter should treat as delivered, anything else to mark it failed. Most custom senders are wrappers rather than replacements — sign the request, pick a proxy, count attempts, then delegate to `HttpSender.jdk(...)` and pass its response back.
89
+
90
+ ## Propagating Context Across Services
91
+
92
+ One request usually touches several services. Your API calls an inventory service, which calls a database proxy — and you want that whole journey to read as *one* trace, not three unrelated ones.
93
+
94
+ That doesn't happen by itself. Each service starts a fresh trace unless the caller tells it which trace it is already part of, and an HTTP call carries nothing but headers. So the caller writes the current trace's identity into a header, and the receiver reads it back out and continues that trace instead of beginning its own.
95
+
96
+ A propagator does that writing and reading. The identity travels as text in a standard header, which is what lets it cross between services written in different languages:
97
+
98
+ ```scala
99
+ trait Propagator {
100
+ def extract[C](carrier: C, getter: (C, String) => Option[String]): Option[SpanContext]
101
+ def inject[C](spanContext: SpanContext, carrier: C, setter: (C, String, String) => C): C
102
+ def fields: Seq[String]
103
+ }
104
+ ```
105
+
106
+ The carrier is whatever holds your headers — a `Map`, your framework's request type — and you supply the accessor, so no HTTP library is assumed. `fields` names the headers a propagator touches, which is what you pass to a CORS or header-allowlist configuration.
107
+
108
+ Use `W3CTraceContextPropagator` unless something upstream requires otherwise: it implements the W3C `traceparent` standard, which is what OpenTelemetry emits by default and what most backends expect. `B3Propagator` covers Zipkin's format in two shapes — `B3Propagator.single` puts everything in one `b3` header, `B3Propagator.multi` splits it across `X-B3-TraceId`, `X-B3-SpanId`, `X-B3-Sampled`, `X-B3-ParentSpanId`, and `X-B3-Flags`.
109
+
110
+ On the way out, take the active span's context and write it into a header map:
111
+
112
+ ```scala
113
+ import zio.blocks.telemetry._
114
+ import zio.blocks.telemetry.otel._
115
+
116
+ val tracer = trace.get("api-service")
117
+
118
+ tracer.span("call-inventory", SpanKind.Client) { span =>
119
+ val headers = W3CTraceContextPropagator.inject(
120
+ span.spanContext,
121
+ Map.empty[String, String],
122
+ (carrier: Map[String, String], k: String, v: String) => carrier + (k -> v)
123
+ )
124
+ // headers now holds "traceparent" -> "00-<traceId>-<spanId>-01"
125
+ headers
126
+ }
127
+ ```
128
+
129
+ On the way in, `extract` returns `None` when the header is absent or malformed, so a request without trace context simply starts its own trace. An extracted context is marked `isRemote = true`, which is how you tell a continued trace from one that started locally.
130
+
131
+ Extracting isn't enough on its own, though — the context has to be *active* while your handler runs, or the span you open won't attach to it. Hold the `ContextStorage` you gave the provider and scope the remote context around the work:
132
+
133
+ ```scala
134
+ import zio.blocks.telemetry._
135
+ import zio.blocks.telemetry.otel._
136
+
137
+ val storage = ContextStorage.create[Option[SpanContext]](None)
138
+ trace.install(TracerProvider.builder.setContextStorage(storage).build())
139
+
140
+ val tracer = trace.get("api-service")
141
+
142
+ val parent: Option[SpanContext] =
143
+ W3CTraceContextPropagator.extract(
144
+ Map("traceparent" -> "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"),
145
+ (m: Map[String, String], k: String) => m.get(k)
146
+ )
147
+
148
+ storage.scoped(parent) {
149
+ tracer.span("handle-checkout", SpanKind.Server) { span =>
150
+ span.setAttribute("http.route", "/checkout")
151
+ }
152
+ }
153
+ ```
154
+
155
+ Create the storage yourself: a provider's own is internal, so there's no reaching it after the fact. Note also that `trace.get` binds to whichever provider is installed when it runs, so take your tracers after `trace.install`.
156
+
157
+ ## Exporting Traces, Logs, and Metrics
158
+
159
+ The exporters that carry each signal to a collector are internal to this module: `OtlpJsonTraceExporter`, `OtlpJsonLogExporter`, and `OtlpJsonMetricExporter` are `private[otel]`, as is the `BatchProcessor` that batches records before a send. There is no public way to construct one yet.
160
+
161
+ The pieces they are built from *are* public, though, so exporting does not have to wait for that entry point. `OtlpJsonEncoder` produces OTLP JSON bytes from recorded signals, `HttpSender` transmits them, and `ExportResult` says whether a failed send is worth retrying — see [Building an Exporter](./custom-exporter.md) for the encoder, the result type, and a worked flush function.
162
+
163
+ For development and testing, the core module's in-process destinations avoid the network entirely: [`log.writer`](../logging/index.md) for formatted output, [`trace.collectedSpans`](../tracing/index.md) for recorded spans, and [`metric.reader`](../metrics/index.md) for measurement snapshots. The [Telemetry Guide](../../../guides/telemetry-guide.md) sketches the intended shape of the wiring once a public exporter lands.
164
+
165
+ ## Threading Context Through `Context[R]`
166
+
167
+ `Propagator` moves trace context between processes. `OtelContext` moves it *within* one, across code that threads dependencies through `Context[R]` rather than reading an ambient storage:
168
+
169
+ ```scala
170
+ final case class OtelContext(spanContext: Option[SpanContext])
171
+
172
+ object OtelContext {
173
+ def current(storage: ContextStorage[Option[SpanContext]]): OtelContext
174
+ def withSpan[A](span: Span, storage: ContextStorage[Option[SpanContext]])(f: => A): A
175
+ }
176
+ ```
177
+
178
+ `OtelContext.current` snapshots whatever span context is active in a storage, giving a plain value that can be placed into a `Context[R & OtelContext]` and passed along like any other dependency:
179
+
180
+ ```scala
181
+ import zio.blocks.telemetry._
182
+ import zio.blocks.telemetry.otel._
183
+
184
+ val storage = ContextStorage.create[Option[SpanContext]](None)
185
+ val snapshot = OtelContext.current(storage)
186
+ ```
187
+
188
+ `OtelContext.withSpan` is the inverse: it makes a span's context active for the duration of a block and restores the previous value afterwards, including when the block throws:
189
+
190
+ ```scala
191
+ import zio.blocks.telemetry._
192
+ import zio.blocks.telemetry.otel._
193
+
194
+ val storage = ContextStorage.create[Option[SpanContext]](None)
195
+ val tracer = trace.get("api-service")
196
+
197
+ tracer.span("handle-request", SpanKind.Server) { span =>
198
+ OtelContext.withSpan(span, storage) {
199
+ // anything reading `storage` here sees this span's context
200
+ OtelContext.current(storage)
201
+ }
202
+ }
203
+ ```
204
+
205
+ An `IsNominalType[OtelContext]` instance is provided, which is what lets the type be used as a `Context` key.
206
+
207
+ ## See Also
208
+
209
+ - [Building an Exporter](./custom-exporter.md) — `OtlpJsonEncoder`, `ExportResult`, and a worked flush function
210
+ - [AttributeValue](../common/any-value.md) — the eight attribute kinds this exporter maps onto OTLP's `AnyValue` JSON shape
211
+ - [Tracing](../tracing/index.md) — opening the spans this module exports, and [`SpanContext`](../tracing/span-context.md), the identity a propagator moves
212
+ - [Telemetry Reference](../index.md) — the core module that records what this one exports
@@ -0,0 +1,155 @@
1
+ ---
2
+ id: index
3
+ title: "Tracing"
4
+ description: "Tracing index: the trace entry point, TracerProvider, Tracer, Span, and supporting types for distributed tracing in the telemetry module."
5
+ keywords:
6
+ - "Distributed Tracing"
7
+ - "Trace Correlation"
8
+ - "Tracing Overview"
9
+ - "TracerProvider"
10
+ sidebar_label: "Tracing"
11
+ ---
12
+
13
+ Tracing follows a single request as it moves through your application — and across services — recording each step as a **span**: a timed unit of work with a name, attributes, and an outcome. Spans nest into a **trace**, a parent/child tree that shows where a request spent its time and where it failed. Because one trace can stretch across several services, it's how you answer "why was this request slow?" when the answer is three network hops away.
14
+
15
+ You create spans through the `trace` object: `trace.span("handle-request") { span => … }` wraps a block of work — it opens a span, runs the block, and closes the span automatically when the block returns. A `trace.span` opened inside another automatically becomes its child, so the tree builds itself with no manual wiring. Inside the block you annotate the span with attributes, events, and a status, and classify its role with a [`SpanKind`](./span-kind.md) when it crosses a service boundary.
16
+
17
+ With no setup, `trace` records every span into an in-memory buffer — the default for development and tests, where `trace.collectedSpans` hands back what was recorded. For production, call `trace.install(provider)` once at startup to send spans to a real backend (Jaeger, OTLP, …); a [`Sampler`](./sampler.md) then decides which spans to keep so you aren't exporting everything under load. The types behind `trace` — [`TracerProvider`](./tracer-provider.md), [`Tracer`](./tracer.md), [`Span`](./span.md), and the [`SpanProcessor`](./span-processor.md)s — are configured once at startup; day to day you just call `trace.span`.
18
+
19
+ ## Open a Span and Record Work
20
+
21
+ Wrap a unit of work in `trace.span(name) { span => … }`.
22
+
23
+ ```scala
24
+ object trace {
25
+ def span[A](name: String)(f: Span => A): A
26
+ }
27
+ ```
28
+
29
+ The block receives a live `Span`; set attributes, add timeline events, and set the completion status on it. The span closes automatically when the block returns.
30
+
31
+ ```scala
32
+ import zio.blocks.telemetry._
33
+
34
+ trace.span("handle-request") { span =>
35
+ span.setAttribute("http.route", "/orders")
36
+ span.setAttribute("http.status_code", 200L)
37
+ span.addEvent("validated")
38
+ span.setStatus(SpanStatus.Ok)
39
+ }
40
+ ```
41
+
42
+ ## Classify a Span and Pre-Set Attributes
43
+
44
+ Two overloads add a [`SpanKind`](./span-kind.md) and, optionally, initial [`Attributes`](../common/attributes.md).
45
+
46
+ ```scala
47
+ object trace {
48
+ def span[A](name: String, kind: SpanKind)(f: Span => A): A
49
+ def span[A](name: String, kind: SpanKind, attributes: Attributes)(f: Span => A): A
50
+ }
51
+ ```
52
+
53
+ `SpanKind` marks a span's role in a trace (`Server`, `Client`, `Producer`, `Consumer`, or the default `Internal`), and the `Attributes` set stamps values known at creation.
54
+
55
+ ```scala
56
+ import zio.blocks.telemetry._
57
+
58
+ trace.span("db-query", SpanKind.Client, Attributes.of(AttributeKey.string("db.system"), "postgresql")) { span =>
59
+ span.setAttribute("db.statement", "SELECT * FROM orders")
60
+ }
61
+ ```
62
+
63
+ ## Nest Spans into a Trace Tree
64
+
65
+ A `trace.span` opened inside another automatically attaches to the enclosing span as its parent through the shared `ContextStorage`, so nested calls build a parent/child tree with no manual context passing.
66
+
67
+ ```scala
68
+ import zio.blocks.telemetry._
69
+
70
+ trace.span("checkout", SpanKind.Server) { _ =>
71
+ trace.span("load-cart") { child =>
72
+ child.setAttribute("cart.items", 3L)
73
+ }
74
+ trace.span("charge-card")(_ => ())
75
+ }
76
+ ```
77
+
78
+ ## Scope Spans to an Instrumentation Library
79
+
80
+ Every span records which code produced it. Spans opened with `trace.span(...)` are all attributed to one scope named `"default"`, which is fine for an application tracing its own work — but it means spans from a library you depend on arrive indistinguishable from your own. `trace.get(name)` returns a [`Tracer`](./tracer.md) that stamps a name of your choosing on every span it records instead, so a backend can group them and someone debugging a slow request can see which component the time went to:
81
+
82
+ ```scala
83
+ object trace {
84
+ def get(name: String): Tracer
85
+ }
86
+ ```
87
+
88
+ Calling this is the application's job, at startup: name the scope after the component it's for, then pass the `Tracer` to that component as a constructor parameter. A library should accept a `Tracer` rather than reach for the global `trace` itself — that keeps it testable in isolation and free of global state. Otherwise the two behave the same for opening spans: same overloads, same automatic parent nesting, same sampler and exporters.
89
+
90
+ ```scala
91
+ import zio.blocks.telemetry._
92
+
93
+ val tracer: Tracer = trace.get("com.example.orders")
94
+
95
+ trace.clearSpans()
96
+ tracer.span("reserve-inventory")(_ => ())
97
+ trace.span("unscoped-work")(_ => ())
98
+
99
+ // the scope each span was recorded under
100
+ trace.collectedSpans.foreach(sd => println(sd.instrumentationScope.name))
101
+ // com.example.orders
102
+ // default
103
+ ```
104
+
105
+ Order matters here. A `Tracer` is bound to whichever provider was installed when `trace.get` ran, while `trace.span` looks up the current provider on every call. Get your tracers *after* `trace.install(...)`, or a tracer created during startup will keep recording into the default in-memory buffer and its spans will never reach the exporter you installed afterwards.
106
+
107
+ ## Inspect Recorded Spans
108
+
109
+ Until you install a provider, finished spans stay in memory where a test can read them back:
110
+
111
+ ```scala
112
+ object trace {
113
+ def collectedSpans: List[SpanData]
114
+ def clearSpans(): Unit
115
+ }
116
+ ```
117
+
118
+ `collectedSpans` returns each completed span as [`SpanData`](./span-data.md), oldest first, and `clearSpans()` empties the buffer — call it at the start of a test so you assert on that test's spans alone.
119
+
120
+ Two limits are worth knowing before you rely on this. The buffer is a ring of 1024 spans, so a long run silently overwrites its oldest entries; assert as you go rather than at the end of a large suite. And both methods read the buffer belonging to the **default** provider, so once you call `trace.install(...)`, they stop seeing new spans — a test that installs a provider has to collect through that provider's own processors instead.
121
+
122
+ ## Install a Provider and Reset
123
+
124
+ `trace.install(provider)` swaps in a production `TracerProvider` — configured with a [`Resource`](../common/resource.md), a `Sampler`, and one or more `SpanProcessor` exporters.
125
+
126
+ ```scala
127
+ object trace {
128
+ def install(provider: TracerProvider): Unit
129
+ def removeAll(): Unit
130
+ }
131
+ ```
132
+
133
+ Build the provider once at startup and install it before any span is opened — name your service in the `Resource` so exported spans can be told apart from other services, and pick a `Sampler` to decide how many spans to keep:
134
+
135
+ ```scala
136
+ import zio.blocks.telemetry._
137
+
138
+ trace.install(
139
+ TracerProvider.builder
140
+ .setResource(Resource.create(Attributes.of(Attributes.ServiceName, "order-service")))
141
+ .setSampler(AlwaysOnSampler)
142
+ .build()
143
+ )
144
+
145
+ // every trace.span in the application now records through this provider
146
+ trace.span("handle-request", SpanKind.Server)(_.setAttribute("http.route", "/orders"))
147
+ ```
148
+
149
+ `trace.removeAll()` swaps in a provider whose sampler drops everything. Your `trace.span` blocks still run — they just receive a no-op `Span` that records nothing — so removing tracing never changes what your code does, only what it reports. Note that it leaves the in-memory buffer untouched; use `clearSpans()` to empty that.
150
+
151
+ ## See Also
152
+
153
+ - [Telemetry Guide](../../../guides/telemetry-guide.md) — tracing data flow, sampling, and production patterns
154
+ - [Telemetry Reference](../index.md) — module overview and all three pillars
155
+ - [Common Types](../common/index.md) — `Attributes`, `AttributeKey`, `Resource`, and `InstrumentationScope`
@@ -0,0 +1,89 @@
1
+ ---
2
+ id: sampler
3
+ title: "Sampler"
4
+ description: "Decides per-span whether to drop, record-only, or record-and-sample."
5
+ keywords:
6
+ - "Distributed Tracing"
7
+ - "Trace Sampling"
8
+ - "Head-Based Sampling"
9
+ - "Custom Sampler"
10
+ - "Sampler"
11
+ sidebar_label: "Sampler"
12
+ ---
13
+
14
+ `Sampler` is the policy that decides, for every new span, whether to record it with the sampled flag set (`RecordAndSample`), record it with the flag cleared (`RecordOnly`), or drop it (`Drop`). Both recording decisions reach every `SpanProcessor` identically; `RecordOnly` differs only in the cleared flag, which is the signal a downstream exporter or service uses to leave the span unexported. It exists to keep tracing volume under control: the sampler runs once per span in the hot path, before any recording work happens. You rarely implement one — the three built-ins cover the common strategies — and you configure a sampler once on the [`TracerProvider`](./tracer-provider.md) rather than calling it yourself. When the decision is `Drop`, [`Tracer`](./tracer.md) hands your block a `Span.NoOp` that records nothing and reaches no processor.
15
+
16
+ ```scala
17
+ trait Sampler {
18
+ def shouldSample(
19
+ parentContext: Option[SpanContext],
20
+ traceIdHi: Long,
21
+ traceIdLo: Long,
22
+ name: String,
23
+ kind: SpanKind,
24
+ attributes: Attributes,
25
+ links: Seq[SpanLink]
26
+ ): SamplingResult
27
+
28
+ def description: String
29
+ }
30
+
31
+ final case class SamplingResult(
32
+ decision: SamplingDecision, // Drop | RecordOnly | RecordAndSample
33
+ attributes: Attributes, // extra attributes injected into the span
34
+ traceState: String
35
+ )
36
+ ```
37
+
38
+ ## Predefined Samplers
39
+
40
+ Three built-in samplers cover the strategies most services need, so a custom implementation is rarely necessary:
41
+
42
+ | Sampler | Behaviour |
43
+ |-------------------------------------|------------------------------------------------------------------------------------|
44
+ | `AlwaysOnSampler` | Always returns `RecordAndSample`. Default for `TracerProvider.builder`. |
45
+ | `AlwaysOffSampler` | Always returns `Drop`, reusing one cached `SamplingResult`. |
46
+ | `ParentBasedSampler(root: Sampler)` | Follows the parent span's sampled flag; delegates to `root` when no parent exists. |
47
+
48
+ Set one on the provider at startup — this configures OpenTelemetry-compatible head sampling:
49
+
50
+ ```scala
51
+ import zio.blocks.telemetry._
52
+
53
+ val provider = TracerProvider.builder
54
+ .setSampler(ParentBasedSampler(AlwaysOnSampler))
55
+ .build()
56
+
57
+ provider.shutdown()
58
+ ```
59
+
60
+ ## Custom Samplers
61
+
62
+ When the built-ins do not fit — for example, sampling only premium tenants or at a probabilistic rate — implement `Sampler` yourself. Cache the `SamplingResult` values so the hot path stays allocation-free:
63
+
64
+ ```scala
65
+ import zio.blocks.telemetry._
66
+
67
+ object TenantSampler extends Sampler {
68
+ private val sampled = SamplingResult(SamplingDecision.RecordAndSample, Attributes.empty, "")
69
+ private val dropped = SamplingResult(SamplingDecision.Drop, Attributes.empty, "")
70
+
71
+ def shouldSample(
72
+ parentContext: Option[SpanContext],
73
+ traceIdHi: Long, traceIdLo: Long,
74
+ name: String, kind: SpanKind, attributes: Attributes,
75
+ links: Seq[SpanLink]
76
+ ): SamplingResult =
77
+ if (attributes.get(AttributeKey.string("tenant")).contains("premium")) sampled
78
+ else dropped
79
+
80
+ def description: String = "TenantSampler"
81
+ }
82
+
83
+ val provider = TracerProvider.builder.setSampler(TenantSampler).build()
84
+ provider.shutdown()
85
+ ```
86
+
87
+ The `attributes` your sampler sees are only the ones passed at creation — `trace.span(name, kind, attributes)` or `tracer.span(name, kind, attributes)`. Anything you set on the span *inside* the block happens after the decision, so a sampler can never route on it. A span started through [`SpanBuilder`](./span-builder.md) never consults a sampler at all.
88
+
89
+ A dropped span is not nothing. The block still runs with a no-op span, and a valid trace id is still put in scope with the sampled bit cleared — so a nested `trace.span` under `ParentBasedSampler` is dropped too, and an outgoing request still carries the trace with flags `00`. That's what lets a downstream service honor the decision you made at the edge.