diff --git a/core/src/main/java/io/opentelemetry/android/OpenTelemetryRumBuilder.java b/core/src/main/java/io/opentelemetry/android/OpenTelemetryRumBuilder.java index a01e497ec..276b7ad2a 100644 --- a/core/src/main/java/io/opentelemetry/android/OpenTelemetryRumBuilder.java +++ b/core/src/main/java/io/opentelemetry/android/OpenTelemetryRumBuilder.java @@ -10,6 +10,7 @@ import android.app.Application; import android.util.Log; import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; import io.opentelemetry.android.common.RumConstants; import io.opentelemetry.android.config.OtelRumConfig; import io.opentelemetry.android.features.diskbuffering.DiskBufferingConfiguration; @@ -31,6 +32,7 @@ import io.opentelemetry.android.internal.services.ServiceManagerImpl; import io.opentelemetry.android.internal.services.periodicwork.PeriodicWorkService; import io.opentelemetry.android.session.SessionManager; +import io.opentelemetry.android.session.SessionManagerImpl; import io.opentelemetry.android.session.SessionProvider; import io.opentelemetry.api.baggage.propagation.W3CBaggagePropagator; import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator; @@ -95,6 +97,7 @@ public final class OpenTelemetryRumBuilder { private Resource resource; @Nullable private ServiceManager serviceManager; + @Nullable private SessionManager sessionManager; @Nullable private ExportScheduleHandler exportScheduleHandler; private static TextMapPropagator buildDefaultPropagator() { @@ -306,8 +309,10 @@ public OpenTelemetryRum build() { } initializationEvents.spanExporterInitialized(spanExporter); - SessionManager sessionManager = - SessionManager.create(timeoutHandler, config.getSessionTimeout().toNanos()); + if (sessionManager == null) { + sessionManager = + SessionManagerImpl.create(timeoutHandler, config.getSessionTimeout().toNanos()); + } OpenTelemetrySdk sdk = OpenTelemetrySdk.builder() @@ -348,6 +353,12 @@ public OpenTelemetryRumBuilder setServiceManager(ServiceManager serviceManager) return this; } + @VisibleForTesting + OpenTelemetryRumBuilder setSessionManager(SessionManager sessionManager) { + this.sessionManager = sessionManager; + return this; + } + /** * Sets a scheduler that will take care of periodically read data stored in disk and export it. * If not specified, the default schedule exporter will be used. diff --git a/core/src/main/java/io/opentelemetry/android/SdkPreconfiguredRumBuilder.kt b/core/src/main/java/io/opentelemetry/android/SdkPreconfiguredRumBuilder.kt index 78891dfc0..b3f6b1b57 100644 --- a/core/src/main/java/io/opentelemetry/android/SdkPreconfiguredRumBuilder.kt +++ b/core/src/main/java/io/opentelemetry/android/SdkPreconfiguredRumBuilder.kt @@ -11,8 +11,8 @@ import io.opentelemetry.android.instrumentation.AndroidInstrumentationLoader import io.opentelemetry.android.instrumentation.InstallationContext import io.opentelemetry.android.internal.services.ServiceManager import io.opentelemetry.android.session.SessionManager +import io.opentelemetry.android.session.SessionManagerImpl import io.opentelemetry.sdk.OpenTelemetrySdk -import io.opentelemetry.sdk.logs.internal.SdkEventLoggerProvider class SdkPreconfiguredRumBuilder @JvmOverloads @@ -20,7 +20,7 @@ class SdkPreconfiguredRumBuilder private val application: Application, private val sdk: OpenTelemetrySdk, private val timeoutHandler: SessionIdTimeoutHandler = SessionIdTimeoutHandler(), - private val sessionManager: SessionManager = SessionManager(timeoutHandler = timeoutHandler), + private val sessionManager: SessionManager = SessionManagerImpl(timeoutHandler = timeoutHandler), private val discoverInstrumentations: Boolean, private val serviceManager: ServiceManager, ) { @@ -52,22 +52,23 @@ class SdkPreconfiguredRumBuilder // might turn off/on additional telemetry depending on whether the app is active or not appLifecycleService.registerListener(timeoutHandler) - val eventLogger = - SdkEventLoggerProvider.create(sdk.logsBridge) - .get(OpenTelemetryRum::class.java.simpleName) - - sessionManager.addObserver(SessionIdEventSender(eventLogger)) - // After addObserver(), we call getSessionId() to trigger a session.start event - sessionManager.getSessionId() - val openTelemetryRum = OpenTelemetryRumImpl(sdk, sessionManager) // Install instrumentations - val ctx = InstallationContext(application, openTelemetryRum.openTelemetry, serviceManager) + val ctx = + InstallationContext( + application = application, + openTelemetry = openTelemetryRum.openTelemetry, + sessionManager = sessionManager, + serviceManager = serviceManager, + ) for (instrumentation in getInstrumentations()) { instrumentation.install(ctx) } + // After installing all instrumentations, we call getSessionId() to trigger the session start + sessionManager.getSessionId() + return openTelemetryRum } diff --git a/core/src/main/java/io/opentelemetry/android/instrumentation/InstallationContext.kt b/core/src/main/java/io/opentelemetry/android/instrumentation/InstallationContext.kt index 826a24a42..3bd7093aa 100644 --- a/core/src/main/java/io/opentelemetry/android/instrumentation/InstallationContext.kt +++ b/core/src/main/java/io/opentelemetry/android/instrumentation/InstallationContext.kt @@ -7,10 +7,12 @@ package io.opentelemetry.android.instrumentation import android.app.Application import io.opentelemetry.android.internal.services.ServiceManager +import io.opentelemetry.android.session.SessionManager import io.opentelemetry.api.OpenTelemetry data class InstallationContext( val application: Application, val openTelemetry: OpenTelemetry, + val sessionManager: SessionManager, val serviceManager: ServiceManager, ) diff --git a/core/src/main/java/io/opentelemetry/android/session/SessionManager.kt b/core/src/main/java/io/opentelemetry/android/session/SessionManager.kt index 6f61ae5e5..c23c3c07b 100644 --- a/core/src/main/java/io/opentelemetry/android/session/SessionManager.kt +++ b/core/src/main/java/io/opentelemetry/android/session/SessionManager.kt @@ -5,76 +5,9 @@ package io.opentelemetry.android.session -import io.opentelemetry.android.SessionIdTimeoutHandler -import io.opentelemetry.sdk.common.Clock -import java.util.Collections.synchronizedList -import java.util.concurrent.TimeUnit - -internal class SessionManager( - private val clock: Clock = Clock.getDefault(), - private val sessionStorage: SessionStorage = SessionStorage.InMemory(), - private val timeoutHandler: SessionIdTimeoutHandler, - private val idGenerator: SessionIdGenerator = SessionIdGenerator.DEFAULT, - private val sessionLifetimeNanos: Long = TimeUnit.HOURS.toNanos(4), -) : SessionProvider, SessionPublisher { - // TODO: Make thread safe / wrap with AtomicReference? - private var session: Session = Session.NONE - private val observers = synchronizedList(ArrayList()) - - init { - sessionStorage.save(session) - } - - override fun addObserver(observer: SessionObserver) { - observers.add(observer) - } - - override fun getSessionId(): String { - // value will never be null - var newSession = session - - if (sessionHasExpired() || timeoutHandler.hasTimedOut()) { - val newId = idGenerator.generateSessionId() - - // TODO FIXME: This is not threadsafe -- if two threads call getSessionId() - // at the same time while timed out, two new sessions are created - // Could require SessionStorage impls to be atomic/threadsafe or - // do the locking in this class? - - newSession = Session.DefaultSession(newId, clock.now()) - sessionStorage.save(newSession) - } - - timeoutHandler.bump() - - // observers need to be called after bumping the timer because it may - // create a new span - if (newSession != session) { - val oldSession = session - session = newSession - observers.forEach { - it.onSessionEnded(oldSession) - it.onSessionStarted(session, oldSession) - } - } - return session.getId() - } - - private fun sessionHasExpired(): Boolean { - val elapsedTime = clock.now() - session.getStartTimestamp() - return elapsedTime >= sessionLifetimeNanos - } - - companion object { - @JvmStatic - fun create( - timeoutHandler: SessionIdTimeoutHandler, - sessionLifetimeNanos: Long, - ): SessionManager { - return SessionManager( - timeoutHandler = timeoutHandler, - sessionLifetimeNanos = sessionLifetimeNanos, - ) - } - } -} +/** + * The SessionManager is a public-facing tag interface that brings together + * the SessionProvider and SessionPublisher interfaces under a common + * name. + */ +interface SessionManager : SessionProvider, SessionPublisher diff --git a/core/src/main/java/io/opentelemetry/android/session/SessionManagerImpl.kt b/core/src/main/java/io/opentelemetry/android/session/SessionManagerImpl.kt new file mode 100644 index 000000000..d223b9a60 --- /dev/null +++ b/core/src/main/java/io/opentelemetry/android/session/SessionManagerImpl.kt @@ -0,0 +1,80 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.android.session + +import io.opentelemetry.android.SessionIdTimeoutHandler +import io.opentelemetry.sdk.common.Clock +import java.util.Collections.synchronizedList +import java.util.concurrent.TimeUnit + +internal class SessionManagerImpl( + private val clock: Clock = Clock.getDefault(), + private val sessionStorage: SessionStorage = SessionStorage.InMemory(), + private val timeoutHandler: SessionIdTimeoutHandler, + private val idGenerator: SessionIdGenerator = SessionIdGenerator.DEFAULT, + private val sessionLifetimeNanos: Long = TimeUnit.HOURS.toNanos(4), +) : SessionManager { + // TODO: Make thread safe / wrap with AtomicReference? + private var session: Session = Session.NONE + private val observers = synchronizedList(ArrayList()) + + init { + sessionStorage.save(session) + } + + override fun addObserver(observer: SessionObserver) { + observers.add(observer) + } + + override fun getSessionId(): String { + // value will never be null + var newSession = session + + if (sessionHasExpired() || timeoutHandler.hasTimedOut()) { + val newId = idGenerator.generateSessionId() + + // TODO FIXME: This is not threadsafe -- if two threads call getSessionId() + // at the same time while timed out, two new sessions are created + // Could require SessionStorage impls to be atomic/threadsafe or + // do the locking in this class? + + newSession = Session.DefaultSession(newId, clock.now()) + sessionStorage.save(newSession) + } + + timeoutHandler.bump() + + // observers need to be called after bumping the timer because it may + // create a new span + if (newSession != session) { + val oldSession = session + session = newSession + observers.forEach { + it.onSessionEnded(oldSession) + it.onSessionStarted(session, oldSession) + } + } + return session.getId() + } + + private fun sessionHasExpired(): Boolean { + val elapsedTime = clock.now() - session.getStartTimestamp() + return elapsedTime >= sessionLifetimeNanos + } + + companion object { + @JvmStatic + fun create( + timeoutHandler: SessionIdTimeoutHandler, + sessionLifetimeNanos: Long, + ): SessionManagerImpl { + return SessionManagerImpl( + timeoutHandler = timeoutHandler, + sessionLifetimeNanos = sessionLifetimeNanos, + ) + } + } +} diff --git a/core/src/test/java/io/opentelemetry/android/OpenTelemetryRumBuilderTest.java b/core/src/test/java/io/opentelemetry/android/OpenTelemetryRumBuilderTest.java index 7392d360a..66fbb75ba 100644 --- a/core/src/test/java/io/opentelemetry/android/OpenTelemetryRumBuilderTest.java +++ b/core/src/test/java/io/opentelemetry/android/OpenTelemetryRumBuilderTest.java @@ -9,7 +9,6 @@ import static io.opentelemetry.api.common.AttributeKey.stringKey; import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.equalTo; -import static io.opentelemetry.semconv.incubating.EventIncubatingAttributes.EVENT_NAME; import static io.opentelemetry.semconv.incubating.SessionIncubatingAttributes.SESSION_ID; import static org.awaitility.Awaitility.await; import static org.mockito.ArgumentMatchers.anyCollection; @@ -43,6 +42,7 @@ import io.opentelemetry.android.internal.services.applifecycle.AppLifecycleService; import io.opentelemetry.android.internal.services.applifecycle.ApplicationStateListener; import io.opentelemetry.android.internal.services.visiblescreen.VisibleScreenService; +import io.opentelemetry.android.session.SessionManager; import io.opentelemetry.api.common.AttributeKey; import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.common.KeyValue; @@ -175,13 +175,8 @@ public void shouldBuildLogRecordProvider() { eventLogger.builder("test.event").put("body.field", "foo").setAttributes(attrs).emit(); List logs = logsExporter.getFinishedLogRecordItems(); - assertThat(logs).hasSize(2); + assertThat(logs).hasSize(1); assertThat(logs.get(0)) - .hasAttributesSatisfyingExactly( - equalTo(SESSION_ID, openTelemetryRum.getRumSessionId()), - equalTo(SCREEN_NAME_KEY, CUR_SCREEN_NAME), - equalTo(stringKey("event.name"), "session.start")); - assertThat(logs.get(1)) .hasAttributesSatisfyingExactly( equalTo(SESSION_ID, openTelemetryRum.getRumSessionId()), equalTo(stringKey("event.name"), "test.event"), @@ -189,7 +184,7 @@ public void shouldBuildLogRecordProvider() { equalTo(stringKey("mega"), "hit")) .hasResource(resource); - Value bodyValue = logs.get(1).getBodyValue(); + Value bodyValue = logs.get(0).getBodyValue(); List payload = (List) bodyValue.getValue(); assertThat(payload).hasSize(1); KeyValue expected = KeyValue.of("body.field", Value.of("foo")); @@ -199,6 +194,7 @@ public void shouldBuildLogRecordProvider() { @Test public void shouldInstallInstrumentation() { ServiceManager serviceManager = createServiceManager(); + SessionManager sessionManager = mock(SessionManager.class); SessionIdTimeoutHandler timeoutHandler = mock(); AndroidInstrumentation localInstrumentation = mock(); AndroidInstrumentation classpathInstrumentation = mock(); @@ -210,12 +206,15 @@ public void shouldInstallInstrumentation() { new OpenTelemetryRumBuilder(application, buildConfig(), timeoutHandler) .addInstrumentation(localInstrumentation) .setServiceManager(serviceManager) + .setSessionManager(sessionManager) .build(); verify(serviceManager.getAppLifecycleService()).registerListener(timeoutHandler); InstallationContext expectedCtx = - new InstallationContext(application, rum.getOpenTelemetry(), serviceManager); + new InstallationContext( + application, rum.getOpenTelemetry(), sessionManager, serviceManager); + verify(localInstrumentation).install(eq(expectedCtx)); verify(classpathInstrumentation).install(eq(expectedCtx)); } @@ -223,6 +222,7 @@ public void shouldInstallInstrumentation() { @Test public void shouldInstallInstrumentation_excludingClasspathImplsWhenRequestedInConfig() { ServiceManager serviceManager = createServiceManager(); + SessionManager sessionManager = mock(SessionManager.class); SessionIdTimeoutHandler timeoutHandler = mock(); AndroidInstrumentation localInstrumentation = mock(); AndroidInstrumentation classpathInstrumentation = mock(); @@ -237,12 +237,14 @@ public void shouldInstallInstrumentation_excludingClasspathImplsWhenRequestedInC timeoutHandler) .addInstrumentation(localInstrumentation) .setServiceManager(serviceManager) + .setSessionManager(sessionManager) .build(); verify(serviceManager.getAppLifecycleService()).registerListener(timeoutHandler); InstallationContext expectedCtx = - new InstallationContext(application, rum.getOpenTelemetry(), serviceManager); + new InstallationContext( + application, rum.getOpenTelemetry(), sessionManager, serviceManager); verify(localInstrumentation).install(eq(expectedCtx)); verifyNoInteractions(classpathInstrumentation); } @@ -325,13 +327,8 @@ public void setLogRecordExporterCustomizer() { () -> assertThat(logsExporter.getFinishedLogRecordItems()).isNotEmpty()); assertThat(wasCalled.get()).isTrue(); Collection logs = logsExporter.getFinishedLogRecordItems(); - assertThat(logs).hasSize(2); + assertThat(logs).hasSize(1); Iterator iter = logs.iterator(); - assertThat(iter.next()) - .hasAttributesSatisfyingExactly( - equalTo(SESSION_ID, rum.getRumSessionId()), - equalTo(SCREEN_NAME_KEY, CUR_SCREEN_NAME), - equalTo(stringKey("event.name"), "session.start")); assertThat(iter.next()) .hasBody("foo") .hasAttributesSatisfyingExactly( @@ -444,14 +441,8 @@ public void verifyGlobalAttrsForLogs() { logger.logRecordBuilder().setAttribute(stringKey("localAttrKey"), "localAttrValue").emit(); List recordedLogs = logRecordExporter.getFinishedLogRecordItems(); - assertThat(recordedLogs).hasSize(2); // session start, the the above log + assertThat(recordedLogs).hasSize(1); // session start, the the above log assertThat(recordedLogs.get(0)) - .hasAttributesSatisfyingExactly( - equalTo(EVENT_NAME, "session.start"), - equalTo(globalKey, "someGlobalValue"), - equalTo(SESSION_ID, rum.getRumSessionId()), - equalTo(SCREEN_NAME_KEY, CUR_SCREEN_NAME)); - assertThat(recordedLogs.get(1)) .hasAttributesSatisfyingExactly( equalTo(SESSION_ID, rum.getRumSessionId()), equalTo(globalKey, "someGlobalValue"), diff --git a/core/src/test/java/io/opentelemetry/android/SessionIdEventSenderTest.java b/core/src/test/java/io/opentelemetry/android/SessionIdEventSenderTest.java deleted file mode 100644 index e58e0e262..000000000 --- a/core/src/test/java/io/opentelemetry/android/SessionIdEventSenderTest.java +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright The OpenTelemetry Authors - * SPDX-License-Identifier: Apache-2.0 - */ - -package io.opentelemetry.android; - -import static io.opentelemetry.api.common.AttributeKey.stringKey; -import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; -import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.equalTo; -import static org.junit.jupiter.api.Assertions.assertEquals; - -import io.opentelemetry.android.session.Session; -import io.opentelemetry.api.common.KeyValue; -import io.opentelemetry.api.common.Value; -import io.opentelemetry.api.incubator.events.EventLogger; -import io.opentelemetry.api.logs.LoggerProvider; -import io.opentelemetry.sdk.logs.data.LogRecordData; -import io.opentelemetry.sdk.logs.internal.SdkEventLoggerProvider; -import io.opentelemetry.sdk.testing.junit5.OpenTelemetryExtension; -import java.util.List; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; - -class SessionIdEventSenderTest { - @RegisterExtension - static final OpenTelemetryExtension otelTesting = OpenTelemetryExtension.create(); - - private SessionIdEventSender underTest; - - @BeforeEach - void setup() { - LoggerProvider loggerProvider = otelTesting.getOpenTelemetry().getLogsBridge(); - EventLogger eventLogger = SdkEventLoggerProvider.create(loggerProvider).get("testLogging"); - underTest = new SessionIdEventSender(eventLogger); - } - - @Test - void shouldEmitSessionStartEvent() { - Session newSession = new Session.DefaultSession("123", 0); - Session oldSession = new Session.DefaultSession("666", 0); - underTest.onSessionStarted(newSession, oldSession); - - List logs = otelTesting.getLogRecords(); - assertEquals(1, logs.size()); - LogRecordData log = logs.get(0); - // TODO: Use new event body assertions when available. - assertThat(log) - .hasAttributesSatisfyingExactly(equalTo(stringKey("event.name"), "session.start")); - - Value> body = (Value>) log.getBodyValue(); - List kvBody = body.getValue(); - assertThat(kvBody.get(0).getKey()).isEqualTo("session.previous_id"); - assertThat(kvBody.get(0).getValue().asString()).isEqualTo("666"); - assertThat(kvBody.get(1).getKey()).isEqualTo("session.id"); - assertThat(kvBody.get(1).getValue().asString()).isEqualTo("123"); - } - - @Test - void shouldEmitSessionEndEvent() { - Session session = new Session.DefaultSession("123", 0); - underTest.onSessionEnded(session); - - List logs = otelTesting.getLogRecords(); - assertEquals(1, logs.size()); - LogRecordData log = logs.get(0); - // TODO: Use new event body assertions when available. - assertThat(log) - .hasAttributesSatisfyingExactly(equalTo(stringKey("event.name"), "session.end")); - - Value> body = (Value>) log.getBodyValue(); - List kvBody = body.getValue(); - assertThat(kvBody.get(0).getKey()).isEqualTo("session.id"); - assertThat(kvBody.get(0).getValue().asString()).isEqualTo("123"); - } -} diff --git a/core/src/test/java/io/opentelemetry/android/session/SessionManagerTest.kt b/core/src/test/java/io/opentelemetry/android/session/SessionManagerImplTest.kt similarity index 91% rename from core/src/test/java/io/opentelemetry/android/session/SessionManagerTest.kt rename to core/src/test/java/io/opentelemetry/android/session/SessionManagerImplTest.kt index fa28c9c06..8dd860104 100644 --- a/core/src/test/java/io/opentelemetry/android/session/SessionManagerTest.kt +++ b/core/src/test/java/io/opentelemetry/android/session/SessionManagerImplTest.kt @@ -22,7 +22,7 @@ import org.junit.jupiter.api.Test import java.util.concurrent.TimeUnit import java.util.regex.Pattern -internal class SessionManagerTest { +internal class SessionManagerImplTest { @MockK lateinit var timeoutHandler: SessionIdTimeoutHandler @@ -35,7 +35,7 @@ internal class SessionManagerTest { @Test fun valueValid() { - val sessionManager = SessionManager(TestClock.create(), timeoutHandler = timeoutHandler) + val sessionManager = SessionManagerImpl(TestClock.create(), timeoutHandler = timeoutHandler) val sessionId = sessionManager.getSessionId() assertThat(sessionId).isNotNull() assertThat(sessionId).hasSize(32) @@ -45,7 +45,7 @@ internal class SessionManagerTest { @Test fun valueSameUntil4Hours() { val clock = TestClock.create() - val sessionManager = SessionManager(clock, timeoutHandler = timeoutHandler) + val sessionManager = SessionManagerImpl(clock, timeoutHandler = timeoutHandler) val value = sessionManager.getSessionId() assertThat(value).isEqualTo(sessionManager.getSessionId()) clock.advance(3, TimeUnit.HOURS) @@ -69,7 +69,7 @@ internal class SessionManagerTest { every { observer.onSessionStarted(any(), any()) } just Runs every { observer.onSessionEnded(any()) } just Runs - val sessionManager = SessionManager(clock, timeoutHandler = timeoutHandler) + val sessionManager = SessionManagerImpl(clock, timeoutHandler = timeoutHandler) sessionManager.addObserver(observer) // The first call expires the Session.NONE initial session and notifies @@ -108,7 +108,7 @@ internal class SessionManagerTest { @Test fun shouldCreateNewSessionIdAfterTimeout() { - val sessionId = SessionManager(timeoutHandler = timeoutHandler) + val sessionId = SessionManagerImpl(timeoutHandler = timeoutHandler) val value = sessionId.getSessionId() verify { timeoutHandler.bump() } diff --git a/instrumentation/activity/src/test/java/io/opentelemetry/android/instrumentation/activity/ActivityLifecycleInstrumentationTest.kt b/instrumentation/activity/src/test/java/io/opentelemetry/android/instrumentation/activity/ActivityLifecycleInstrumentationTest.kt index 20e7c195f..f47fd1e94 100644 --- a/instrumentation/activity/src/test/java/io/opentelemetry/android/instrumentation/activity/ActivityLifecycleInstrumentationTest.kt +++ b/instrumentation/activity/src/test/java/io/opentelemetry/android/instrumentation/activity/ActivityLifecycleInstrumentationTest.kt @@ -14,6 +14,7 @@ import io.opentelemetry.android.common.RumConstants import io.opentelemetry.android.instrumentation.InstallationContext import io.opentelemetry.android.internal.services.ServiceManager import io.opentelemetry.android.internal.services.visiblescreen.VisibleScreenService +import io.opentelemetry.android.session.SessionManager import io.opentelemetry.api.trace.Span import io.opentelemetry.api.trace.SpanBuilder import io.opentelemetry.api.trace.Tracer @@ -29,6 +30,7 @@ class ActivityLifecycleInstrumentationTest { private lateinit var application: Application private lateinit var openTelemetry: OpenTelemetrySdk private lateinit var serviceManager: ServiceManager + private lateinit var sessionManager: SessionManager @Before fun setUp() { @@ -36,6 +38,7 @@ class ActivityLifecycleInstrumentationTest { openTelemetry = mockk() activityLifecycleInstrumentation = ActivityLifecycleInstrumentation() serviceManager = mockk() + sessionManager = mockk() every { serviceManager.getVisibleScreenService() }.returns(mockk()) } @@ -53,7 +56,7 @@ class ActivityLifecycleInstrumentationTest { ) every { startupSpanBuilder.startSpan() }.returns(startupSpan) - val ctx = InstallationContext(application, openTelemetry, serviceManager) + val ctx = InstallationContext(application, openTelemetry, sessionManager, serviceManager) activityLifecycleInstrumentation.install(ctx) verify { diff --git a/instrumentation/crash/src/test/java/io/opentelemetry/android/instrumentation/crash/CrashReporterTest.java b/instrumentation/crash/src/test/java/io/opentelemetry/android/instrumentation/crash/CrashReporterTest.java index da6de01d3..21ff4604b 100644 --- a/instrumentation/crash/src/test/java/io/opentelemetry/android/instrumentation/crash/CrashReporterTest.java +++ b/instrumentation/crash/src/test/java/io/opentelemetry/android/instrumentation/crash/CrashReporterTest.java @@ -13,6 +13,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import io.opentelemetry.android.instrumentation.InstallationContext; import io.opentelemetry.android.internal.services.ServiceManager; +import io.opentelemetry.android.session.SessionManager; import io.opentelemetry.api.common.Attributes; import io.opentelemetry.sdk.OpenTelemetrySdk; import io.opentelemetry.sdk.logs.SdkLoggerProvider; @@ -64,6 +65,7 @@ public void integrationTest() throws InterruptedException { new InstallationContext( RuntimeEnvironment.getApplication(), openTelemetrySdk, + mock(SessionManager.class), mock(ServiceManager.class)); instrumentation.install(ctx); diff --git a/instrumentation/sessions/README.md b/instrumentation/sessions/README.md new file mode 100644 index 000000000..ee4993b39 --- /dev/null +++ b/instrumentation/sessions/README.md @@ -0,0 +1,13 @@ + +# Session Instrumentation + +Out of the box, the `opentelemetry-android` agent will create and manage sessions. Sessions are associated +with a unique identifier and will expire after a duration. This instrumentation is responsible +for adding a `SessionObserver` instance that will generate OpenTelemetry events for +`session.start` and `session.end` when interesting things happen to the session. + +See [session.md in the semantic conventions](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/general/session.md) +for additional details about sessions. + +See ["Session Events"](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/general/session.md#session-events) +for more information about the specific events generated by this instrumentation. diff --git a/instrumentation/sessions/build.gradle.kts b/instrumentation/sessions/build.gradle.kts new file mode 100644 index 000000000..96df01c10 --- /dev/null +++ b/instrumentation/sessions/build.gradle.kts @@ -0,0 +1,37 @@ +plugins { + id("otel.android-library-conventions") + id("otel.publish-conventions") +} + +description = "OpenTelemetry Android session instrumentation" + +android { + namespace = "io.opentelemetry.android.instrumentation.sessions" + + defaultConfig { + consumerProguardFiles("consumer-rules.pro") + } + + testOptions { + unitTests.isReturnDefaultValues = true + unitTests.isIncludeAndroidResources = true + } +} + +dependencies { + api(platform(libs.opentelemetry.platform.alpha)) + implementation(libs.opentelemetry.api.incubator) + implementation(libs.opentelemetry.sdk) + implementation(libs.opentelemetry.semconv.incubating) + api(project(":instrumentation:common-api")) + api(project(":core")) + +// api(libs.opentelemetry.api) +// api(project(":core")) +// implementation(libs.androidx.core) +// implementation(libs.opentelemetry.semconv.incubating) +// implementation(libs.opentelemetry.sdk) +// implementation(libs.opentelemetry.instrumentation.api) +// testImplementation(libs.robolectric) +// testImplementation(libs.androidx.test.core) +} diff --git a/instrumentation/sessions/consumer-rules.pro b/instrumentation/sessions/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/core/src/main/java/io/opentelemetry/android/SessionIdEventSender.kt b/instrumentation/sessions/src/main/kotlin/io/opentelemetry/android/instrumentation/sessions/SessionIdEventSender.kt similarity index 87% rename from core/src/main/java/io/opentelemetry/android/SessionIdEventSender.kt rename to instrumentation/sessions/src/main/kotlin/io/opentelemetry/android/instrumentation/sessions/SessionIdEventSender.kt index 45a67cad6..30190791d 100644 --- a/core/src/main/java/io/opentelemetry/android/SessionIdEventSender.kt +++ b/instrumentation/sessions/src/main/kotlin/io/opentelemetry/android/instrumentation/sessions/SessionIdEventSender.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -package io.opentelemetry.android +package io.opentelemetry.android.instrumentation.sessions import io.opentelemetry.android.common.RumConstants.Events.EVENT_SESSION_END import io.opentelemetry.android.common.RumConstants.Events.EVENT_SESSION_START @@ -13,6 +13,10 @@ import io.opentelemetry.api.incubator.events.EventLogger import io.opentelemetry.semconv.incubating.SessionIncubatingAttributes.SESSION_ID import io.opentelemetry.semconv.incubating.SessionIncubatingAttributes.SESSION_PREVIOUS_ID +/** + * This class is responsible for generating the session related events as + * specified in the OpenTelemetry semantic conventions. + */ internal class SessionIdEventSender(private val eventLogger: EventLogger) : SessionObserver { override fun onSessionStarted( newSession: Session, diff --git a/instrumentation/sessions/src/main/kotlin/io/opentelemetry/android/instrumentation/sessions/SessionInstrumentation.kt b/instrumentation/sessions/src/main/kotlin/io/opentelemetry/android/instrumentation/sessions/SessionInstrumentation.kt new file mode 100644 index 000000000..e687d5e59 --- /dev/null +++ b/instrumentation/sessions/src/main/kotlin/io/opentelemetry/android/instrumentation/sessions/SessionInstrumentation.kt @@ -0,0 +1,25 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.android.instrumentation.sessions + +import io.opentelemetry.android.OpenTelemetryRum +import io.opentelemetry.android.instrumentation.AndroidInstrumentation +import io.opentelemetry.android.instrumentation.InstallationContext +import io.opentelemetry.sdk.logs.internal.SdkEventLoggerProvider + +/** + * This instrumentation will register an observer with the SessionManager, + * and this observer will send start/end events when the session id changes. + */ +class SessionInstrumentation : AndroidInstrumentation { + override fun install(ctx: InstallationContext) { + val eventLogger = + SdkEventLoggerProvider.create(ctx.openTelemetry.logsBridge) + .get(OpenTelemetryRum::class.java.simpleName) + + ctx.sessionManager.addObserver(SessionIdEventSender(eventLogger)) + } +} diff --git a/instrumentation/sessions/src/test/kotlin/io/opentelemetry/android/instrumentation/sessions/SessionIdEventSenderTest.kt b/instrumentation/sessions/src/test/kotlin/io/opentelemetry/android/instrumentation/sessions/SessionIdEventSenderTest.kt new file mode 100644 index 000000000..31b053ab3 --- /dev/null +++ b/instrumentation/sessions/src/test/kotlin/io/opentelemetry/android/instrumentation/sessions/SessionIdEventSenderTest.kt @@ -0,0 +1,84 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.android.instrumentation.sessions + +import io.opentelemetry.android.session.Session +import io.opentelemetry.android.session.Session.DefaultSession +import io.opentelemetry.api.common.AttributeKey +import io.opentelemetry.api.common.KeyValue +import io.opentelemetry.api.common.Value +import io.opentelemetry.sdk.logs.internal.SdkEventLoggerProvider +import io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions +import io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat +import io.opentelemetry.sdk.testing.junit5.OpenTelemetryExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension + +class SessionIdEventSenderTest { + private var underTest: SessionIdEventSender? = null + + @BeforeEach + fun setup() { + val loggerProvider = otelTesting.openTelemetry.logsBridge + val eventLogger = SdkEventLoggerProvider.create(loggerProvider)["testLogging"] + underTest = SessionIdEventSender(eventLogger) + } + + @Test + fun `should emit session start event`() { + val newSession: Session = DefaultSession("123", 0) + val oldSession: Session = DefaultSession("666", 0) + underTest!!.onSessionStarted(newSession, oldSession) + + val logs = otelTesting.logRecords + assertEquals(1, logs.size) + val log = logs[0] + + // TODO: Use new event body assertions when available. + assertThat(log) + .hasAttributesSatisfyingExactly(OpenTelemetryAssertions.equalTo(AttributeKey.stringKey("event.name"), "session.start")) + + val body = log.bodyValue as Value>? + val kvBody = body!!.value + assertThat(kvBody[0].key).isEqualTo("session.previous_id") + assertThat(kvBody[0].value.asString()).isEqualTo("666") + assertThat(kvBody[1].key).isEqualTo("session.id") + assertThat(kvBody[1].value.asString()).isEqualTo("123") + } + + @Test + fun `should emit session end event`() { + val session: Session = DefaultSession("123", 0) + underTest!!.onSessionEnded(session) + + val logs = otelTesting.logRecords + assertEquals(1, logs.size) + val log = logs[0] + + // TODO: Use new event body assertions when available. + assertThat(log) + .hasAttributesSatisfyingExactly( + OpenTelemetryAssertions.equalTo( + AttributeKey.stringKey("event.name"), + "session.end", + ), + ) + + val body = log.bodyValue as Value>? + val kvBody = body!!.value + assertThat(kvBody[0].key).isEqualTo("session.id") + assertThat(kvBody[0].value.asString()).isEqualTo("123") + } + + companion object { + @RegisterExtension + @JvmField + var otelTesting: OpenTelemetryExtension = OpenTelemetryExtension.create() + } +} diff --git a/instrumentation/slowrendering/src/test/java/io/opentelemetry/android/instrumentation/slowrendering/SlowRenderingInstrumentationTest.kt b/instrumentation/slowrendering/src/test/java/io/opentelemetry/android/instrumentation/slowrendering/SlowRenderingInstrumentationTest.kt index 973b3880a..6570ccf72 100644 --- a/instrumentation/slowrendering/src/test/java/io/opentelemetry/android/instrumentation/slowrendering/SlowRenderingInstrumentationTest.kt +++ b/instrumentation/slowrendering/src/test/java/io/opentelemetry/android/instrumentation/slowrendering/SlowRenderingInstrumentationTest.kt @@ -64,7 +64,7 @@ class SlowRenderingInstrumentationTest { @Config(sdk = [23]) @Test fun `Not installing instrumentation on devices with API level lower than 24`() { - val ctx = InstallationContext(application, openTelemetry, mockk()) + val ctx = InstallationContext(application, openTelemetry, mockk(), mockk()) slowRenderingInstrumentation.install(ctx) verify { @@ -81,7 +81,7 @@ class SlowRenderingInstrumentationTest { val capturedListener = slot() every { openTelemetry.getTracer(any()) }.returns(mockk()) every { application.registerActivityLifecycleCallbacks(any()) } just Runs - val ctx = InstallationContext(application, openTelemetry, mockk()) + val ctx = InstallationContext(application, openTelemetry, mockk(), mockk()) slowRenderingInstrumentation.install(ctx) verify { openTelemetry.getTracer("io.opentelemetry.slow-rendering") } diff --git a/instrumentation/startup/src/test/java/io/opentelemetry/android/instrumentation/startup/StartupInstrumentationTest.kt b/instrumentation/startup/src/test/java/io/opentelemetry/android/instrumentation/startup/StartupInstrumentationTest.kt index 98f0eb6f5..467cfb268 100644 --- a/instrumentation/startup/src/test/java/io/opentelemetry/android/instrumentation/startup/StartupInstrumentationTest.kt +++ b/instrumentation/startup/src/test/java/io/opentelemetry/android/instrumentation/startup/StartupInstrumentationTest.kt @@ -15,6 +15,7 @@ import io.mockk.verify import io.opentelemetry.android.instrumentation.InstallationContext import io.opentelemetry.android.internal.initialization.InitializationEvents import io.opentelemetry.android.internal.services.ServiceManager +import io.opentelemetry.android.session.SessionManager import io.opentelemetry.sdk.testing.junit5.OpenTelemetryExtension import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach @@ -64,6 +65,7 @@ class StartupInstrumentationTest { return InstallationContext( mockk(), otelTesting.openTelemetry, + mockk(), mockk(), ) } diff --git a/settings.gradle.kts b/settings.gradle.kts index 0ed23cc0d..bf5101942 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -11,6 +11,7 @@ include(":instrumentation:okhttp:okhttp-3.0:agent") include(":instrumentation:okhttp:okhttp-3.0:library") include(":instrumentation:okhttp:okhttp-3.0:testing") include(":instrumentation:network") +include(":instrumentation:sessions") include(":instrumentation:slowrendering") include(":instrumentation:startup") include(":instrumentation:volley:library")