@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.
- package/adr/2026-07-18-data-migration.md +123 -0
- package/guides/async-getting-started.md +687 -0
- package/guides/compile-time-resource-safety-with-scope.md +21 -16
- package/guides/getting-started-with-mux.md +1395 -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 +640 -165
- package/guides/sql-checked-interpolation.md +173 -0
- package/guides/sql-transactions.md +286 -0
- package/guides/telemetry-guide.md +1130 -0
- package/guides/zio-schema-migration.md +29 -22
- package/index.md +248 -389
- 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 +1499 -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/config-decoder.md +460 -0
- package/reference/config/config-source.md +489 -0
- package/reference/config/errors.md +278 -0
- package/reference/config/flags.md +369 -0
- package/reference/config/formats.md +314 -0
- package/reference/config/index.md +304 -0
- package/reference/config/rollout.md +336 -0
- package/reference/context.md +9 -52
- package/reference/data-migration.md +269 -0
- package/reference/datastar/attributes.md +302 -0
- package/reference/datastar/events.md +234 -0
- package/reference/datastar/index.md +256 -0
- package/reference/datastar/signals.md +230 -0
- package/reference/datastar/sse.md +295 -0
- package/reference/datastar.md +346 -0
- package/reference/docs.md +1461 -345
- package/reference/endpoint/auth-type.md +146 -0
- package/reference/endpoint/bulk-creation.md +96 -0
- package/reference/endpoint/endpoint.md +297 -0
- package/reference/endpoint/http-codec.md +249 -0
- package/reference/endpoint/index.md +745 -0
- package/reference/endpoint/path-codec.md +225 -0
- package/reference/endpoint/route-pattern.md +194 -0
- package/reference/endpoint/route-tree.md +111 -0
- package/reference/endpoint/segment-codec.md +199 -0
- package/reference/html.md +1424 -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 +807 -0
- package/reference/htmx/response-headers.md +240 -0
- package/reference/http-model/headers.md +735 -0
- package/reference/http-model/index.md +49 -0
- package/reference/http-model/model.md +1517 -0
- package/reference/http-model/schema-codecs.md +522 -0
- package/reference/http-model/schema.md +750 -0
- package/reference/http-model/server-sent-event.md +341 -0
- package/reference/jwt.md +195 -0
- package/reference/maybe.md +943 -0
- package/reference/media-type.md +2 -2
- package/reference/mux.md +254 -0
- package/reference/mux.mdx +828 -0
- package/reference/openapi.md +1351 -0
- package/reference/projection.md +654 -0
- package/reference/resource-management/defer-handle.md +1 -1
- package/reference/resource-management/resource.md +31 -98
- package/reference/resource-management/scope.md +28 -220
- package/reference/resource-management/wire.md +5 -55
- 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 +185 -0
- package/reference/ringbuffer/mpsc.mdx +164 -0
- package/reference/ringbuffer/spmc.mdx +108 -0
- package/reference/ringbuffer/spsc.mdx +416 -0
- package/reference/{allows.md → schema/allows.md} +4 -100
- package/reference/{binding-resolver.md → schema/binding-resolver.md} +1 -1
- package/reference/{binding.md → schema/binding.md} +3 -4
- package/reference/schema/built-in-codecs/avro.md +451 -0
- package/reference/schema/built-in-codecs/bson.md +510 -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} +11 -11
- package/reference/{dynamic-optic.md → schema/dynamic-optic.md} +196 -5
- package/reference/{dynamic-schema.md → schema/dynamic-schema.md} +8 -8
- package/reference/schema/format.md +92 -0
- package/reference/schema/index.md +52 -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} +167 -72
- package/reference/schema/reflect-transformer.md +140 -0
- 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/schema-search.md +263 -0
- package/reference/{schema.md → schema/schema.md} +22 -2
- 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 +1032 -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 +148 -0
- package/reference/sql/db-tx.md +114 -0
- package/reference/sql/db-value.md +41 -0
- package/reference/sql/ddl.md +85 -0
- package/reference/sql/frag.md +288 -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 +363 -0
- package/reference/sql-zio.md +112 -0
- package/reference/streams/core/index.md +32 -0
- package/reference/streams/core/pipeline.md +854 -0
- package/reference/streams/core/sink.md +1404 -0
- package/reference/streams/core/stream.md +3236 -0
- package/reference/streams/execution-and-compatibility/async-execution.md +822 -0
- package/reference/streams/execution-and-compatibility/index.md +35 -0
- package/reference/streams/execution-and-compatibility/platform-differences.md +297 -0
- package/reference/streams/execution-and-compatibility/scala-2-compatibility.md +88 -0
- package/reference/streams/execution-and-compatibility/zero-boxing.md +393 -0
- package/reference/streams/index.md +726 -0
- package/reference/streams/primitives/index.md +30 -0
- package/reference/streams/primitives/reader.md +1992 -0
- package/reference/streams/primitives/writer.md +1201 -0
- package/reference/telemetry/common/any-value.md +90 -0
- package/reference/telemetry/common/attribute-key.md +87 -0
- package/reference/telemetry/common/attributes.md +118 -0
- package/reference/telemetry/common/index.md +39 -0
- package/reference/telemetry/common/instrumentation-scope.md +24 -0
- package/reference/telemetry/common/resource.md +34 -0
- package/reference/telemetry/index.md +311 -0
- package/reference/telemetry/logging/index.md +197 -0
- package/reference/telemetry/logging/log-enrichment.md +72 -0
- package/reference/telemetry/logging/log-formatter.md +100 -0
- package/reference/telemetry/logging/log-record-processor.md +56 -0
- package/reference/telemetry/logging/log-record.md +44 -0
- package/reference/telemetry/logging/log-writer.md +64 -0
- package/reference/telemetry/logging/logger-provider.md +142 -0
- package/reference/telemetry/logging/logger.md +83 -0
- package/reference/telemetry/logging/severity.md +62 -0
- package/reference/telemetry/metrics/index.md +150 -0
- package/reference/telemetry/metrics/instruments.md +183 -0
- package/reference/telemetry/metrics/labeled-instruments.md +74 -0
- package/reference/telemetry/metrics/meter-provider.md +76 -0
- package/reference/telemetry/metrics/meter.md +98 -0
- package/reference/telemetry/metrics/metric-data.md +57 -0
- package/reference/telemetry/otel/custom-exporter.md +216 -0
- package/reference/telemetry/otel/index.md +212 -0
- package/reference/telemetry/tracing/index.md +155 -0
- package/reference/telemetry/tracing/sampler.md +89 -0
- package/reference/telemetry/tracing/span-builder.md +57 -0
- package/reference/telemetry/tracing/span-context.md +39 -0
- package/reference/telemetry/tracing/span-data.md +32 -0
- package/reference/telemetry/tracing/span-kind.md +55 -0
- package/reference/telemetry/tracing/span-processor.md +53 -0
- package/reference/telemetry/tracing/span-status.md +47 -0
- package/reference/telemetry/tracing/span.md +117 -0
- package/reference/telemetry/tracing/tracer-provider.md +91 -0
- package/reference/telemetry/tracing/tracer.md +52 -0
- package/reference/typeid.md +5 -83
- package/sidebars.js +376 -43
- package/undocumented-report.md +528 -270
- 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,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.
|