From d1f9dc4e0fce662510a51d81dbe3ac3c65260beb Mon Sep 17 00:00:00 2001 From: Nicklas Ansman Date: Fri, 22 Nov 2024 11:33:17 -0500 Subject: [PATCH] Prevent ANR during SDK initialization by deferring exporter initialization Description: When initializing the OpenTelemetry Android SDK with disk buffering enabled, we discovered that synchronous disk space checks were causing ANRs in production. These checks occur during the creation of disk buffering exporters, specifically in `DiskManager.getMaxFolderSize()`, which makes blocking IPC calls through `StorageManager.getAllocatableBytes()` on the main thread. The issue manifests in the following trace: ``` android.os.BinderProxy.transact (BinderProxy.java:662) android.os.storage.IStorageManager$Stub$Proxy.getAllocatableBytes (IStorageManager.java:2837) android.os.storage.StorageManager.getAllocatableBytes (StorageManager.java:2414) android.os.storage.StorageManager.getAllocatableBytes (StorageManager.java:2404) io.opentelemetry.android.internal.services.CacheStorage.getAvailableSpace (CacheStorage.java:66) io.opentelemetry.android.internal.services.CacheStorage.ensureCacheSpaceAvailable (CacheStorage.java:50) io.opentelemetry.android.internal.features.persistence.DiskManager.getMaxFolderSize (DiskManager.kt:58) io.opentelemetry.android.OpenTelemetryRumBuilder.createStorageConfiguration (OpenTelemetryRumBuilder.java:338) io.opentelemetry.android.OpenTelemetryRumBuilder.build (OpenTelemetryRumBuilder.java:286) ``` Our Solution: 1. Initialize the SDK with `BufferDelegatingExporter` instances that can immediately accept telemetry data. 2. Move exporter initialization off the main thread. 3. Once async initialization completes, flush buffered signals to initialized exporters and delegate all future signals. The primary goal of this solution is to be unobtrusive and prevent ANRs caused by initialization of disk exporters, while preventing signals from being dropped. Testing - Unit tests covering buffer delegation and RUM building. - Verified on both disk enabled and disk disabled builds. --- .../android/OpenTelemetryRumBuilder.java | 86 +++++++++---- .../export/BufferDelegatingLogExporter.kt | 35 ++++++ .../export/BufferDelegatingSpanExporter.kt | 35 ++++++ .../export/BufferedDelegatingExporter.kt | 99 +++++++++++++++ .../android/OpenTelemetryRumBuilderTest.java | 83 +++++++----- .../export/BufferDelegatingLogExporterTest.kt | 92 ++++++++++++++ .../BufferDelegatingSpanExporterTest.kt | 118 ++++++++++++++++++ 7 files changed, 493 insertions(+), 55 deletions(-) create mode 100644 core/src/main/java/io/opentelemetry/android/export/BufferDelegatingLogExporter.kt create mode 100644 core/src/main/java/io/opentelemetry/android/export/BufferDelegatingSpanExporter.kt create mode 100644 core/src/main/java/io/opentelemetry/android/export/BufferedDelegatingExporter.kt create mode 100644 core/src/test/java/io/opentelemetry/android/export/BufferDelegatingLogExporterTest.kt create mode 100644 core/src/test/java/io/opentelemetry/android/export/BufferDelegatingSpanExporterTest.kt diff --git a/core/src/main/java/io/opentelemetry/android/OpenTelemetryRumBuilder.java b/core/src/main/java/io/opentelemetry/android/OpenTelemetryRumBuilder.java index a01e497ec..af74cb45b 100644 --- a/core/src/main/java/io/opentelemetry/android/OpenTelemetryRumBuilder.java +++ b/core/src/main/java/io/opentelemetry/android/OpenTelemetryRumBuilder.java @@ -10,8 +10,11 @@ import android.app.Application; import android.util.Log; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import io.opentelemetry.android.common.RumConstants; import io.opentelemetry.android.config.OtelRumConfig; +import io.opentelemetry.android.export.BufferDelegatingLogExporter; +import io.opentelemetry.android.export.BufferDelegatingSpanExporter; import io.opentelemetry.android.features.diskbuffering.DiskBufferingConfiguration; import io.opentelemetry.android.features.diskbuffering.SignalFromDiskExporter; import io.opentelemetry.android.features.diskbuffering.scheduler.DefaultExportScheduleHandler; @@ -279,11 +282,65 @@ public OpenTelemetryRum build() { InitializationEvents initializationEvents = InitializationEvents.get(); applyConfiguration(initializationEvents); + BufferDelegatingLogExporter bufferDelegatingLogExporter = new BufferDelegatingLogExporter(); + + BufferDelegatingSpanExporter bufferDelegatingSpanExporter = + new BufferDelegatingSpanExporter(); + + SessionManager sessionManager = + SessionManager.create(timeoutHandler, config.getSessionTimeout().toNanos()); + + OpenTelemetrySdk sdk = + OpenTelemetrySdk.builder() + .setTracerProvider( + buildTracerProvider( + sessionManager, application, bufferDelegatingSpanExporter)) + .setLoggerProvider( + buildLoggerProvider(sessionManager, application, logsExporter)) + .setMeterProvider(buildMeterProvider(application)) + .setPropagators(buildFinalPropagators()) + .build(); + + otelSdkReadyListeners.forEach(listener -> listener.accept(sdk)); + + SdkPreconfiguredRumBuilder delegate = + new SdkPreconfiguredRumBuilder( + application, + sdk, + timeoutHandler, + sessionManager, + config.shouldDiscoverInstrumentations(), + getServiceManager()); + + android.os.AsyncTask.THREAD_POOL_EXECUTOR.execute( + () -> { + initializeExporters( + serviceManager, + initializationEvents, + bufferDelegatingSpanExporter, + bufferDelegatingLogExporter); + }); + + instrumentations.forEach(delegate::addInstrumentation); + + return delegate.build(); + } + + private void initializeExporters( + ServiceManager serviceManager, + InitializationEvents initializationEvents, + BufferDelegatingSpanExporter bufferDelegatingSpanExporter, + BufferDelegatingLogExporter bufferedDelegatingLogExporter) { + DiskBufferingConfiguration diskBufferingConfiguration = config.getDiskBufferingConfiguration(); + SpanExporter spanExporter = buildSpanExporter(); + LogRecordExporter logsExporter = buildLogsExporter(); + SignalFromDiskExporter signalFromDiskExporter = null; + if (diskBufferingConfiguration.isEnabled()) { try { StorageConfiguration storageConfiguration = createStorageConfiguration(); @@ -304,35 +361,14 @@ public OpenTelemetryRum build() { Log.e(RumConstants.OTEL_RUM_LOG_TAG, "Could not initialize disk exporters.", e); } } - initializationEvents.spanExporterInitialized(spanExporter); - SessionManager sessionManager = - SessionManager.create(timeoutHandler, config.getSessionTimeout().toNanos()); - - OpenTelemetrySdk sdk = - OpenTelemetrySdk.builder() - .setTracerProvider( - buildTracerProvider(sessionManager, application, spanExporter)) - .setLoggerProvider( - buildLoggerProvider(sessionManager, application, logsExporter)) - .setMeterProvider(buildMeterProvider(application)) - .setPropagators(buildFinalPropagators()) - .build(); + initializationEvents.spanExporterInitialized(spanExporter); - otelSdkReadyListeners.forEach(listener -> listener.accept(sdk)); + bufferedDelegatingLogExporter.setDelegate(logsExporter); - scheduleDiskTelemetryReader(signalFromDiskExporter); + bufferDelegatingSpanExporter.setDelegate(spanExporter); - SdkPreconfiguredRumBuilder delegate = - new SdkPreconfiguredRumBuilder( - application, - sdk, - timeoutHandler, - sessionManager, - config.shouldDiscoverInstrumentations(), - getServiceManager()); - instrumentations.forEach(delegate::addInstrumentation); - return delegate.build(); + scheduleDiskTelemetryReader(signalFromDiskExporter, diskBufferingConfiguration); } @NonNull diff --git a/core/src/main/java/io/opentelemetry/android/export/BufferDelegatingLogExporter.kt b/core/src/main/java/io/opentelemetry/android/export/BufferDelegatingLogExporter.kt new file mode 100644 index 000000000..d75a22b24 --- /dev/null +++ b/core/src/main/java/io/opentelemetry/android/export/BufferDelegatingLogExporter.kt @@ -0,0 +1,35 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.android.export + +import io.opentelemetry.sdk.common.CompletableResultCode +import io.opentelemetry.sdk.logs.data.LogRecordData +import io.opentelemetry.sdk.logs.export.LogRecordExporter + +/** + * An in-memory buffer delegating log exporter that buffers log records in memory until a delegate is set. + * Once a delegate is set, the buffered log records are exported to the delegate. + * + * The buffer size is set to 5,000 log entries by default. If the buffer is full, the exporter will drop new log records. + */ +internal class BufferDelegatingLogExporter( + maxBufferedLogs: Int = 5_000, +) : BufferedDelegatingExporter(bufferedSignals = maxBufferedLogs), + LogRecordExporter { + override fun exportToDelegate( + delegate: LogRecordExporter, + data: Collection, + ): CompletableResultCode = delegate.export(data) + + override fun shutdownDelegate(delegate: LogRecordExporter): CompletableResultCode = delegate.shutdown() + + override fun export(logs: Collection): CompletableResultCode = bufferOrDelegate(logs) + + override fun flush(): CompletableResultCode = + withDelegateOrNull { delegate -> + delegate?.flush() ?: CompletableResultCode.ofSuccess() + } +} diff --git a/core/src/main/java/io/opentelemetry/android/export/BufferDelegatingSpanExporter.kt b/core/src/main/java/io/opentelemetry/android/export/BufferDelegatingSpanExporter.kt new file mode 100644 index 000000000..f6683a375 --- /dev/null +++ b/core/src/main/java/io/opentelemetry/android/export/BufferDelegatingSpanExporter.kt @@ -0,0 +1,35 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.android.export + +import io.opentelemetry.sdk.common.CompletableResultCode +import io.opentelemetry.sdk.trace.data.SpanData +import io.opentelemetry.sdk.trace.export.SpanExporter + +/** + * An in-memory buffer delegating span exporter that buffers span data in memory until a delegate is set. + * Once a delegate is set, the buffered span data is exported to the delegate. + * + * The buffer size is set to 5,000 spans by default. If the buffer is full, the exporter will drop new span data. + */ +internal class BufferDelegatingSpanExporter( + maxBufferedSpans: Int = 5_000, +) : BufferedDelegatingExporter(bufferedSignals = maxBufferedSpans), + SpanExporter { + override fun exportToDelegate( + delegate: SpanExporter, + data: Collection, + ): CompletableResultCode = delegate.export(data) + + override fun shutdownDelegate(delegate: SpanExporter): CompletableResultCode = delegate.shutdown() + + override fun export(spans: Collection): CompletableResultCode = bufferOrDelegate(spans) + + override fun flush(): CompletableResultCode = + withDelegateOrNull { delegate -> + delegate?.flush() ?: CompletableResultCode.ofSuccess() + } +} diff --git a/core/src/main/java/io/opentelemetry/android/export/BufferedDelegatingExporter.kt b/core/src/main/java/io/opentelemetry/android/export/BufferedDelegatingExporter.kt new file mode 100644 index 000000000..91ac598a2 --- /dev/null +++ b/core/src/main/java/io/opentelemetry/android/export/BufferedDelegatingExporter.kt @@ -0,0 +1,99 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.android.export + +import io.opentelemetry.sdk.common.CompletableResultCode +import java.util.concurrent.atomic.AtomicBoolean + +/** + * An in-memory buffer delegating signal exporter that buffers signal in memory until a delegate is set. + * Once a delegate is set, the buffered signals are exported to the delegate. + * + * The buffer size is set to 5,000 by default. If the buffer is full, the exporter will drop new signals. + */ +internal abstract class BufferedDelegatingExporter(private val bufferedSignals: Int = 5_000) { + private var delegate: D? = null + private val buffer = arrayListOf() + private val lock = Any() + private var isShutDown = AtomicBoolean(false) + + /** + * Sets the delegate for this exporter and flushes the buffer to the delegate. + * + * If the delegate has already been set, an [IllegalStateException] will be thrown. + * If this exporter has been shut down, the delegate will be shut down immediately. + * + * @param delegate the delegate to set + * + * @throws IllegalStateException if a delegate has already been set + */ + fun setDelegate(delegate: D) { + synchronized(lock) { + check(this.delegate == null) { "Exporter delegate has already been set." } + + flushToDelegate(delegate) + + this.delegate = delegate + + if (isShutDown.get()) { + shutdownDelegate(delegate) + } + } + } + + /** + * Buffers the given data if the delegate has not been set, otherwise exports the data to the delegate. + * + * @param data the data to buffer or export + */ + protected fun bufferOrDelegate(data: Collection): CompletableResultCode = + withDelegateOrNull { + if (it != null) { + exportToDelegate(it, data) + } else { + val amountToTake = bufferedSignals - buffer.size + buffer.addAll(data.take(amountToTake)) + CompletableResultCode.ofSuccess() + } + } + + /** + * Executes the given block with the delegate if it has been set, otherwise executes the block with a null delegate. + * + * @param block the block to execute + */ + protected fun withDelegateOrNull(block: (D?) -> R): R { + delegate?.let { return block(it) } + return synchronized(lock) { block(delegate) } + } + + open fun shutdown(): CompletableResultCode = bufferedShutDown() + + protected abstract fun exportToDelegate( + delegate: D, + data: Collection, + ): CompletableResultCode + + protected abstract fun shutdownDelegate(delegate: D): CompletableResultCode + + private fun flushToDelegate(delegate: D) { + exportToDelegate(delegate, buffer) + buffer.clear() + buffer.trimToSize() + } + + private fun bufferedShutDown(): CompletableResultCode { + isShutDown.set(true) + + return withDelegateOrNull { + if (it != null) { + shutdownDelegate(it) + } else { + CompletableResultCode.ofSuccess() + } + } + } +} diff --git a/core/src/test/java/io/opentelemetry/android/OpenTelemetryRumBuilderTest.java b/core/src/test/java/io/opentelemetry/android/OpenTelemetryRumBuilderTest.java index 67d425115..951e899b1 100644 --- a/core/src/test/java/io/opentelemetry/android/OpenTelemetryRumBuilderTest.java +++ b/core/src/test/java/io/opentelemetry/android/OpenTelemetryRumBuilderTest.java @@ -135,21 +135,26 @@ public void shouldBuildTracerProvider() { SimpleSpanProcessor.create(spanExporter))) .build(); - String sessionId = openTelemetryRum.getRumSessionId(); - openTelemetryRum - .getOpenTelemetry() - .getTracer("test") - .spanBuilder("test span") - .startSpan() - .end(); - - List spans = spanExporter.getFinishedSpanItems(); - assertThat(spans).hasSize(1); - assertThat(spans.get(0)) - .hasName("test span") - .hasResource(resource) - .hasAttributesSatisfyingExactly( - equalTo(SESSION_ID, sessionId), equalTo(SCREEN_NAME_KEY, "unknown")); + await().atMost(Duration.ofSeconds(30)) + .untilAsserted( + () -> { + String sessionId = openTelemetryRum.getRumSessionId(); + openTelemetryRum + .getOpenTelemetry() + .getTracer("test") + .spanBuilder("test span") + .startSpan() + .end(); + + List spans = spanExporter.getFinishedSpanItems(); + assertThat(spans).hasSize(1); + assertThat(spans.get(0)) + .hasName("test span") + .hasResource(resource) + .hasAttributesSatisfyingExactly( + equalTo(SESSION_ID, sessionId), + equalTo(SCREEN_NAME_KEY, "unknown")); + }); } @Test @@ -344,11 +349,17 @@ public void diskBufferingEnabled() { .setServiceManager(serviceManager) .build(); - assertThat(SignalFromDiskExporter.get()).isNotNull(); - verify(scheduleHandler).enable(); - verify(scheduleHandler, never()).disable(); - verify(initializationEvents).spanExporterInitialized(exporterCaptor.capture()); - assertThat(exporterCaptor.getValue()).isInstanceOf(SpanToDiskExporter.class); + await().atMost(Duration.ofSeconds(30)) + .untilAsserted( + () -> { + assertThat(SignalFromDiskExporter.get()).isNotNull(); + verify(scheduleHandler).enable(); + verify(scheduleHandler, never()).disable(); + verify(initializationEvents) + .spanExporterInitialized(exporterCaptor.capture()); + assertThat(exporterCaptor.getValue()) + .isInstanceOf(SpanToDiskExporter.class); + }); } @Test @@ -373,11 +384,17 @@ public void diskBufferingEnabled_when_exception_thrown() { .setExportScheduleHandler(scheduleHandler) .build(); - verify(initializationEvents).spanExporterInitialized(exporterCaptor.capture()); - verify(scheduleHandler, never()).enable(); - verify(scheduleHandler).disable(); - assertThat(exporterCaptor.getValue()).isNotInstanceOf(SpanToDiskExporter.class); - assertThat(SignalFromDiskExporter.get()).isNull(); + await().atMost(Duration.ofSeconds(30)) + .untilAsserted( + () -> { + verify(initializationEvents) + .spanExporterInitialized(exporterCaptor.capture()); + verify(scheduleHandler, never()).enable(); + verify(scheduleHandler).disable(); + assertThat(exporterCaptor.getValue()) + .isNotInstanceOf(SpanToDiskExporter.class); + assertThat(SignalFromDiskExporter.get()).isNull(); + }); } @Test @@ -404,11 +421,17 @@ public void diskBufferingDisabled() { .setExportScheduleHandler(scheduleHandler) .build(); - verify(initializationEvents).spanExporterInitialized(exporterCaptor.capture()); - verify(scheduleHandler, never()).enable(); - verify(scheduleHandler).disable(); - assertThat(exporterCaptor.getValue()).isNotInstanceOf(SpanToDiskExporter.class); - assertThat(SignalFromDiskExporter.get()).isNull(); + await().atMost(Duration.ofSeconds(30)) + .untilAsserted( + () -> { + verify(initializationEvents) + .spanExporterInitialized(exporterCaptor.capture()); + verify(scheduleHandler, never()).enable(); + verify(scheduleHandler).disable(); + assertThat(exporterCaptor.getValue()) + .isNotInstanceOf(SpanToDiskExporter.class); + assertThat(SignalFromDiskExporter.get()).isNull(); + }); } @Test diff --git a/core/src/test/java/io/opentelemetry/android/export/BufferDelegatingLogExporterTest.kt b/core/src/test/java/io/opentelemetry/android/export/BufferDelegatingLogExporterTest.kt new file mode 100644 index 000000000..44017314e --- /dev/null +++ b/core/src/test/java/io/opentelemetry/android/export/BufferDelegatingLogExporterTest.kt @@ -0,0 +1,92 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.android.export + +import io.mockk.mockk +import io.mockk.spyk +import io.mockk.verify +import io.opentelemetry.sdk.logs.data.LogRecordData +import io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat +import io.opentelemetry.sdk.testing.exporter.InMemoryLogRecordExporter +import org.junit.Test + +class BufferDelegatingLogExporterTest { + @Test + fun `test setDelegate`() { + val inMemoryBufferDelegatingLogExporter = BufferDelegatingLogExporter() + val logRecordExporter = InMemoryLogRecordExporter.create() + + val logRecordData = mockk() + inMemoryBufferDelegatingLogExporter.export(listOf(logRecordData)) + inMemoryBufferDelegatingLogExporter.setDelegate(logRecordExporter) + + assertThat(logRecordExporter.finishedLogRecordItems) + .containsExactly(logRecordData) + } + + @Test + fun `test buffer limit handling`() { + val inMemoryBufferDelegatingLogExporter = BufferDelegatingLogExporter(10) + val logRecordExporter = InMemoryLogRecordExporter.create() + + repeat(11) { + val logRecordData = mockk() + inMemoryBufferDelegatingLogExporter.export(listOf(logRecordData)) + } + + inMemoryBufferDelegatingLogExporter.setDelegate(logRecordExporter) + + assertThat(logRecordExporter.finishedLogRecordItems) + .hasSize(10) + } + + @Test + fun `test flush with delegate`() { + val inMemoryBufferDelegatingLogExporter = BufferDelegatingLogExporter() + val delegate = spyk() + + val logRecordData = mockk() + inMemoryBufferDelegatingLogExporter.export(listOf(logRecordData)) + + inMemoryBufferDelegatingLogExporter.setDelegate(delegate) + + inMemoryBufferDelegatingLogExporter.flush() + + verify { delegate.flush() } + } + + @Test + fun `test export with delegate`() { + val inMemoryBufferDelegatingLogExporter = BufferDelegatingLogExporter() + val delegate = spyk() + + val logRecordData = mockk() + inMemoryBufferDelegatingLogExporter.export(listOf(logRecordData)) + + verify(exactly = 0) { delegate.export(any()) } + + inMemoryBufferDelegatingLogExporter.setDelegate(delegate) + + verify(exactly = 1) { delegate.export(any()) } + + val logRecordData2 = mockk() + inMemoryBufferDelegatingLogExporter.export(listOf(logRecordData2)) + + verify(exactly = 2) { delegate.export(any()) } + } + + @Test + fun `test shutdown with delegate`() { + val inMemoryBufferDelegatingLogExporter = BufferDelegatingLogExporter() + val delegate = spyk() + + inMemoryBufferDelegatingLogExporter.setDelegate(delegate) + + inMemoryBufferDelegatingLogExporter.shutdown() + + verify { delegate.shutdown() } + } +} diff --git a/core/src/test/java/io/opentelemetry/android/export/BufferDelegatingSpanExporterTest.kt b/core/src/test/java/io/opentelemetry/android/export/BufferDelegatingSpanExporterTest.kt new file mode 100644 index 000000000..d6f0b5460 --- /dev/null +++ b/core/src/test/java/io/opentelemetry/android/export/BufferDelegatingSpanExporterTest.kt @@ -0,0 +1,118 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.android.export + +import io.mockk.mockk +import io.mockk.spyk +import io.mockk.verify +import io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat +import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter +import io.opentelemetry.sdk.trace.data.SpanData +import org.junit.Test + +class BufferDelegatingSpanExporterTest { + @Test + fun `test setDelegate`() { + val bufferDelegatingSpanExporter = BufferDelegatingSpanExporter() + val spanExporter = InMemorySpanExporter.create() + + val spanData = mockk() + bufferDelegatingSpanExporter.export(listOf(spanData)) + bufferDelegatingSpanExporter.setDelegate(spanExporter) + + assertThat(spanExporter.finishedSpanItems) + .containsExactly(spanData) + } + + @Test + fun `test buffer limit handling`() { + val bufferDelegatingSpanExporter = BufferDelegatingSpanExporter(10) + val spanExporter = InMemorySpanExporter.create() + + repeat(11) { + val spanData = mockk() + bufferDelegatingSpanExporter.export(listOf(spanData)) + } + + bufferDelegatingSpanExporter.setDelegate(spanExporter) + + assertThat(spanExporter.finishedSpanItems) + .hasSize(10) + } + + @Test + fun `test flush with delegate`() { + val bufferDelegatingSpanExporter = BufferDelegatingSpanExporter() + val delegate = spyk() + + val spanData = mockk() + bufferDelegatingSpanExporter.export(listOf(spanData)) + + bufferDelegatingSpanExporter.setDelegate(delegate) + + verify(exactly = 0) { delegate.flush() } + + bufferDelegatingSpanExporter.flush() + + verify { delegate.flush() } + } + + @Test + fun `test export with delegate`() { + val bufferDelegatingSpanExporter = BufferDelegatingSpanExporter() + val delegate = spyk() + + val spanData = mockk() + bufferDelegatingSpanExporter.export(listOf(spanData)) + + verify(exactly = 0) { delegate.export(any()) } + + bufferDelegatingSpanExporter.setDelegate(delegate) + + verify(exactly = 1) { delegate.export(any()) } + + val spanData2 = mockk() + bufferDelegatingSpanExporter.export(listOf(spanData2)) + + verify(exactly = 2) { delegate.export(any()) } + } + + @Test + fun `test shutdown with delegate`() { + val bufferDelegatingSpanExporter = BufferDelegatingSpanExporter() + val delegate = spyk() + + bufferDelegatingSpanExporter.setDelegate(delegate) + + bufferDelegatingSpanExporter.shutdown() + + verify { delegate.shutdown() } + } + + @Test + fun `test flush without delegate`() { + val bufferDelegatingSpanExporter = BufferDelegatingSpanExporter() + + val spanData = mockk() + bufferDelegatingSpanExporter.export(listOf(spanData)) + + val flushResult = bufferDelegatingSpanExporter.flush() + + assertThat(flushResult.isSuccess).isTrue() + } + + @Test + fun `test shutdown without delegate`() { + val bufferDelegatingSpanExporter = BufferDelegatingSpanExporter() + + val spanData = mockk() + bufferDelegatingSpanExporter.export(listOf(spanData)) + + val shutdownResult = bufferDelegatingSpanExporter.shutdown() + + assertThat(shutdownResult.isSuccess).isTrue() + } +}