|
| 1 | +# Exporter Delegate Chain (Spans / Logs / Metrics) |
| 2 | + |
| 3 | +Status: draft |
| 4 | + |
| 5 | +This document explains the default exporter delegation chain used by the OpenTelemetry Android RUM SDK and how you can customize or extend it with your own exporter layers. |
| 6 | + |
| 7 | +## Goals |
| 8 | + |
| 9 | +1. Avoid ANRs and StrictMode violations during SDK initialization by deferring expensive exporter setup off the main thread. |
| 10 | +2. Ensure early telemetry (signals produced before exporters finish initializing) is not lost. |
| 11 | +3. Provide optional durable (disk) buffering for offline / startup scenarios. |
| 12 | +4. Allow applications to insert custom exporters (filtering, redaction, fan‑out, encryption, etc.). |
| 13 | + |
| 14 | +## High-Level Flow |
| 15 | + |
| 16 | +When you call `OpenTelemetryRum.builder()...build()`: |
| 17 | + |
| 18 | +1. Lightweight in‑memory buffer exporters are installed immediately so spans, logs, and metrics can be accepted. |
| 19 | +2. Actual exporter initialization (logging / OTLP + optional disk storage wiring) runs asynchronously on a background executor. |
| 20 | +3. Once ready, buffered signals are flushed to the real exporters and all future exports are delegated directly. |
| 21 | +4. If disk buffering is enabled, signals flow through disk first, then are later read back and exported to the network exporter. |
| 22 | + |
| 23 | +## Default Chain (No Custom Exporters, Disk Buffering Enabled) |
| 24 | + |
| 25 | +For each signal type (Span / LogRecord / Metric): |
| 26 | + |
| 27 | +```text |
| 28 | +BufferDelegating*Exporter --> *ToDiskExporter --> (Original default exporter) |
| 29 | + (in-memory buffer) (writes batch (e.g. LoggingSpanExporter, |
| 30 | + files to storage) SystemOutLogRecordExporter, |
| 31 | + LoggingMetricExporter) |
| 32 | +```text |
| 33 | +
|
| 34 | +Later, a periodic scheduler reads batches from disk and replays them on the original exporter via: |
| 35 | +
|
| 36 | +```text |
| 37 | +SignalFromDiskExporter -> *FromDiskExporter -> Original exporter |
| 38 | +``` |
| 39 | + |
| 40 | +(Where `*` stands for `Span`, `LogRecord`, or `Metric`.) |
| 41 | + |
| 42 | +## Default Chain (Disk Buffering Disabled) |
| 43 | + |
| 44 | +```text |
| 45 | +BufferDelegating*Exporter --> (Original default exporter) |
| 46 | +``` |
| 47 | + |
| 48 | +No disk layer is inserted and no scheduled reader is enabled. |
| 49 | + |
| 50 | +## Components |
| 51 | + |
| 52 | +### BufferDelegating[Span|Log|Metric]Exporter |
| 53 | + |
| 54 | +Internal in‑memory temporary exporters created immediately. They hold up to 5,000 items (per signal type) produced before the real delegate is attached. If the buffer fills, additional items are dropped with a warning log (`The <type> buffer was filled before export delegate set...`). After `setDelegate(...)` is invoked, the buffered data is exported, optional pending `flush()` / `shutdown()` calls are honored, and the buffer is cleared. From that point on the exporter no longer buffers anything: every new signal is delegated straight through to the next exporter in the chain (*ToDiskExporter if disk buffering is enabled, otherwise the customized/base exporter). |
| 55 | + |
| 56 | +Source examples: |
| 57 | + |
| 58 | +* `core/src/main/java/io/opentelemetry/android/export/BufferDelegatingSpanExporter.kt` |
| 59 | +* `core/src/main/java/io/opentelemetry/android/export/BufferDelegatingLogExporter.kt` |
| 60 | +* `core/src/main/java/io/opentelemetry/android/export/BufferDelegatingMetricExporter.kt` |
| 61 | + |
| 62 | +### *ToDiskExporter (Optional Layer) |
| 63 | + |
| 64 | +Wrappers provided by `io.opentelemetry.contrib.disk.buffering` that persist batches to disk before forwarding to the underlying ("original") exporter. Inserted only when `DiskBufferingConfig.enabled == true`. |
| 65 | + |
| 66 | +### Original Exporters |
| 67 | + |
| 68 | +The base exporters produced by the builder if you don't customize them: |
| 69 | + |
| 70 | +* Spans: `LoggingSpanExporter` |
| 71 | +* Logs: `SystemOutLogRecordExporter` |
| 72 | +* Metrics: `LoggingMetricExporter` |
| 73 | + |
| 74 | +In real deployments you usually replace these with OTLP exporters (e.g. OTLP/HTTP) by supplying customizers or by configuring upstream dependencies providing them. |
| 75 | + |
| 76 | +### SignalFromDiskExporter & *FromDiskExporter |
| 77 | + |
| 78 | +A coordinator (`SignalFromDiskExporter`) plus per-signal readers that pull batches from disk (one stored batch per original write call) and export them to the original exporter. A scheduler periodically invokes these to drain the on-device queue. |
| 79 | + |
| 80 | +## Asynchronous Initialization |
| 81 | + |
| 82 | +Exporter initialization (including disk capacity checks and creating `Storage` directories) is executed on `AsyncTask.THREAD_POOL_EXECUTOR` to prevent main-thread stalls. This was introduced after ANRs were observed when performing synchronous disk space checks (see PR #709). The memory buffering ensures telemetry created during this window is retained (up to buffer limits). |
| 83 | + |
| 84 | +## Customizing the Chain |
| 85 | + |
| 86 | +The builder exposes customizer hooks that let you wrap or replace the default exporter before the disk layer is added: |
| 87 | + |
| 88 | +```java |
| 89 | +OpenTelemetryRum.builder(application) |
| 90 | + .addSpanExporterCustomizer(exp -> new MyFilteringSpanExporter(exp)) |
| 91 | + .addLogRecordExporterCustomizer(exp -> SpanAttributeRedactingLogExporter.wrap(exp)) |
| 92 | + .addMetricExporterCustomizer(exp -> myMetricsFanOut(exp)) |
| 93 | + .build(); |
| 94 | +``` |
| 95 | + |
| 96 | +Customizer semantics (build time vs. runtime order): |
| 97 | + |
| 98 | +* Start with the SDK's default exporter (e.g. `LoggingSpanExporter`) – call this the base exporter. |
| 99 | +* Each customizer is invoked in the exact order it was registered. It receives the exporter built so far and returns a (possibly wrapped) exporter. This produces a nested chain. If you register A then B then C, the resulting nesting is: `C(B(A(base)))`. |
| 100 | +* If disk buffering is enabled, the fully customized exporter (the outermost custom wrapper in code, i.e. `C(...)` in the example) is then wrapped by `*ToDiskExporter` so data is persisted to disk before reaching any of the custom wrappers. |
| 101 | +* Finally a `BufferDelegating*Exporter` wraps the whole thing to capture early telemetry while async initialization finishes. After `setDelegate(...)` it becomes a pass‑through. |
| 102 | +* Runtime data flow therefore in the A,B,C example (registered in that order) is: |
| 103 | + |
| 104 | +```text |
| 105 | +BufferDelegating*Exporter → *ToDiskExporter (if enabled) → C → B → A → base exporter |
| 106 | +``` |
| 107 | + |
| 108 | +Notes: |
| 109 | + |
| 110 | +* Registration order (A,B,C) is outside‑in at build time, but runtime export order is the reverse (C,B,A) because of nested wrapping. |
| 111 | +* The disk buffering layer is not added via a customizer; it is applied after all customizers are evaluated. |
| 112 | +* If disk buffering is disabled the flow simply omits that layer: |
| 113 | + |
| 114 | +```text |
| 115 | +BufferDelegating*Exporter → C → B → A → base exporter |
| 116 | +``` |
| 117 | + |
| 118 | +Summary (concise): Once the exporter is built and all customizers have been applied, that result is wrapped by a disk buffering exporter (if enabled) and then finally wrapped by a `BufferDelegating*Exporter`. |
| 119 | + |
| 120 | +You control the final network/export sink by returning it from the last customizer. For example, to send spans via OTLP HTTP: |
| 121 | + |
| 122 | +```java |
| 123 | +builder.addSpanExporterCustomizer(prev -> OtlpHttpSpanExporter.builder() |
| 124 | + .setEndpoint("https://collector.example.com/v1/traces") |
| 125 | + .build()); |
| 126 | +``` |
| 127 | + |
| 128 | +(You can wrap the OTLP exporter again if you need additional behavior.) |
| 129 | + |
| 130 | +## Flush and Shutdown Behavior |
| 131 | + |
| 132 | +If `flush()` or `shutdown()` is invoked before delegates are attached, the `DelegatingExporter` stores a pending result. Once the real delegate is set: |
| 133 | + |
| 134 | +1. Buffered data is exported |
| 135 | +2. A flush is issued if it was pending |
| 136 | +3. A shutdown is issued if it was pending |
| 137 | +4. Pending futures complete with the real delegate result |
| 138 | + |
| 139 | +## Failure and Backpressure Characteristics |
| 140 | + |
| 141 | +* Buffer Overflow: If more than 5,000 signals of a type are produced before delegate attachment, newer signals beyond capacity are dropped (a warning is logged). Consider reducing startup emission volume or initializing earlier if this occurs. |
| 142 | +* Disk Layer: If disk initialization fails, the SDK logs an error and proceeds WITHOUT disk buffering (the chain reverts to memory buffer -> original exporter). The scheduled disk reader is disabled in this case. |
| 143 | +* From-Disk Export Failures: Batches that fail to export remain on disk (subject to age / size pruning rules governed by `DiskBufferingConfig`). |
| 144 | +* To-Disk Export Failures: If disk buffering is enabled and initialized but a batch cannot be written (e.g., I/O error or size constraint), that batch is immediately forwarded to the underlying exporter (skipping disk for that batch) to avoid data loss. Subsequent batches continue attempting disk writes. |
| 145 | + |
| 146 | +## Configuration References |
| 147 | + |
| 148 | +`DiskBufferingConfig` (see `core/src/main/java/io/opentelemetry/android/features/diskbuffering/DiskBufferingConfig.kt`) controls: |
| 149 | + |
| 150 | +* Enable/disable disk buffering |
| 151 | +* Max cache folder size |
| 152 | +* Max file size |
| 153 | +* File age thresholds for read/write rotation |
| 154 | +* Optional directory override |
| 155 | + |
| 156 | +## When To Add Your Own Layer |
| 157 | + |
| 158 | +Common customization patterns: |
| 159 | + |
| 160 | +* Filtering / Sampling: Drop or modify signals before disk/network (privacy, volume control) |
| 161 | +* Redaction / PII Scrubbing: Remove sensitive attributes centrally |
| 162 | +* Fan-out: Send data to multiple exporters (e.g., internal analytics + OTLP) |
| 163 | +* Encryption: Encrypt payloads prior to writing to disk (wrap before `*ToDiskExporter`) or prior to network send (wrap after disk exporter if you want encrypted at rest) |
| 164 | + |
| 165 | +Example filtering span exporter: |
| 166 | + |
| 167 | +```java |
| 168 | +class MyFilteringSpanExporter implements SpanExporter { |
| 169 | + private final SpanExporter delegate; |
| 170 | + MyFilteringSpanExporter(SpanExporter delegate) { this.delegate = delegate; } |
| 171 | + @Override public CompletableResultCode export(Collection<SpanData> spans) { |
| 172 | + List<SpanData> filtered = spans.stream() |
| 173 | + .filter(s -> !"debug".equals(s.getAttributes().get(stringKey("env")))) |
| 174 | + .toList(); |
| 175 | + return delegate.export(filtered); |
| 176 | + } |
| 177 | + @Override public CompletableResultCode flush() { return delegate.flush(); } |
| 178 | + @Override public CompletableResultCode shutdown() { return delegate.shutdown(); } |
| 179 | +} |
| 180 | +``` |
| 181 | + |
| 182 | +Register in builder: |
| 183 | + |
| 184 | +```java |
| 185 | +builder.addSpanExporterCustomizer(exp -> new MyFilteringSpanExporter(exp)); |
| 186 | +``` |
0 commit comments