Skip to content

Send Timber logs through Sentry Logs #4490

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 17 commits into from
Jul 7, 2025
Merged
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

### Fixes

- Send Timber logs through Sentry Logs ([#4490](https://github.com/getsentry/sentry-java/pull/4490))
- Enable the Logs feature in your `SentryOptions` or with the `io.sentry.logs.enabled` manifest option and the SDK will automatically send Timber logs to Sentry, if the TimberIntegration is enabled.
- The SDK will automatically detect Timber and use it to send logs to Sentry.
- Send logcat through Sentry Logs ([#4487](https://github.com/getsentry/sentry-java/pull/4487))
- Enable the Logs feature in your `SentryOptions` or with the `io.sentry.logs.enabled` manifest option and the SDK will automatically send logcat logs to Sentry, if the Sentry Android Gradle plugin is applied.
- To set the logcat level check the [Logcat integration documentation](https://docs.sentry.io/platforms/android/integrations/logcat/#configure).
Expand Down
8 changes: 5 additions & 3 deletions sentry-android-timber/api/sentry-android-timber.api
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,18 @@ public final class io/sentry/android/timber/BuildConfig {

public final class io/sentry/android/timber/SentryTimberIntegration : io/sentry/Integration, java/io/Closeable {
public fun <init> ()V
public fun <init> (Lio/sentry/SentryLevel;Lio/sentry/SentryLevel;)V
public synthetic fun <init> (Lio/sentry/SentryLevel;Lio/sentry/SentryLevel;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun <init> (Lio/sentry/SentryLevel;Lio/sentry/SentryLevel;Lio/sentry/SentryLogLevel;)V
public synthetic fun <init> (Lio/sentry/SentryLevel;Lio/sentry/SentryLevel;Lio/sentry/SentryLogLevel;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun close ()V
public final fun getMinBreadcrumbLevel ()Lio/sentry/SentryLevel;
public final fun getMinEventLevel ()Lio/sentry/SentryLevel;
public final fun getMinLogsLevel ()Lio/sentry/SentryLogLevel;
public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V
}

public final class io/sentry/android/timber/SentryTimberTree : timber/log/Timber$Tree {
public fun <init> (Lio/sentry/IScopes;Lio/sentry/SentryLevel;Lio/sentry/SentryLevel;)V
public fun <init> (Lio/sentry/IScopes;Lio/sentry/SentryLevel;Lio/sentry/SentryLevel;Lio/sentry/SentryLogLevel;)V
public synthetic fun <init> (Lio/sentry/IScopes;Lio/sentry/SentryLevel;Lio/sentry/SentryLevel;Lio/sentry/SentryLogLevel;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun d (Ljava/lang/String;[Ljava/lang/Object;)V
public fun d (Ljava/lang/Throwable;)V
public fun d (Ljava/lang/Throwable;Ljava/lang/String;[Ljava/lang/Object;)V
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import io.sentry.IScopes
import io.sentry.Integration
import io.sentry.SentryIntegrationPackageStorage
import io.sentry.SentryLevel
import io.sentry.SentryLogLevel
import io.sentry.SentryOptions
import io.sentry.android.timber.BuildConfig.VERSION_NAME
import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion
Expand All @@ -15,6 +16,7 @@ import timber.log.Timber
public class SentryTimberIntegration(
public val minEventLevel: SentryLevel = SentryLevel.ERROR,
public val minBreadcrumbLevel: SentryLevel = SentryLevel.INFO,
public val minLogsLevel: SentryLogLevel = SentryLogLevel.INFO,
) : Integration, Closeable {
private lateinit var tree: SentryTimberTree
private lateinit var logger: ILogger
Expand All @@ -29,7 +31,7 @@ public class SentryTimberIntegration(
override fun register(scopes: IScopes, options: SentryOptions) {
logger = options.logger

tree = SentryTimberTree(scopes, minEventLevel, minBreadcrumbLevel)
tree = SentryTimberTree(scopes, minEventLevel, minBreadcrumbLevel, minLogsLevel)
Timber.plant(tree)

logger.log(SentryLevel.DEBUG, "SentryTimberIntegration installed.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import io.sentry.Breadcrumb
import io.sentry.IScopes
import io.sentry.SentryEvent
import io.sentry.SentryLevel
import io.sentry.SentryLogLevel
import io.sentry.protocol.Message
import timber.log.Timber

Expand All @@ -14,6 +15,7 @@ public class SentryTimberTree(
private val scopes: IScopes,
private val minEventLevel: SentryLevel,
private val minBreadcrumbLevel: SentryLevel,
private val minLogLevel: SentryLogLevel = SentryLogLevel.INFO,
) : Timber.Tree() {
private val pendingTag = ThreadLocal<String?>()

Expand Down Expand Up @@ -168,6 +170,7 @@ public class SentryTimberTree(
}

val level = getSentryLevel(priority)
val logLevel = getSentryLogLevel(priority)
val sentryMessage =
Message().apply {
this.message = message
Expand All @@ -179,12 +182,17 @@ public class SentryTimberTree(

captureEvent(level, tag, sentryMessage, throwable)
addBreadcrumb(level, sentryMessage, throwable)
addLog(logLevel, message, throwable, *args)
}

/** do not log if it's lower than min. required level. */
private fun isLoggable(level: SentryLevel, minLevel: SentryLevel): Boolean =
level.ordinal >= minLevel.ordinal

/** do not log if it's lower than min. required level. */
private fun isLoggable(level: SentryLogLevel, minLevel: SentryLogLevel): Boolean =
level.ordinal >= minLevel.ordinal

/** Captures an event with the given attributes */
private fun captureEvent(
sentryLevel: SentryLevel,
Expand Down Expand Up @@ -227,6 +235,25 @@ public class SentryTimberTree(
}
}

/** Send a Sentry Logs */
private fun addLog(
sentryLogLevel: SentryLogLevel,
msg: String?,
throwable: Throwable?,
vararg args: Any?,
) {
// checks the log level
if (isLoggable(sentryLogLevel, minLogLevel)) {
val throwableMsg = throwable?.message
when {
msg != null && throwableMsg != null ->
scopes.logger().log(sentryLogLevel, "$msg\n$throwableMsg", *args)
msg != null -> scopes.logger().log(sentryLogLevel, msg, *args)
throwableMsg != null -> scopes.logger().log(sentryLogLevel, throwableMsg, *args)
}
}
}

/** Converts from Timber priority to SentryLevel. Fallback to SentryLevel.DEBUG. */
private fun getSentryLevel(priority: Int): SentryLevel =
when (priority) {
Expand All @@ -238,4 +265,17 @@ public class SentryTimberTree(
Log.VERBOSE -> SentryLevel.DEBUG
else -> SentryLevel.DEBUG
}

/** Converts from Timber priority to SentryLogLevel. Fallback to SentryLogLevel.DEBUG. */
private fun getSentryLogLevel(priority: Int): SentryLogLevel {
return when (priority) {
Log.ASSERT -> SentryLogLevel.FATAL
Log.ERROR -> SentryLogLevel.ERROR
Log.WARN -> SentryLogLevel.WARN
Log.INFO -> SentryLogLevel.INFO
Log.DEBUG -> SentryLogLevel.DEBUG
Log.VERBOSE -> SentryLogLevel.TRACE
else -> SentryLogLevel.DEBUG
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package io.sentry.android.timber

import io.sentry.IScopes
import io.sentry.SentryLevel
import io.sentry.SentryLogLevel
import io.sentry.SentryOptions
import io.sentry.protocol.SdkVersion
import kotlin.test.BeforeTest
Expand All @@ -21,10 +22,12 @@ class SentryTimberIntegrationTest {
fun getSut(
minEventLevel: SentryLevel = SentryLevel.ERROR,
minBreadcrumbLevel: SentryLevel = SentryLevel.INFO,
minLogsLevel: SentryLogLevel = SentryLogLevel.INFO,
): SentryTimberIntegration =
SentryTimberIntegration(
minEventLevel = minEventLevel,
minBreadcrumbLevel = minBreadcrumbLevel,
minLogsLevel = minLogsLevel,
)
}

Expand Down Expand Up @@ -78,11 +81,16 @@ class SentryTimberIntegrationTest {
@Test
fun `Integrations pass the right min levels`() {
val sut =
fixture.getSut(minEventLevel = SentryLevel.INFO, minBreadcrumbLevel = SentryLevel.DEBUG)
fixture.getSut(
minEventLevel = SentryLevel.INFO,
minBreadcrumbLevel = SentryLevel.DEBUG,
minLogsLevel = SentryLogLevel.TRACE,
)
sut.register(fixture.scopes, fixture.options)

assertEquals(sut.minEventLevel, SentryLevel.INFO)
assertEquals(sut.minBreadcrumbLevel, SentryLevel.DEBUG)
assertEquals(sut.minLogsLevel, SentryLogLevel.TRACE)
}

@Test
Expand Down
Original file line number Diff line number Diff line change
@@ -1,28 +1,39 @@
package io.sentry.android.timber

import io.sentry.Breadcrumb
import io.sentry.IScopes
import io.sentry.Scopes
import io.sentry.SentryLevel
import io.sentry.SentryLogLevel
import io.sentry.logger.ILoggerApi
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import org.mockito.kotlin.any
import org.mockito.kotlin.check
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.verify
import org.mockito.kotlin.verifyNoInteractions
import org.mockito.kotlin.whenever
import timber.log.Timber

class SentryTimberTreeTest {
private class Fixture {
val scopes = mock<IScopes>()
val scopes = mock<Scopes>()
val logs = mock<ILoggerApi>()

init {
whenever(scopes.logger()).thenReturn(logs)
}

fun getSut(
minEventLevel: SentryLevel = SentryLevel.ERROR,
minBreadcrumbLevel: SentryLevel = SentryLevel.INFO,
): SentryTimberTree = SentryTimberTree(scopes, minEventLevel, minBreadcrumbLevel)
minLogsLevel: SentryLogLevel = SentryLogLevel.INFO,
): SentryTimberTree = SentryTimberTree(scopes, minEventLevel, minBreadcrumbLevel, minLogsLevel)
}

private val fixture = Fixture()
Expand Down Expand Up @@ -231,4 +242,72 @@ class SentryTimberTreeTest {
val sut = fixture.getSut()
sut.d("test %s, %s", 1, 1)
}

@Test
fun `Tree adds a log with message and arguments, when provided`() {
val sut = fixture.getSut()
sut.e("test count: %d %d", 32, 5)

verify(fixture.logs).log(eq(SentryLogLevel.ERROR), eq("test count: %d %d"), eq(32), eq(5))
}

@Test
fun `Tree adds a log if min level is equal`() {
val sut = fixture.getSut()
sut.i(Throwable("test"))
verify(fixture.logs).log(any(), any())
}

@Test
fun `Tree adds a log if min level is higher`() {
val sut = fixture.getSut()
sut.e(Throwable("test"))
verify(fixture.logs).log(any(), any<String>(), any())
}

@Test
fun `Tree won't add a log if min level is lower`() {
val sut = fixture.getSut(minLogsLevel = SentryLogLevel.ERROR)
sut.i(Throwable("test"))
verifyNoInteractions(fixture.logs)
}

@Test
fun `Tree adds an info log`() {
val sut = fixture.getSut()
sut.i("message")

verify(fixture.logs).log(eq(SentryLogLevel.INFO), eq("message"))
}

@Test
fun `Tree adds an error log`() {
val sut = fixture.getSut()
sut.e(Throwable("test"))

verify(fixture.logs).log(eq(SentryLogLevel.ERROR), eq("test"))
}

@Test
fun `Tree does not add a log, if no message or throwable is provided`() {
val sut = fixture.getSut()
sut.e(null as String?)
verifyNoInteractions(fixture.logs)
}

@Test
fun `Tree logs throwable`() {
val sut = fixture.getSut()
sut.e(Throwable("throwable message"))

verify(fixture.logs).log(eq(SentryLogLevel.ERROR), eq("throwable message"))
}

@Test
fun `Tree logs throwable and message`() {
val sut = fixture.getSut()
sut.e(Throwable("throwable message"), "My message")

verify(fixture.logs).log(eq(SentryLogLevel.ERROR), eq("My message\nthrowable message"))
}
}
Loading