Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions agent-api/api/agent-api.api
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
public abstract interface annotation class io/opentelemetry/android/Incubating : java/lang/annotation/Annotation {
}

public abstract interface class io/opentelemetry/android/OpenTelemetryRum {
public abstract fun emitEvent (Ljava/lang/String;Ljava/lang/String;Lio/opentelemetry/api/common/Attributes;)V
public static synthetic fun emitEvent$default (Lio/opentelemetry/android/OpenTelemetryRum;Ljava/lang/String;Ljava/lang/String;Lio/opentelemetry/api/common/Attributes;ILjava/lang/Object;)V
Expand Down
29 changes: 26 additions & 3 deletions android-agent/api/android-agent.api
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
public abstract interface annotation class io/opentelemetry/android/Incubating : java/lang/annotation/Annotation {
}

public final class io/opentelemetry/android/agent/OpenTelemetryRumInitializer {
public static final field INSTANCE Lio/opentelemetry/android/agent/OpenTelemetryRumInitializer;
public static final fun initialize (Landroid/content/Context;Lkotlin/jvm/functions/Function1;)Lio/opentelemetry/android/OpenTelemetryRum;
Expand Down Expand Up @@ -105,3 +102,29 @@ public final class io/opentelemetry/android/agent/dsl/instrumentation/SlowRender
public fun enabled (Z)V
}

public final class io/opentelemetry/android/agent/session/SessionConfig {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO it'd be preferable to use the existing SessionConfiguration in the DSL API rather than using concrete classes such as SessionConfig and SessionManagerFactory. E.g. something like this should be possible:

OpenTelemetryRumInitializer.initialize(ctx) {
    sessionConfig {
        backgroundInactivityTimeout = 30.minutes
    }
    sessionProvider = MyCustomSessionProvider()
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it. So you're suggesting adding sessionProvider as a property to OpenTelemetryConfiguration so users can inject custom providers via DSL. Is that correct? If yes, then something like this:

OpenTelemetryRumInitializer.initialize(ctx) {
    sessionProvider = MyCustomSessionProvider()
}

That works for injection, but there's still a creation question of how do DI users create the SessionProvider in the first place?

For instance, when DI is used:

@Provides
fun provideSessionProvider(app: Application): SessionProvider {
    // Need to create a SessionProvider here
    // If SessionConfig and SessionManagerFactory are internal, what do I use?
    return ???
}

@Provides
fun provideOpenTelemetryRum(
    app: Application,
    sessionProvider: SessionProvider
): OpenTelemetryRum {
    return OpenTelemetryRumInitializer.initialize(app) {
        sessionProvider = sessionProvider // The DSL suggestion works for this part
    }
}

Here are the options I see for creating the SessionProvider:

  1. Keep SessionManagerFactory public:
    Users can do
SessionManagerFactory(app).createSessionProvider(config)
  1. Add public factory method: e.g.,
SessionProvider.create(app, timeout, maxLifetime)
  1. Users implement SessionProvider from scratch:
    They rebuild SessionManager's logic (~200 lines of concurrent code, session storage, timeout handling, lifecycle integration)

Which approach makes the most sense to you? I'm leaning heavily toward option 1 (public factory) since it matches how DiskBufferingConfiguration works and keeps the actual implementation internal.

public static final field Companion Lio/opentelemetry/android/agent/session/SessionConfig$Companion;
public synthetic fun <init> (JJILkotlin/jvm/internal/DefaultConstructorMarker;)V
public synthetic fun <init> (JJLkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun equals (Ljava/lang/Object;)Z
public final fun getBackgroundInactivityTimeout-UwyO8pc ()J
public final fun getMaxLifetime-UwyO8pc ()J
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
public static final fun withDefaults ()Lio/opentelemetry/android/agent/session/SessionConfig;
}

public final class io/opentelemetry/android/agent/session/SessionConfig$Companion {
public final fun withDefaults ()Lio/opentelemetry/android/agent/session/SessionConfig;
}

public class io/opentelemetry/android/agent/session/factory/SessionManagerFactory : io/opentelemetry/android/agent/session/factory/SessionProviderFactory {
public fun <init> (Landroid/app/Application;Lio/opentelemetry/sdk/common/Clock;)V
public synthetic fun <init> (Landroid/app/Application;Lio/opentelemetry/sdk/common/Clock;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun createSessionProvider (Lio/opentelemetry/android/agent/session/SessionConfig;)Lio/opentelemetry/android/session/SessionProvider;
}

public abstract interface class io/opentelemetry/android/agent/session/factory/SessionProviderFactory {
public abstract fun createSessionProvider (Lio/opentelemetry/android/agent/session/SessionConfig;)Lio/opentelemetry/android/session/SessionProvider;
}

Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ object OpenTelemetryRumInitializer {
else -> "none"
}

@OptIn(Incubating::class)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a need to annotate this private function as Incubating?

private fun createSessionProvider(
context: Context,
cfg: OpenTelemetryConfiguration,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,87 @@ import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes

internal class SessionConfig(
/**
* Configures session management behavior in the OpenTelemetry Android SDK.
*
* Sessions provide a way to group related telemetry data (spans, logs, metrics) that occur during
* a logical user interaction or application usage period. This configuration controls when sessions
* expire and new sessions are created.
*
* ## Session Lifecycle
*
* A session can end due to two conditions:
* 1. **Background Inactivity**: When the app goes to background and remains inactive for longer than [backgroundInactivityTimeout]
* 2. **Maximum Lifetime**: When a session has been active for longer than [maxLifetime], regardless of app state
*
* When a session ends, a new session will be created on the next telemetry operation, and the previous
* session ID will be tracked for correlation purposes.
*
* ## Usage Example
*
* ```kotlin
* // Use default configuration (15 minutes background timeout, 4 hours max lifetime)
* val config = SessionConfig.withDefaults()
*
* // Custom configuration for shorter sessions
* val shortSessionConfig = SessionConfig(
* backgroundInactivityTimeout = 5.minutes,
* maxLifetime = 1.hours
* )
* ```
*
* @param backgroundInactivityTimeout duration of app backgrounding after which the session expires.
* Default is 15 minutes, meaning if the app stays in background for 15+ minutes, the current session
* ends and a new one will be created when the app becomes active again.
*
* @param maxLifetime maximum duration a session can remain active regardless of app activity.
* Default is 4 hours, meaning even if the app stays in foreground continuously, sessions will
* rotate every 4 hours to prevent unbounded session growth and ensure fresh session context.
*
* @see io.opentelemetry.android.agent.session.SessionManager
* @see io.opentelemetry.android.session.SessionProvider
*/
class SessionConfig(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit confused about why we need to expose this class?

val backgroundInactivityTimeout: Duration = 15.minutes,
val maxLifetime: Duration = 4.hours,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false

other as SessionConfig

if (backgroundInactivityTimeout != other.backgroundInactivityTimeout) return false
if (maxLifetime != other.maxLifetime) return false

return true
}

override fun hashCode(): Int {
var result = backgroundInactivityTimeout.hashCode()
result = 31 * result + maxLifetime.hashCode()
return result
}

override fun toString(): String =
"SessionConfig(" +
"backgroundInactivityTimeout=$backgroundInactivityTimeout, " +
"maxLifetime=$maxLifetime" +
")"

companion object {
/**
* Creates a SessionConfig with default values.
*
* Default configuration:
* - Background inactivity timeout: 15 minutes
* - Maximum session lifetime: 4 hours
*
* These defaults balance session continuity with memory efficiency and provide
* reasonable session boundaries for most mobile applications.
*
* @return a new SessionConfig instance with default timeout values.
*/
@JvmStatic
fun withDefaults(): SessionConfig = SessionConfig()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ internal class SessionManager(
) : SessionProvider,
SessionPublisher {
private val session: AtomicReference<Session> = AtomicReference(Session.NONE)
private val previousSession: AtomicReference<Session> = AtomicReference(Session.NONE)
private val observers = synchronizedList(ArrayList<SessionObserver>())

init {
Expand All @@ -47,6 +48,10 @@ internal class SessionManager(
if (session.compareAndSet(currentSession, newSession)) {
sessionStorage.save(newSession)
timeoutHandler.bump()

// Track the previous session for session transition correlation
previousSession.set(currentSession)

// Observers need to be called after bumping the timer because it may create a new
// span.
notifyObserversOfSessionUpdate(currentSession, newSession)
Expand Down Expand Up @@ -79,6 +84,8 @@ internal class SessionManager(
return elapsedTime >= maxSessionLifetime.inWholeNanoseconds
}

override fun getPreviousSessionId(): String = previousSession.get().getId()

companion object {
@OptIn(Incubating::class)
@JvmStatic
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/

package io.opentelemetry.android.agent.session.factory

import android.app.Application
import io.opentelemetry.android.Incubating
import io.opentelemetry.android.agent.session.SessionConfig
import io.opentelemetry.android.agent.session.SessionIdTimeoutHandler
import io.opentelemetry.android.agent.session.SessionManager
import io.opentelemetry.android.internal.services.Services
import io.opentelemetry.android.session.SessionProvider
import io.opentelemetry.sdk.common.Clock

/**
* Default implementation of [SessionProviderFactory] that creates [SessionManager] instances.
*
* This implementation creates a [SessionManager] and wires it with:
* - A timeout handler configured from the session config
* - Application lifecycle integration for timeout management
*
* This class is open to allow custom implementations for specialized use cases.
*
* @param application the Android application instance used to access platform services.
* @param clock the clock instance to use for time-based operations. Defaults to [Clock.getDefault].
* @see SessionProviderFactory
* @see SessionManager
* @see SessionConfig
*/
open class SessionManagerFactory(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not a fan of exposing concrete types as part of the public API, particularly if they're open like here. IMO the default implementation should be internal and not visible to an end-user

Copy link
Copy Markdown
Contributor Author

@gregory-at-cvs gregory-at-cvs Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it relates to how we're thinking about the SDK's architecture. These are good discussions to have. 😄

Libraries vs SDKs vs Frameworks

From my perspective, this SDK's design fits that of a framework and not a library. That distinction matters because:

Libraries tend to give you code to use as-is without customization
Frameworks provide building blocks you extend
SDKs combine both, providing framework capabilities plus tooling

This SDK already leans into the framework side:

  1. OpenTelemetryRum.builder() lets you customize at every layer
  2. Config classes like DiskBufferingConfiguration and OtelRumConfig are public
  3. Extension points throughout (customizers for providers, exporters, propagators)

Making SessionManagerFactory public and open follows that same pattern. It makes the SDK extensible by design.

On Your DSL Suggestion

Adding sessionProvider as a property to OpenTelemetryConfiguration would solve the injection part:

OpenTelemetryRumInitializer.initialize(ctx) {
    sessionProvider = myCustomProvider
} // But there's still a creation question - how do DI users create that `SessionProvider` in the first place if `SessionManagerFactory` is internal?

@Provides
fun provideSessionProvider(app: Application, config: AppConfig): SessionProvider {
    return ??? // Need a way to create this with custom config
} // That's why the factory needs to be public.

The Codebase Already Works This Way

OtelRumConfig is public so teams can create it in their DI containers and pull values from Firebase Remote Config, feature flags, or their own config services.

SessionManagerFactory needs to be public for the same reason. Teams need to instantiate it in DI with session timeouts from their config sources, not hardcoded values.

The open modifier lets teams subclass the factory to add custom behavior (analytics integration, custom logging) while reusing the default implementation. Without open, they'd duplicate the timeout handler creation, lifecycle registration, and session manager wiring.

What Remains Hidden

The complicated stuff still stays internal:

  • SessionManager - internal
  • SessionIdTimeoutHandler - internal
  • SessionStorage - internal
  • All the threading, concurrency, lifecycle wiring - internal

Only the extension points are public (factory and config). The implementation stays locked down.

This is how frameworks like Dagger, OkHttp, and others work. They expose factories because customization is part of the public contract.

This is the architectural pattern I'm seeing throughout the SDK. If you have a different approach for how teams should create SessionProviders in DI scenarios, I'd like to understand that.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Btw, OpenTelemetryRumBuilder already allows to set an instance of SessionProvider. If we wanted to allow users of android-agent to provide their own session logic, we could allow them to set their own instance of SessionProvider in the initializer function, but this is something that would require a deeper talk, as the agent is supposedly our opinionated way of configuring OpenTelemetryRumBuilder.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the intended use case for this factory? SessionManager is supposed to be our opinionated implementation of SessionProvider, so users shouldn't ever need to manually create SessionManager.

private val application: Application,
private val clock: Clock = Clock.getDefault(),
) : SessionProviderFactory {
/**
* Creates a session manager instance.
*
* This implementation creates a [SessionManager] with a timeout handler that is
* registered with the application lifecycle service.
*
* @param sessionConfig the configuration for session management.
* @return a session manager instance.
*/
@OptIn(Incubating::class)
override fun createSessionProvider(sessionConfig: SessionConfig): SessionProvider {
val timeoutHandler = SessionIdTimeoutHandler(sessionConfig, clock)
Services.get(application).appLifecycle.registerListener(timeoutHandler)
return SessionManager.create(timeoutHandler, sessionConfig, clock)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/

package io.opentelemetry.android.agent.session.factory

import io.opentelemetry.android.agent.session.SessionConfig
import io.opentelemetry.android.session.SessionProvider

/**
* Factory for creating [SessionProvider] instances.
*
* This interface follows the Factory design pattern to enable flexible dependency injection
* and testing of session management components. Implementations can provide custom session
* providers with different behaviors while maintaining a consistent creation interface.
*
* @see SessionManagerFactory
* @see SessionProvider
*/
interface SessionProviderFactory {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm also not sure what could be the use case for this factory. Users should be able to create their own implementation of SessionProvider and pass it to OpenTelemetryRumBuilder if they want a custom session management.

/**
* Creates a session provider instance.
*
* @param sessionConfig the configuration for session management.
* @return a session provider instance.
*/
fun createSessionProvider(sessionConfig: SessionConfig): SessionProvider
}
Loading