diff --git a/embrace-android-api/api/embrace-android-api.api b/embrace-android-api/api/embrace-android-api.api index c48518aa80..e9cc33e90a 100644 --- a/embrace-android-api/api/embrace-android-api.api +++ b/embrace-android-api/api/embrace-android-api.api @@ -87,6 +87,8 @@ public abstract interface class io/embrace/android/embracesdk/internal/api/LogsA public abstract fun logInfo (Ljava/lang/String;)V public abstract fun logMessage (Ljava/lang/String;Lio/embrace/android/embracesdk/Severity;)V public abstract fun logMessage (Ljava/lang/String;Lio/embrace/android/embracesdk/Severity;Ljava/util/Map;)V + public abstract fun logMessage (Ljava/lang/String;Lio/embrace/android/embracesdk/Severity;Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;)V + public abstract fun logMessage (Ljava/lang/String;Lio/embrace/android/embracesdk/Severity;Ljava/util/Map;[B)V public abstract fun logPushNotification (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Boolean;Ljava/lang/Boolean;)V public abstract fun logWarning (Ljava/lang/String;)V } diff --git a/embrace-android-api/src/main/kotlin/io/embrace/android/embracesdk/internal/api/LogsApi.kt b/embrace-android-api/src/main/kotlin/io/embrace/android/embracesdk/internal/api/LogsApi.kt index bfdd10db03..fb93a14902 100644 --- a/embrace-android-api/src/main/kotlin/io/embrace/android/embracesdk/internal/api/LogsApi.kt +++ b/embrace-android-api/src/main/kotlin/io/embrace/android/embracesdk/internal/api/LogsApi.kt @@ -34,6 +34,42 @@ public interface LogsApi { properties: Map?, ) + /** + * Remotely logs a message at the given severity level with an attachment. These log messages will appear + * as part of the session timeline, and can be used to describe what was happening at a particular + * time within the app. + * + * @param message the message to remotely log + * @param severity the severity level of the log message + * @param properties the properties to attach to the log message + * @param attachment an attachment to include with the log message. This must be < 1MB in size. + */ + public fun logMessage( + message: String, + severity: Severity, + properties: Map?, + attachment: ByteArray, + ) + + /** + * Remotely logs a message at the given severity level with an attachment that has been persisted on a 3rd party + * hosting solution. These log messages will appear as part of the session timeline, and can be used to + * describe what was happening at a particular time within the app. + * + * @param message the message to remotely log + * @param severity the severity level of the log message + * @param properties the properties to attach to the log message + * @param attachmentId a UUID that identifies the attachment + * @param attachmentUrl a URL that gives the location of the attachment + */ + public fun logMessage( + message: String, + severity: Severity, + properties: Map?, + attachmentId: String, + attachmentUrl: String, + ) + /** * Remotely logs a message at INFO level. These log messages will appear as part of the session * timeline, and can be used to describe what was happening at a particular time within the app. diff --git a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/injection/LogModule.kt b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/injection/LogModule.kt index b86881d740..3ddecae4b0 100644 --- a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/injection/LogModule.kt +++ b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/injection/LogModule.kt @@ -2,6 +2,7 @@ package io.embrace.android.embracesdk.internal.injection import io.embrace.android.embracesdk.internal.logs.LogOrchestrator import io.embrace.android.embracesdk.internal.logs.LogService +import io.embrace.android.embracesdk.internal.logs.attachments.AttachmentService import io.embrace.android.embracesdk.internal.network.logging.NetworkCaptureDataSource import io.embrace.android.embracesdk.internal.network.logging.NetworkCaptureService import io.embrace.android.embracesdk.internal.network.logging.NetworkLoggingService @@ -15,4 +16,5 @@ interface LogModule { val networkLoggingService: NetworkLoggingService val logService: LogService val logOrchestrator: LogOrchestrator + val attachmentService: AttachmentService } diff --git a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/injection/LogModuleImpl.kt b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/injection/LogModuleImpl.kt index 6dfe51efdf..f244c95c56 100644 --- a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/injection/LogModuleImpl.kt +++ b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/injection/LogModuleImpl.kt @@ -4,6 +4,7 @@ import io.embrace.android.embracesdk.internal.logs.EmbraceLogService import io.embrace.android.embracesdk.internal.logs.LogOrchestrator import io.embrace.android.embracesdk.internal.logs.LogOrchestratorImpl import io.embrace.android.embracesdk.internal.logs.LogService +import io.embrace.android.embracesdk.internal.logs.attachments.AttachmentService import io.embrace.android.embracesdk.internal.network.logging.EmbraceDomainCountLimiter import io.embrace.android.embracesdk.internal.network.logging.EmbraceNetworkCaptureService import io.embrace.android.embracesdk.internal.network.logging.EmbraceNetworkLoggingService @@ -61,7 +62,8 @@ internal class LogModuleImpl( EmbraceLogService( essentialServiceModule.logWriter, configModule.configService, - essentialServiceModule.sessionPropertiesService + essentialServiceModule.sessionPropertiesService, + deliveryModule.payloadStore, ) } @@ -74,4 +76,8 @@ internal class LogModuleImpl( payloadSourceModule.logEnvelopeSource, ) } + + override val attachmentService: AttachmentService by singleton { + AttachmentService() + } } diff --git a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/logs/EmbraceLogService.kt b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/logs/EmbraceLogService.kt index 6dfe02e372..6b8a1d44a4 100644 --- a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/logs/EmbraceLogService.kt +++ b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/logs/EmbraceLogService.kt @@ -11,8 +11,11 @@ import io.embrace.android.embracesdk.internal.arch.schema.TelemetryAttributes import io.embrace.android.embracesdk.internal.capture.session.SessionPropertiesService import io.embrace.android.embracesdk.internal.config.ConfigService import io.embrace.android.embracesdk.internal.config.behavior.REDACTED_LABEL +import io.embrace.android.embracesdk.internal.logs.attachments.Attachment import io.embrace.android.embracesdk.internal.opentelemetry.embExceptionHandling import io.embrace.android.embracesdk.internal.payload.AppFramework +import io.embrace.android.embracesdk.internal.payload.Envelope +import io.embrace.android.embracesdk.internal.session.orchestrator.PayloadStore import io.embrace.android.embracesdk.internal.spans.toOtelSeverity import io.embrace.android.embracesdk.internal.utils.PropertyUtils.normalizeProperties import io.embrace.android.embracesdk.internal.utils.Uuid @@ -26,6 +29,7 @@ class EmbraceLogService( private val logWriter: LogWriter, private val configService: ConfigService, private val sessionPropertiesService: SessionPropertiesService, + private val payloadStore: PayloadStore?, ) : LogService { private val behavior = configService.logMessageBehavior @@ -41,6 +45,7 @@ class EmbraceLogService( logExceptionType: LogExceptionType, properties: Map?, customLogAttrs: Map, String>, + logAttachment: Attachment.EmbraceHosted?, ) { val redactedProperties = redactSensitiveProperties(normalizeProperties(properties)) val attrs = createTelemetryAttributes(redactedProperties, customLogAttrs) @@ -53,6 +58,12 @@ class EmbraceLogService( if (logExceptionType != LogExceptionType.NONE) { attrs.setAttribute(embExceptionHandling, logExceptionType.value) } + + logAttachment?.let { + val envelope = Envelope(data = Pair(it.id, it.bytes)) + payloadStore?.storeAttachment(envelope) + } + addLogEventData( message = message, severity = severity, diff --git a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/logs/LogService.kt b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/logs/LogService.kt index 654d11c42c..f6c14e93c5 100644 --- a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/logs/LogService.kt +++ b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/logs/LogService.kt @@ -2,6 +2,7 @@ package io.embrace.android.embracesdk.internal.logs import io.embrace.android.embracesdk.LogExceptionType import io.embrace.android.embracesdk.Severity +import io.embrace.android.embracesdk.internal.logs.attachments.Attachment import io.embrace.android.embracesdk.internal.session.MemoryCleanerListener import io.opentelemetry.api.common.AttributeKey @@ -12,11 +13,6 @@ interface LogService : MemoryCleanerListener { /** * Creates a remote log. - * - * @param message the message to log - * @param severity the log severity - * @param logExceptionType whether the log is a handled exception, unhandled, or non an exception - * @param properties custom properties to send as part of the event */ fun log( message: String, @@ -24,6 +20,7 @@ interface LogService : MemoryCleanerListener { logExceptionType: LogExceptionType, properties: Map? = null, customLogAttrs: Map, String> = emptyMap(), + logAttachment: Attachment.EmbraceHosted? = null, ) /** diff --git a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/logs/attachments/Attachment.kt b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/logs/attachments/Attachment.kt index d5f9a23fec..8c76b8a376 100644 --- a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/logs/attachments/Attachment.kt +++ b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/logs/attachments/Attachment.kt @@ -1,38 +1,34 @@ package io.embrace.android.embracesdk.internal.logs.attachments +import io.embrace.android.embracesdk.internal.arch.schema.EmbraceAttributeKey import io.embrace.android.embracesdk.internal.logs.attachments.AttachmentErrorCode.ATTACHMENT_TOO_LARGE import io.embrace.android.embracesdk.internal.logs.attachments.AttachmentErrorCode.OVER_MAX_ATTACHMENTS import io.embrace.android.embracesdk.internal.logs.attachments.AttachmentErrorCode.UNKNOWN +import io.embrace.android.embracesdk.internal.opentelemetry.embAttachmentErrorCode +import io.embrace.android.embracesdk.internal.opentelemetry.embAttachmentId +import io.embrace.android.embracesdk.internal.opentelemetry.embAttachmentSize +import io.embrace.android.embracesdk.internal.opentelemetry.embAttachmentUrl import io.embrace.android.embracesdk.internal.utils.toNonNullMap import java.util.UUID /** * Holds attributes that describe an attachment to a log record. */ -internal sealed class Attachment( - val size: Long, - val id: String, - val counter: AttachmentCounter, -) { +sealed class Attachment(val id: String) { internal companion object { - const val ATTR_KEY_SIZE = "emb.attachment_size" - const val ATTR_KEY_URL = "emb.attachment_url" - const val ATTR_KEY_ID = "emb.attachment_id" - const val ATTR_KEY_ERR_CODE = "emb.attachment_error_code" private const val LIMIT_MB = 1 * 1024 * 1024 } - abstract val attributes: Map + abstract val attributes: Map + abstract val errorCode: AttachmentErrorCode? protected fun constructAttributes( - size: Long, id: String, - errorCode: AttachmentErrorCode? = null - ) = mapOf( - ATTR_KEY_SIZE to size.toString(), - ATTR_KEY_ID to id, - ATTR_KEY_ERR_CODE to errorCode?.name + errorCode: AttachmentErrorCode? = null, + ): Map = mapOf( + embAttachmentId to id, + embAttachmentErrorCode to errorCode?.name ).toNonNullMap() /** @@ -40,44 +36,45 @@ internal sealed class Attachment( */ class EmbraceHosted( val bytes: ByteArray, - counter: AttachmentCounter, + counter: () -> Boolean, ) : Attachment( - bytes.size.toLong(), - UUID.randomUUID().toString(), - counter + UUID.randomUUID().toString() ) { - private val errorCode: AttachmentErrorCode? = when { - !counter.incrementAndCheckAttachmentLimit() -> OVER_MAX_ATTACHMENTS + private val size: Long = bytes.size.toLong() + + override val errorCode: AttachmentErrorCode? = when { + !counter() -> OVER_MAX_ATTACHMENTS size > LIMIT_MB -> ATTACHMENT_TOO_LARGE else -> null } - override val attributes: Map = constructAttributes(size, id, errorCode) + override val attributes: Map = constructAttributes(id, errorCode).plus( + embAttachmentSize to size.toString(), + ) + + fun shouldAttemptUpload(): Boolean = errorCode == null } /** * An attachment that is uploaded to a user-supplied backend. */ class UserHosted( - size: Long, id: String, val url: String, - counter: AttachmentCounter, - ) : Attachment(size, id, counter) { + counter: () -> Boolean, + ) : Attachment(id) { - private val errorCode: AttachmentErrorCode? = when { - !counter.incrementAndCheckAttachmentLimit() -> OVER_MAX_ATTACHMENTS - size < 0 -> UNKNOWN + override val errorCode: AttachmentErrorCode? = when { + !counter() -> OVER_MAX_ATTACHMENTS url.isEmpty() -> UNKNOWN isNotUuid() -> UNKNOWN else -> null } - override val attributes: Map = - constructAttributes(size, id, errorCode).plus( - ATTR_KEY_URL to url - ) + override val attributes: Map = constructAttributes(id, errorCode).plus( + embAttachmentUrl to url + ) private fun isNotUuid(): Boolean = try { UUID.fromString(id) diff --git a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/logs/attachments/AttachmentCounter.kt b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/logs/attachments/AttachmentCounter.kt deleted file mode 100644 index 8dd30d35be..0000000000 --- a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/logs/attachments/AttachmentCounter.kt +++ /dev/null @@ -1,19 +0,0 @@ -package io.embrace.android.embracesdk.internal.logs.attachments - -import io.embrace.android.embracesdk.internal.session.MemoryCleanerListener -import java.util.concurrent.atomic.AtomicInteger - -/** - * Counts the number of attachments that should be added to log records. - */ -class AttachmentCounter(private val limit: Int = 5) : MemoryCleanerListener { - - private val count: AtomicInteger = AtomicInteger(0) - - override fun cleanCollections() = count.set(0) - - /** - * Increments the counter of attachments for this session and returns true if an attachment can be uploaded. - */ - fun incrementAndCheckAttachmentLimit(): Boolean = count.incrementAndGet() <= limit -} diff --git a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/logs/attachments/AttachmentErrorCode.kt b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/logs/attachments/AttachmentErrorCode.kt index e3896ee8bb..58bca21841 100644 --- a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/logs/attachments/AttachmentErrorCode.kt +++ b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/logs/attachments/AttachmentErrorCode.kt @@ -3,9 +3,8 @@ package io.embrace.android.embracesdk.internal.logs.attachments /** * Enumerates the states where an attachment could not be added to a log record. */ -internal enum class AttachmentErrorCode { +enum class AttachmentErrorCode { ATTACHMENT_TOO_LARGE, - UNSUCCESSFUL_UPLOAD, OVER_MAX_ATTACHMENTS, UNKNOWN } diff --git a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/logs/attachments/AttachmentService.kt b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/logs/attachments/AttachmentService.kt new file mode 100644 index 0000000000..249dde6e91 --- /dev/null +++ b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/logs/attachments/AttachmentService.kt @@ -0,0 +1,33 @@ +package io.embrace.android.embracesdk.internal.logs.attachments + +import io.embrace.android.embracesdk.internal.logs.attachments.Attachment.EmbraceHosted +import io.embrace.android.embracesdk.internal.logs.attachments.Attachment.UserHosted +import io.embrace.android.embracesdk.internal.session.MemoryCleanerListener +import java.util.concurrent.atomic.AtomicInteger + +/** + * Counts the number of attachments that should be added to log records. + */ +class AttachmentService(private val limit: Int = 5) : MemoryCleanerListener { + + fun createAttachment(attachment: ByteArray): EmbraceHosted = + EmbraceHosted(attachment, ::incrementAndCheckAttachmentLimit) + + fun createAttachment( + attachmentId: String, + attachmentUrl: String, + ): UserHosted = UserHosted( + attachmentId, + attachmentUrl, + ::incrementAndCheckAttachmentLimit + ) + + private val count: AtomicInteger = AtomicInteger(0) + + override fun cleanCollections() = count.set(0) + + /** + * Increments the counter of attachments for this session and returns true if an attachment can be uploaded. + */ + private fun incrementAndCheckAttachmentLimit(): Boolean = count.incrementAndGet() <= limit +} diff --git a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/opentelemetry/EmbraceAttributeKeys.kt b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/opentelemetry/EmbraceAttributeKeys.kt index d94c1bc426..2ff54bdde9 100644 --- a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/opentelemetry/EmbraceAttributeKeys.kt +++ b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/opentelemetry/EmbraceAttributeKeys.kt @@ -102,3 +102,23 @@ val embFreeDiskBytes: EmbraceAttributeKey = EmbraceAttributeKey("disk_free_bytes * Attribute name that identifies how a signal should be delivered to the Embrace backend */ val embSendMode: EmbraceAttributeKey = EmbraceAttributeKey(id = "send_mode", isPrivate = true) + +/** + * The size of a log message attachment in bytes + */ +val embAttachmentSize: EmbraceAttributeKey = EmbraceAttributeKey("attachment_size") + +/** + * The URL of a user-hosted log message attachment + */ +val embAttachmentUrl: EmbraceAttributeKey = EmbraceAttributeKey("attachment_url") + +/** + * The ID of a user-hosted log message attachment + */ +val embAttachmentId: EmbraceAttributeKey = EmbraceAttributeKey("attachment_id") + +/** + * The error code associated with a failed log message attachment + */ +val embAttachmentErrorCode: EmbraceAttributeKey = EmbraceAttributeKey("attachment_error_code") diff --git a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/resurrection/PayloadResurrectionServiceImpl.kt b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/resurrection/PayloadResurrectionServiceImpl.kt index 76473bb1b8..6c9b316fbd 100644 --- a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/resurrection/PayloadResurrectionServiceImpl.kt +++ b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/resurrection/PayloadResurrectionServiceImpl.kt @@ -97,7 +97,7 @@ internal class PayloadResurrectionServiceImpl( inputStream = GZIPInputStream( cacheStorageService.loadPayloadAsStream(cachedCrashEnvelopeMetadata) ), - type = SupportedEnvelopeType.CRASH.serializedType + type = checkNotNull(SupportedEnvelopeType.CRASH.serializedType) ).also { cacheStorageService.delete(cachedCrashEnvelopeMetadata) } @@ -157,7 +157,7 @@ internal class PayloadResurrectionServiceImpl( SupportedEnvelopeType.SESSION -> { val deadSession = serializer.fromJson>( inputStream = GZIPInputStream(cacheStorageService.loadPayloadAsStream(this)), - type = envelopeType.serializedType + type = checkNotNull(envelopeType.serializedType) ) val sessionId = deadSession.getSessionId() diff --git a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/session/orchestrator/PayloadStore.kt b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/session/orchestrator/PayloadStore.kt index 7f85f2a917..fd3ee1e65b 100644 --- a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/session/orchestrator/PayloadStore.kt +++ b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/session/orchestrator/PayloadStore.kt @@ -28,6 +28,11 @@ interface PayloadStore : CrashTeardownHandler { */ fun storeLogPayload(envelope: Envelope, attemptImmediateRequest: Boolean) + /** + * Stores a log attachment. + */ + fun storeAttachment(envelope: Envelope>) + /** * Stores an empty payload-type-less crash envelope for future use. One one cached version of this should * exist at one time. diff --git a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/session/orchestrator/V1PayloadStore.kt b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/session/orchestrator/V1PayloadStore.kt index e2ccdb5bdd..68d47042fd 100644 --- a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/session/orchestrator/V1PayloadStore.kt +++ b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/session/orchestrator/V1PayloadStore.kt @@ -31,6 +31,10 @@ class V1PayloadStore( } } + override fun storeAttachment(envelope: Envelope>) { + // ignored - v1 doesn't support attachments + } + override fun cacheEmptyCrashEnvelope(envelope: Envelope) { // ignore - there's no good place to stash this, and since this will be deleted soon, I'm not going to find one } diff --git a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/session/orchestrator/V2PayloadStore.kt b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/session/orchestrator/V2PayloadStore.kt index 805d2072b7..e0a8bd3b29 100644 --- a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/session/orchestrator/V2PayloadStore.kt +++ b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/session/orchestrator/V2PayloadStore.kt @@ -41,6 +41,16 @@ internal class V2PayloadStore( intakeService.take(envelope, createMetadata(type, payloadType = payloadType)) } + override fun storeAttachment(envelope: Envelope>) { + intakeService.take( + envelope, + createMetadata( + type = SupportedEnvelopeType.ATTACHMENT, + payloadType = PayloadType.ATTACHMENT + ) + ) + } + override fun cacheEmptyCrashEnvelope(envelope: Envelope) { intakeService.take( intake = envelope, diff --git a/embrace-android-core/src/test/java/io/embrace/android/embracesdk/internal/injection/LogModuleImplTest.kt b/embrace-android-core/src/test/java/io/embrace/android/embracesdk/internal/injection/LogModuleImplTest.kt index 8c2e504909..0ecb90bce4 100644 --- a/embrace-android-core/src/test/java/io/embrace/android/embracesdk/internal/injection/LogModuleImplTest.kt +++ b/embrace-android-core/src/test/java/io/embrace/android/embracesdk/internal/injection/LogModuleImplTest.kt @@ -30,5 +30,6 @@ internal class LogModuleImplTest { assertNotNull(module.networkCaptureService) assertNotNull(module.networkLoggingService) assertNotNull(module.logService) + assertNotNull(module.attachmentService) } } diff --git a/embrace-android-core/src/test/java/io/embrace/android/embracesdk/internal/logs/EmbraceLogServiceTest.kt b/embrace-android-core/src/test/java/io/embrace/android/embracesdk/internal/logs/EmbraceLogServiceTest.kt index 1680bda5f1..ffb48dcae8 100644 --- a/embrace-android-core/src/test/java/io/embrace/android/embracesdk/internal/logs/EmbraceLogServiceTest.kt +++ b/embrace-android-core/src/test/java/io/embrace/android/embracesdk/internal/logs/EmbraceLogServiceTest.kt @@ -4,6 +4,7 @@ import io.embrace.android.embracesdk.LogExceptionType import io.embrace.android.embracesdk.Severity import io.embrace.android.embracesdk.fakes.FakeConfigService import io.embrace.android.embracesdk.fakes.FakeLogWriter +import io.embrace.android.embracesdk.fakes.FakePayloadStore import io.embrace.android.embracesdk.fakes.FakeSessionPropertiesService import io.embrace.android.embracesdk.fakes.behavior.FakeLogMessageBehavior import io.embrace.android.embracesdk.fakes.config.FakeInstrumentedConfig @@ -14,6 +15,7 @@ import io.embrace.android.embracesdk.internal.config.behavior.REDACTED_LABEL import io.embrace.android.embracesdk.internal.config.behavior.SensitiveKeysBehaviorImpl import io.embrace.android.embracesdk.internal.config.remote.RemoteConfig import io.embrace.android.embracesdk.internal.config.remote.SessionRemoteConfig +import io.embrace.android.embracesdk.internal.logs.attachments.Attachment import io.embrace.android.embracesdk.internal.payload.AppFramework import io.opentelemetry.semconv.incubating.LogIncubatingAttributes import org.junit.Assert.assertEquals @@ -28,6 +30,7 @@ internal class EmbraceLogServiceTest { private lateinit var fakeLogWriter: FakeLogWriter private lateinit var fakeSessionPropertiesService: FakeSessionPropertiesService private lateinit var fakeConfigService: FakeConfigService + private lateinit var payloadStore: FakePayloadStore @Before fun setUp() { @@ -38,7 +41,7 @@ internal class EmbraceLogServiceTest { ) fakeSessionPropertiesService = FakeSessionPropertiesService() fakeLogWriter = FakeLogWriter() - + payloadStore = FakePayloadStore() logService = createEmbraceLogService() } @@ -46,6 +49,7 @@ internal class EmbraceLogServiceTest { logWriter = fakeLogWriter, configService = fakeConfigService, sessionPropertiesService = fakeSessionPropertiesService, + payloadStore = payloadStore, ) @Test @@ -237,4 +241,22 @@ internal class EmbraceLogServiceTest { // then the correct number of error logs is returned assertEquals(5, logService.getErrorLogsCount()) } + + @Test + fun `log with attachment`() { + val bytes = ByteArray(2) + val msg = "message" + logService.log( + message = msg, + severity = Severity.INFO, + logExceptionType = LogExceptionType.NONE, + logAttachment = Attachment.EmbraceHosted(bytes) { true }, + ) + + // then the sensitive key is redacted + val log = fakeLogWriter.logEvents.single() + assertEquals(msg, log.message) + val attachment = payloadStore.storedAttachments.single() + assertEquals(bytes, attachment.data.second) + } } diff --git a/embrace-android-core/src/test/java/io/embrace/android/embracesdk/internal/logs/attachments/AttachmentTest.kt b/embrace-android-core/src/test/java/io/embrace/android/embracesdk/internal/logs/attachments/AttachmentTest.kt index 8d7445ee4c..b596adc767 100644 --- a/embrace-android-core/src/test/java/io/embrace/android/embracesdk/internal/logs/attachments/AttachmentTest.kt +++ b/embrace-android-core/src/test/java/io/embrace/android/embracesdk/internal/logs/attachments/AttachmentTest.kt @@ -1,12 +1,14 @@ package io.embrace.android.embracesdk.internal.logs.attachments -import io.embrace.android.embracesdk.internal.logs.attachments.Attachment.Companion.ATTR_KEY_ERR_CODE -import io.embrace.android.embracesdk.internal.logs.attachments.Attachment.Companion.ATTR_KEY_ID -import io.embrace.android.embracesdk.internal.logs.attachments.Attachment.Companion.ATTR_KEY_SIZE -import io.embrace.android.embracesdk.internal.logs.attachments.Attachment.Companion.ATTR_KEY_URL +import io.embrace.android.embracesdk.internal.logs.attachments.Attachment.EmbraceHosted +import io.embrace.android.embracesdk.internal.logs.attachments.Attachment.UserHosted import io.embrace.android.embracesdk.internal.logs.attachments.AttachmentErrorCode.ATTACHMENT_TOO_LARGE import io.embrace.android.embracesdk.internal.logs.attachments.AttachmentErrorCode.OVER_MAX_ATTACHMENTS import io.embrace.android.embracesdk.internal.logs.attachments.AttachmentErrorCode.UNKNOWN +import io.embrace.android.embracesdk.internal.opentelemetry.embAttachmentErrorCode +import io.embrace.android.embracesdk.internal.opentelemetry.embAttachmentId +import io.embrace.android.embracesdk.internal.opentelemetry.embAttachmentSize +import io.embrace.android.embracesdk.internal.opentelemetry.embAttachmentUrl import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Test @@ -22,116 +24,105 @@ internal class AttachmentTest { private val BYTES = "{}".toByteArray() } - private val counter = AttachmentCounter(limit = Int.MAX_VALUE) + private val counter: () -> Boolean = { true } @Test fun `create embrace hosted attachment`() { - val attachment = Attachment.EmbraceHosted(BYTES, counter) + val attachment = EmbraceHosted(BYTES, counter) attachment.assertEmbraceHostedAttributesMatch() } @Test fun `embrace hosted attachment empty byte array`() { - val attachment = Attachment.EmbraceHosted(ByteArray(0), counter) + val attachment = EmbraceHosted(ByteArray(0), counter) attachment.assertEmbraceHostedAttributesMatch(size = 0) } @Test fun `embrace hosted attachment at max size`() { - val attachment = Attachment.EmbraceHosted(ByteArray(LIMIT_MB), counter) + val attachment = EmbraceHosted(ByteArray(LIMIT_MB), counter) attachment.assertEmbraceHostedAttributesMatch(size = LIMIT_MB.toLong()) } @Test fun `embrace hosted attachment obeys max size constraints`() { val size = LIMIT_MB + 1 - val attachment = Attachment.EmbraceHosted(ByteArray(size), counter) - attachment.assertEmbraceHostedAttributesMatch(size = size.toLong(), errorCode = ATTACHMENT_TOO_LARGE) + val attachment = EmbraceHosted(ByteArray(size), counter) + attachment.assertEmbraceHostedAttributesMatch( + size = size.toLong(), + errorCode = ATTACHMENT_TOO_LARGE + ) } @Test fun `embrace hosted attachment exceeds session limit`() { - val smallCounter = AttachmentCounter(1) - val attachment = Attachment.EmbraceHosted(BYTES, smallCounter) + var limit = true + val smallCounter: () -> Boolean = { limit } + val attachment = EmbraceHosted(BYTES, smallCounter) attachment.assertEmbraceHostedAttributesMatch() val size = LIMIT_MB + 1L val bytes = ByteArray(size.toInt()) - val limitedAttachment = Attachment.EmbraceHosted(bytes, smallCounter) - limitedAttachment.assertEmbraceHostedAttributesMatch(size = size, errorCode = OVER_MAX_ATTACHMENTS) + limit = false + val limitedAttachment = EmbraceHosted(bytes, smallCounter) + limitedAttachment.assertEmbraceHostedAttributesMatch( + size = size, + errorCode = OVER_MAX_ATTACHMENTS + ) } @Test fun `create user hosted attachment`() { - val attachment = Attachment.UserHosted(SIZE, ID, URL, counter) + val attachment = UserHosted(ID, URL, counter) attachment.assertUserHostedAttributesMatch() } - @Test - fun `user hosted attachment empty size`() { - val size: Long = 0 - val attachment = Attachment.UserHosted(size, ID, URL, counter) - attachment.assertUserHostedAttributesMatch(size = size) - } - - @Test - fun `user hosted attachment invalid size`() { - val size: Long = -1 - val attachment = Attachment.UserHosted(size, ID, URL, counter) - attachment.assertUserHostedAttributesMatch(size = size, errorCode = UNKNOWN) - } - @Test fun `user hosted attachment invalid url`() { val url = "" - val attachment = Attachment.UserHosted(SIZE, ID, url, counter) + val attachment = UserHosted(ID, url, counter) attachment.assertUserHostedAttributesMatch(url = url, errorCode = UNKNOWN) } @Test fun `user hosted attachment invalid ID`() { val id = "my-id" - val attachment = Attachment.UserHosted(SIZE, id, URL, counter) + val attachment = UserHosted(id, URL, counter) attachment.assertUserHostedAttributesMatch(id = id, errorCode = UNKNOWN) } - @Test - fun `user hosted attachment has no max size constraints`() { - val size = 5000000L // 50MiB - val attachment = Attachment.UserHosted(size, ID, URL, counter) - attachment.assertUserHostedAttributesMatch(size = size) - } - @Test fun `user hosted attachment exceeds session limit`() { - val smallCounter = AttachmentCounter(1) - val attachment = Attachment.UserHosted(SIZE, ID, URL, smallCounter) + var limit = true + val smallCounter: () -> Boolean = { limit } + val attachment = UserHosted(ID, URL, smallCounter) attachment.assertUserHostedAttributesMatch() - val size = -1L - val limitedAttachment = Attachment.UserHosted(size, ID, URL, smallCounter) - limitedAttachment.assertUserHostedAttributesMatch(size = size, errorCode = OVER_MAX_ATTACHMENTS) + limit = false + val limitedAttachment = UserHosted(ID, URL, smallCounter) + limitedAttachment.assertUserHostedAttributesMatch( + errorCode = OVER_MAX_ATTACHMENTS + ) } - private fun Attachment.assertEmbraceHostedAttributesMatch( + private fun EmbraceHosted.assertEmbraceHostedAttributesMatch( size: Long = SIZE, errorCode: AttachmentErrorCode? = null, ) { - val observedId = checkNotNull(attributes[ATTR_KEY_ID]) + val observedId = checkNotNull(attributes[embAttachmentId]) assertNotNull(UUID.fromString(observedId)) - assertEquals(size, checkNotNull(attributes[ATTR_KEY_SIZE]).toLong()) - assertEquals(errorCode?.toString(), attributes[ATTR_KEY_ERR_CODE]) + assertEquals(size, checkNotNull(attributes[embAttachmentSize]).toLong()) + assertEquals(errorCode?.toString(), attributes[embAttachmentErrorCode]) + assertEquals(errorCode == null, shouldAttemptUpload()) } - private fun Attachment.assertUserHostedAttributesMatch( - size: Long = SIZE, + private fun UserHosted.assertUserHostedAttributesMatch( url: String = URL, id: String = ID, errorCode: AttachmentErrorCode? = null, ) { - assertEquals(size, checkNotNull(attributes[ATTR_KEY_SIZE]).toLong()) - assertEquals(id, checkNotNull(attributes[ATTR_KEY_ID])) - assertEquals(errorCode?.toString(), attributes[ATTR_KEY_ERR_CODE]) - assertEquals(url, checkNotNull(attributes[ATTR_KEY_URL])) + assertEquals(id, checkNotNull(attributes[embAttachmentId])) + assertEquals(errorCode?.toString(), attributes[embAttachmentErrorCode]) + assertEquals(url, checkNotNull(attributes[embAttachmentUrl])) } } diff --git a/embrace-android-core/src/test/java/io/embrace/android/embracesdk/internal/session/orchestrator/V2PayloadStoreTest.kt b/embrace-android-core/src/test/java/io/embrace/android/embracesdk/internal/session/orchestrator/V2PayloadStoreTest.kt index ffdc06e5c3..7bf22c0624 100644 --- a/embrace-android-core/src/test/java/io/embrace/android/embracesdk/internal/session/orchestrator/V2PayloadStoreTest.kt +++ b/embrace-android-core/src/test/java/io/embrace/android/embracesdk/internal/session/orchestrator/V2PayloadStoreTest.kt @@ -110,6 +110,17 @@ class V2PayloadStoreTest { assertEquals(System.NetworkCapturedRequest.value, getLastLogMetadata().payloadType.value) } + @Test + fun `test log attachment`() { + val envelope = Envelope(data = Pair("test", ByteArray(5))) + store.storeAttachment(envelope) + + val intake = intakeService.getIntakes>().single() + assertSame(envelope, intake.envelope) + assertEquals("p4_1692201601000_fakeuuid_fakeProcessId_true_attachment_v1.json", intake.metadata.filename) + assertEquals(0, intakeService.shutdownCount) + } + private fun storeLogWithType(type: TelemetryType) { val envelope = Envelope( data = LogPayload( diff --git a/embrace-android-delivery/src/main/kotlin/io/embrace/android/embracesdk/internal/delivery/PayloadType.kt b/embrace-android-delivery/src/main/kotlin/io/embrace/android/embracesdk/internal/delivery/PayloadType.kt index 063327e632..a27eb47a2f 100644 --- a/embrace-android-delivery/src/main/kotlin/io/embrace/android/embracesdk/internal/delivery/PayloadType.kt +++ b/embrace-android-delivery/src/main/kotlin/io/embrace/android/embracesdk/internal/delivery/PayloadType.kt @@ -2,54 +2,29 @@ package io.embrace.android.embracesdk.internal.delivery enum class PayloadType( val value: String, + val filenameComponent: String, ) { - SESSION("ux.session"), - LOG("sys.log"), - CRASH("sys.android.crash"), - NATIVE_CRASH("sys.android.native_crash"), - REACT_NATIVE_CRASH("sys.android.react_native_crash"), - FLUTTER_EXCEPTION("sys.flutter_exception"), - AEI("sys.exit"), - EXCEPTION("sys.exception"), - NETWORK_CAPTURE("sys.network_capture"), - INTERNAL_ERROR("sys.internal"), - UNKNOWN("unknown"); + SESSION("ux.session", "session"), + LOG("sys.log", "log"), + CRASH("sys.android.crash", "crash"), + NATIVE_CRASH("sys.android.native_crash", "native"), + REACT_NATIVE_CRASH("sys.android.react_native_crash", "react"), + FLUTTER_EXCEPTION("sys.flutter_exception", "flutter"), + AEI("sys.exit", "aei"), + EXCEPTION("sys.exception", "exception"), + NETWORK_CAPTURE("sys.network_capture", "network"), + INTERNAL_ERROR("sys.internal", "internal"), + ATTACHMENT("attachment", "attachment"), + UNKNOWN("unknown", "unknown"); companion object { + + private val filenameMap = PayloadType.values().associateBy { it.filenameComponent } + fun fromValue(value: String?): PayloadType { return values().firstOrNull { it.value == value } ?: UNKNOWN } - fun toFilenamePart(payloadType: PayloadType): String { - return when (payloadType) { - SESSION -> "session" - CRASH -> "crash" - LOG -> "log" - NATIVE_CRASH -> "native" - REACT_NATIVE_CRASH -> "react" - FLUTTER_EXCEPTION -> "flutter" - AEI -> "aei" - EXCEPTION -> "exception" - NETWORK_CAPTURE -> "network" - INTERNAL_ERROR -> "internal" - else -> "unknown" - } - } - - fun fromFilenameComponent(component: String): PayloadType { - return when (component) { - "session" -> SESSION - "crash" -> CRASH - "log" -> LOG - "native" -> NATIVE_CRASH - "react" -> REACT_NATIVE_CRASH - "flutter" -> FLUTTER_EXCEPTION - "aei" -> AEI - "exception" -> EXCEPTION - "network" -> NETWORK_CAPTURE - "internal" -> INTERNAL_ERROR - else -> UNKNOWN - } - } + fun fromFilenameComponent(component: String): PayloadType = filenameMap[component] ?: UNKNOWN } } diff --git a/embrace-android-delivery/src/main/kotlin/io/embrace/android/embracesdk/internal/delivery/StoredTelemetryMetadata.kt b/embrace-android-delivery/src/main/kotlin/io/embrace/android/embracesdk/internal/delivery/StoredTelemetryMetadata.kt index a1e22cf844..f9edcfcacd 100644 --- a/embrace-android-delivery/src/main/kotlin/io/embrace/android/embracesdk/internal/delivery/StoredTelemetryMetadata.kt +++ b/embrace-android-delivery/src/main/kotlin/io/embrace/android/embracesdk/internal/delivery/StoredTelemetryMetadata.kt @@ -1,6 +1,5 @@ package io.embrace.android.embracesdk.internal.delivery -import io.embrace.android.embracesdk.internal.delivery.PayloadType.Companion.toFilenamePart import kotlin.Result.Companion.failure /** @@ -17,9 +16,7 @@ data class StoredTelemetryMetadata( val payloadType: PayloadType = PayloadType.UNKNOWN, ) { val filename: String = "${envelopeType.priority}_${timestamp}_${uuid}_${processId}_${complete}_${ - toFilenamePart( - payloadType - ) + payloadType.filenameComponent }_v1.json" companion object { diff --git a/embrace-android-delivery/src/main/kotlin/io/embrace/android/embracesdk/internal/delivery/SupportedEnvelopeType.kt b/embrace-android-delivery/src/main/kotlin/io/embrace/android/embracesdk/internal/delivery/SupportedEnvelopeType.kt index dd7217dc89..7f753468e3 100644 --- a/embrace-android-delivery/src/main/kotlin/io/embrace/android/embracesdk/internal/delivery/SupportedEnvelopeType.kt +++ b/embrace-android-delivery/src/main/kotlin/io/embrace/android/embracesdk/internal/delivery/SupportedEnvelopeType.kt @@ -8,13 +8,14 @@ import java.lang.reflect.Type * Enumerates the different types of telemetry that are supported when persisting to disk. */ enum class SupportedEnvelopeType( - val serializedType: Type, + val serializedType: Type?, val priority: String, val endpoint: Endpoint, ) { CRASH(Envelope.logEnvelopeType, "p1", Endpoint.LOGS), SESSION(Envelope.sessionEnvelopeType, "p3", Endpoint.SESSIONS), + ATTACHMENT(null, "p4", Endpoint.ATTACHMENTS), LOG(Envelope.logEnvelopeType, "p5", Endpoint.LOGS), BLOB(Envelope.logEnvelopeType, "p7", Endpoint.LOGS); diff --git a/embrace-android-delivery/src/main/kotlin/io/embrace/android/embracesdk/internal/delivery/execution/OkHttpRequestExecutionService.kt b/embrace-android-delivery/src/main/kotlin/io/embrace/android/embracesdk/internal/delivery/execution/OkHttpRequestExecutionService.kt index 04f7d90f24..56c29dfc07 100644 --- a/embrace-android-delivery/src/main/kotlin/io/embrace/android/embracesdk/internal/delivery/execution/OkHttpRequestExecutionService.kt +++ b/embrace-android-delivery/src/main/kotlin/io/embrace/android/embracesdk/internal/delivery/execution/OkHttpRequestExecutionService.kt @@ -5,18 +5,22 @@ import io.embrace.android.embracesdk.internal.comms.api.Endpoint import io.embrace.android.embracesdk.internal.delivery.SupportedEnvelopeType import io.embrace.android.embracesdk.internal.delivery.debug.DeliveryTracer import io.embrace.android.embracesdk.internal.delivery.execution.ExecutionResult.Companion.getResult +import io.embrace.android.embracesdk.internal.delivery.storage.loadAttachment import io.embrace.android.embracesdk.internal.logging.EmbLogger import io.embrace.android.embracesdk.internal.logging.InternalErrorType import okhttp3.Headers.Companion.toHeaders import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody import okio.BufferedSink import okio.buffer import okio.source import java.io.IOException import java.io.InputStream +import java.util.zip.GZIPInputStream class OkHttpRequestExecutionService( private val okHttpClient: OkHttpClient, @@ -28,23 +32,21 @@ class OkHttpRequestExecutionService( private val deliveryTracer: DeliveryTracer? = null, ) : RequestExecutionService { + private companion object { + private val mediaType = "application/json".toMediaType() + } + override fun attemptHttpRequest( payloadStream: () -> InputStream, envelopeType: SupportedEnvelopeType, payloadType: String, ): ExecutionResult { - val apiRequest = envelopeType.endpoint.getApiRequestFromEndpoint() - val requestBody = ApiRequestBody(payloadStream) - val request = Request.Builder() - .url(apiRequest.url) - .headers( - apiRequest - .getHeaders() - .plus("X-EM-TYPES" to payloadType) - .toHeaders() - ) - .post(requestBody) - .build() + val multipart = envelopeType.endpoint == Endpoint.ATTACHMENTS + val apiRequest = envelopeType.endpoint.getApiRequestFromEndpoint(multipart) + val request = when { + multipart -> prepareMultipartRequest(payloadStream, apiRequest) + else -> prepareRequest(payloadStream, apiRequest, payloadType) + } var executionError: Throwable? = null val httpCallResponse = try { @@ -72,26 +74,67 @@ class OkHttpRequestExecutionService( } } - private fun Endpoint.getApiRequestFromEndpoint(): ApiRequestV2 = ApiRequestV2( + private fun prepareRequest( + payloadStream: () -> InputStream, + apiRequest: ApiRequestV2, + payloadType: String, + ): Request { + val request = Request.Builder() + .url(apiRequest.url) + .headers( + apiRequest + .getHeaders() + .plus("X-EM-TYPES" to payloadType) + .toHeaders() + ) + .post(ApiRequestBody(payloadStream)) + .build() + return request + } + + private fun prepareMultipartRequest( + payloadStream: () -> InputStream, + apiRequest: ApiRequestV2, + ): Request { + GZIPInputStream(payloadStream()).use { + val attachment = loadAttachment(it) ?: throw IOException("Failed to load attachment") + val builder = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("app_id", checkNotNull(apiRequest.appId)) + .addFormDataPart("attachment_id", attachment.second) + .addFormDataPart("file", "file", attachment.first.toRequestBody()) + + val request = Request.Builder() + .url(apiRequest.url) + .post(builder.build()) + .build() + return request + } + } + + private fun Endpoint.getApiRequestFromEndpoint(multipart: Boolean): ApiRequestV2 = ApiRequestV2( url = "$coreBaseUrl${this.path}", appId = appId, deviceId = lazyDeviceId.value, - contentEncoding = "gzip", + contentEncoding = when { + multipart -> null + else -> "gzip" + }, + contentType = when { + multipart -> "multipart/form-data" + else -> "application/json" + }, userAgent = "Embrace/a/$embraceVersionName" ) - private companion object { - private val mediaType = "application/json".toMediaType() - - class ApiRequestBody( - private val payloadStream: () -> InputStream, - ) : RequestBody() { - override fun contentType() = mediaType + class ApiRequestBody( + private val payloadStream: () -> InputStream, + ) : RequestBody() { + override fun contentType() = mediaType - override fun writeTo(sink: BufferedSink) { - payloadStream().source().buffer().use { source -> - sink.writeAll(source) - } + override fun writeTo(sink: BufferedSink) { + payloadStream().source().buffer().use { source -> + sink.writeAll(source) } } } diff --git a/embrace-android-delivery/src/main/kotlin/io/embrace/android/embracesdk/internal/delivery/intake/IntakeServiceImpl.kt b/embrace-android-delivery/src/main/kotlin/io/embrace/android/embracesdk/internal/delivery/intake/IntakeServiceImpl.kt index 75cf8cb781..e100e08ef8 100644 --- a/embrace-android-delivery/src/main/kotlin/io/embrace/android/embracesdk/internal/delivery/intake/IntakeServiceImpl.kt +++ b/embrace-android-delivery/src/main/kotlin/io/embrace/android/embracesdk/internal/delivery/intake/IntakeServiceImpl.kt @@ -5,6 +5,7 @@ import io.embrace.android.embracesdk.internal.delivery.SupportedEnvelopeType import io.embrace.android.embracesdk.internal.delivery.debug.DeliveryTracer import io.embrace.android.embracesdk.internal.delivery.scheduling.SchedulingService import io.embrace.android.embracesdk.internal.delivery.storage.PayloadStorageService +import io.embrace.android.embracesdk.internal.delivery.storage.storeAttachment import io.embrace.android.embracesdk.internal.logging.EmbLogger import io.embrace.android.embracesdk.internal.logging.InternalErrorType import io.embrace.android.embracesdk.internal.payload.Envelope @@ -45,6 +46,7 @@ class IntakeServiceImpl( } } + @Suppress("UNCHECKED_CAST") private fun processIntake( intake: Envelope<*>, metadata: StoredTelemetryMetadata, @@ -55,7 +57,12 @@ class IntakeServiceImpl( else -> cacheStorageService } service.store(metadata) { stream -> - serializer.toJson(intake, metadata.envelopeType.serializedType, stream) + if (metadata.envelopeType.serializedType != null) { + serializer.toJson(intake, metadata.envelopeType.serializedType, stream) + } else { // payload doesn't require serialization + val pair = intake.data as Pair + storeAttachment(stream, pair.second, pair.first) + } } val lastReference = cacheReferences[metadata.envelopeType] diff --git a/embrace-android-delivery/src/main/kotlin/io/embrace/android/embracesdk/internal/delivery/storage/AttachmentStorage.kt b/embrace-android-delivery/src/main/kotlin/io/embrace/android/embracesdk/internal/delivery/storage/AttachmentStorage.kt new file mode 100644 index 0000000000..e32e533568 --- /dev/null +++ b/embrace-android-delivery/src/main/kotlin/io/embrace/android/embracesdk/internal/delivery/storage/AttachmentStorage.kt @@ -0,0 +1,22 @@ +package io.embrace.android.embracesdk.internal.delivery.storage + +import java.io.InputStream +import java.io.OutputStream + +fun storeAttachment(stream: OutputStream, attachment: ByteArray, id: String) { + stream.use { + it.write(id.toByteArray()) + it.write("\n".toByteArray()) + it.write(attachment) + } +} + +fun loadAttachment(stream: InputStream): Pair? { + stream.use { + val contents = it.readBytes() + val start = contents.indexOfFirst { byte -> byte == '\n'.code.toByte() } + val id = String(contents.sliceArray(0 until start)) + val attachment = contents.sliceArray(start + 1 until contents.size) + return Pair(attachment, id) + } +} diff --git a/embrace-android-delivery/src/main/kotlin/io/embrace/android/embracesdk/internal/delivery/storage/CachedLogEnvelopeStoreImpl.kt b/embrace-android-delivery/src/main/kotlin/io/embrace/android/embracesdk/internal/delivery/storage/CachedLogEnvelopeStoreImpl.kt index a21795d6b5..f2150a0097 100644 --- a/embrace-android-delivery/src/main/kotlin/io/embrace/android/embracesdk/internal/delivery/storage/CachedLogEnvelopeStoreImpl.kt +++ b/embrace-android-delivery/src/main/kotlin/io/embrace/android/embracesdk/internal/delivery/storage/CachedLogEnvelopeStoreImpl.kt @@ -37,7 +37,7 @@ class CachedLogEnvelopeStoreImpl( resource, metadata ), - storedTelemetryMetadata.envelopeType.serializedType, + checkNotNull(storedTelemetryMetadata.envelopeType.serializedType), stream ) } @@ -48,7 +48,7 @@ class CachedLogEnvelopeStoreImpl( fileStorageService.loadPayloadAsStream(storedTelemetryMetadata)?.let { inputStream -> serializer.fromJson>( inputStream = inputStream, - type = storedTelemetryMetadata.envelopeType.serializedType + type = checkNotNull(storedTelemetryMetadata.envelopeType.serializedType) ) } }.getOrNull() diff --git a/embrace-android-delivery/src/test/kotlin/io/embrace/android/embracesdk/internal/delivery/intake/IntakeServiceImplTest.kt b/embrace-android-delivery/src/test/kotlin/io/embrace/android/embracesdk/internal/delivery/intake/IntakeServiceImplTest.kt index 2a48669b53..192c4d44cc 100644 --- a/embrace-android-delivery/src/test/kotlin/io/embrace/android/embracesdk/internal/delivery/intake/IntakeServiceImplTest.kt +++ b/embrace-android-delivery/src/test/kotlin/io/embrace/android/embracesdk/internal/delivery/intake/IntakeServiceImplTest.kt @@ -8,6 +8,7 @@ import io.embrace.android.embracesdk.fakes.FakeSchedulingService import io.embrace.android.embracesdk.fakes.TestPlatformSerializer import io.embrace.android.embracesdk.internal.delivery.PayloadType import io.embrace.android.embracesdk.internal.delivery.StoredTelemetryMetadata +import io.embrace.android.embracesdk.internal.delivery.SupportedEnvelopeType.ATTACHMENT import io.embrace.android.embracesdk.internal.delivery.SupportedEnvelopeType.BLOB import io.embrace.android.embracesdk.internal.delivery.SupportedEnvelopeType.CRASH import io.embrace.android.embracesdk.internal.delivery.SupportedEnvelopeType.LOG @@ -25,6 +26,7 @@ import io.embrace.android.embracesdk.internal.worker.PriorityWorker import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -57,6 +59,9 @@ class IntakeServiceImplTest { private val logEnvelope = Envelope( data = LogPayload(logs = listOf(Log(body = "Log data"))) ) + private val attachmentEnvelope = Envelope( + data = Pair("my-id", ByteArray(2)) + ) private val sessionDataExpected = run { val baos = ByteArrayOutputStream() serializer.toJson(sessionEnvelope, Envelope.sessionEnvelopeType, GZIPOutputStream(baos)) @@ -70,11 +75,15 @@ class IntakeServiceImplTest { private val clock = FakeClock() private val sessionMetadata = StoredTelemetryMetadata(clock.now(), UUID, PROCESS_ID, SESSION) - private val logMetadata = StoredTelemetryMetadata(clock.now(), UUID, PROCESS_ID, LOG) + private val attachmentMetadata = + StoredTelemetryMetadata(clock.now(), UUID, PROCESS_ID, ATTACHMENT, payloadType = PayloadType.ATTACHMENT) + private val logMetadata = StoredTelemetryMetadata(clock.now(), UUID, PROCESS_ID, LOG, payloadType = PayloadType.LOG) private val networkMetadata = StoredTelemetryMetadata(clock.now(), UUID, PROCESS_ID, BLOB) private val crashMetadata = StoredTelemetryMetadata(clock.now(), UUID, PROCESS_ID, CRASH) private val sessionMetadata2 = StoredTelemetryMetadata(clock.apply { tick(100L) }.now(), UUID, PROCESS_ID, SESSION) private val logMetadata2 = StoredTelemetryMetadata(clock.apply { tick(100L) }.now(), UUID, PROCESS_ID, LOG) + private val attachmentMetadata2 = + StoredTelemetryMetadata(clock.apply { tick(100L) }.now(), UUID, PROCESS_ID, ATTACHMENT) private val networkMetadata2 = StoredTelemetryMetadata(clock.apply { tick(100L) }.now(), UUID, PROCESS_ID, BLOB) private val crashMetadata2 = StoredTelemetryMetadata(clock.apply { tick(100L) }.now(), UUID, PROCESS_ID, CRASH) @@ -137,6 +146,24 @@ class IntakeServiceImplTest { assertEquals(1, schedulingService.payloadIntakeCount) } + @Test + fun `take attachment`() { + intakeService.take(attachmentEnvelope, attachmentMetadata) + executorService.runCurrentlyBlocked() + + // assert filename is valid & contains correct metadata + val filename = payloadStorageService.storedFilenames().single() + val metadata = StoredTelemetryMetadata.fromFilename(filename).getOrThrow() + assertEquals(ATTACHMENT, metadata.envelopeType) + + // assert payload was stored + val obj = payloadStorageService.storedPayloads().single() + assertNotNull(obj) + + // assert scheduling service was notified + assertEquals(1, schedulingService.payloadIntakeCount) + } + @Test fun `take session`() { intakeService.take(sessionEnvelope, sessionMetadata) @@ -231,8 +258,10 @@ class IntakeServiceImplTest { take(sessionEnvelope, sessionMetadata) take(logEnvelope, crashMetadata) take(logEnvelope, networkMetadata) + take(attachmentEnvelope, attachmentMetadata) take(logEnvelope, logMetadata) clock.tick(1000) + take(attachmentEnvelope, attachmentMetadata2) take(logEnvelope, logMetadata2) take(logEnvelope, networkMetadata2) take(logEnvelope, crashMetadata2) @@ -242,15 +271,15 @@ class IntakeServiceImplTest { latch.countDown() } worker.shutdownAndWait(1000) - assertEquals(8, payloadStorageService.storedPayloadCount()) - assertEquals(8, payloadStorageService.storeCount.get()) + assertEquals(10, payloadStorageService.storedPayloadCount()) + assertEquals(10, payloadStorageService.storeCount.get()) // assert payloads were prioritised in the expected order val observedTypes = payloadStorageService.storedFilenames().map { val metadata = StoredTelemetryMetadata.fromFilename(it).getOrThrow() metadata.envelopeType } - val expected = listOf(CRASH, CRASH, SESSION, SESSION, LOG, LOG, BLOB, BLOB) + val expected = listOf(CRASH, CRASH, SESSION, SESSION, ATTACHMENT, ATTACHMENT, LOG, LOG, BLOB, BLOB) assertEquals(expected, observedTypes) } diff --git a/embrace-android-payload/src/main/kotlin/io/embrace/android/embracesdk/internal/comms/api/Endpoint.kt b/embrace-android-payload/src/main/kotlin/io/embrace/android/embracesdk/internal/comms/api/Endpoint.kt index a3bf5eb020..9431b6c9a5 100644 --- a/embrace-android-payload/src/main/kotlin/io/embrace/android/embracesdk/internal/comms/api/Endpoint.kt +++ b/embrace-android-payload/src/main/kotlin/io/embrace/android/embracesdk/internal/comms/api/Endpoint.kt @@ -10,6 +10,6 @@ enum class Endpoint( LOGS("logs", "v2"), SESSIONS("spans", "v2"), CONFIG("config", "v2"), - ATTACHMENT("attachment", "v2"), + ATTACHMENTS("attachments", "v2"), UNKNOWN("unknown", "v1"), } diff --git a/embrace-android-sdk/api/embrace-android-sdk.api b/embrace-android-sdk/api/embrace-android-sdk.api index f57ba981a0..7edf19a6b5 100644 --- a/embrace-android-sdk/api/embrace-android-sdk.api +++ b/embrace-android-sdk/api/embrace-android-sdk.api @@ -45,6 +45,8 @@ public final class io/embrace/android/embracesdk/Embrace : io/embrace/android/em public fun logInfo (Ljava/lang/String;)V public fun logMessage (Ljava/lang/String;Lio/embrace/android/embracesdk/Severity;)V public fun logMessage (Ljava/lang/String;Lio/embrace/android/embracesdk/Severity;Ljava/util/Map;)V + public fun logMessage (Ljava/lang/String;Lio/embrace/android/embracesdk/Severity;Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;)V + public fun logMessage (Ljava/lang/String;Lio/embrace/android/embracesdk/Severity;Ljava/util/Map;[B)V public fun logPushNotification (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Boolean;Ljava/lang/Boolean;)V public fun logWarning (Ljava/lang/String;)V public fun logWebView (Ljava/lang/String;)V diff --git a/embrace-android-sdk/build.gradle.kts b/embrace-android-sdk/build.gradle.kts index 1ba689292c..57e71fba43 100644 --- a/embrace-android-sdk/build.gradle.kts +++ b/embrace-android-sdk/build.gradle.kts @@ -82,6 +82,7 @@ dependencies { testImplementation(libs.protobuf.java) testImplementation(libs.protobuf.java.util) testImplementation(libs.kotlin.reflect) + testImplementation(libs.mockwebserver) androidTestImplementation(project(":embrace-test-fakes")) } diff --git a/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testcases/features/FileAttachmentFeatureTest.kt b/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testcases/features/FileAttachmentFeatureTest.kt new file mode 100644 index 0000000000..6138607f2a --- /dev/null +++ b/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testcases/features/FileAttachmentFeatureTest.kt @@ -0,0 +1,278 @@ +package io.embrace.android.embracesdk.testcases.features + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.embrace.android.embracesdk.Severity +import io.embrace.android.embracesdk.internal.payload.Log +import io.embrace.android.embracesdk.internal.spans.findAttributeValue +import io.embrace.android.embracesdk.testframework.IntegrationTestRule +import io.embrace.android.embracesdk.testframework.actions.EmbraceActionInterface +import io.embrace.android.embracesdk.testframework.assertions.assertOtelLogReceived +import io.embrace.android.embracesdk.testframework.assertions.getLastLog +import io.embrace.android.embracesdk.testframework.assertions.getOtelSeverity +import io.embrace.android.embracesdk.testframework.server.FormPart +import java.util.UUID +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +internal class FileAttachmentFeatureTest { + + private companion object { + private const val ATTR_KEY_SIZE = "emb.attachment_size" + private const val ATTR_KEY_URL = "emb.attachment_url" + private const val ATTR_KEY_ID = "emb.attachment_id" + private const val ATTR_KEY_ERR_CODE = "emb.attachment_error_code" + } + + private val attachmentId = UUID.randomUUID() + + @Rule + @JvmField + val testRule: IntegrationTestRule = IntegrationTestRule() + + @Test + fun `log message with user hosted file attachment`() { + val url = "https://example.com/my-file.txt" + val id = attachmentId.toString() + testRule.runTest(testCaseAction = { + recordSession { + logWithUserHostedAttachment(id, url) + } + }, assertAction = { + val log = getSingleLogEnvelope().getLastLog() + assertUserHostedLogSent(log, url, id) + assertNull(log.attributes?.findAttributeValue(ATTR_KEY_ERR_CODE)) + }) + } + + @Test + fun `user hosted file attachment exceeding session limit`() { + val url = "https://example.com/another-file.txt" + val id = attachmentId.toString() + val limit = 5 + testRule.runTest(testCaseAction = { + repeat(2) { + recordSession { + repeat(limit + 1) { + logWithUserHostedAttachment(id, url) + } + } + } + }, assertAction = { + val logs = getLogEnvelopes(2).flatMap { checkNotNull(it.data.logs) } + assertEquals((limit * 2) + 2, logs.size) + + logs.forEachIndexed { k, log -> + assertUserHostedLogSent(log, url, id) + + val errCode = log.attributes?.findAttributeValue(ATTR_KEY_ERR_CODE) + val expectedCode = when (k) { + limit, limit + limit + 1 -> "OVER_MAX_ATTACHMENTS" + else -> null + } + assertEquals(expectedCode, errCode) + } + }) + } + + @Test + fun `log message with embrace hosted file attachment`() { + val byteArray = "Hello, world!".toByteArray() + testRule.runTest(testCaseAction = { + recordSession { + logWithEmbraceHostedAttachment(byteArray) + } + }, assertAction = { + val parts = getSingleAttachment() + verifyAttachment(parts, byteArray) + + val log = getSingleLogEnvelope().getLastLog() + assertEmbraceHostedLogSent(log, byteArray.size.toLong()) + }) + } + + @Test + fun `embrace hosted file attachment exceeding session limit`() { + val byteArray = ByteArray(1024 * 16) + val limit = 5 + testRule.runTest(testCaseAction = { + repeat(2) { + recordSession { + repeat(limit + 1) { + logWithEmbraceHostedAttachment(byteArray) + } + } + } + }, assertAction = { + val logs = getLogEnvelopes(2).flatMap { checkNotNull(it.data.logs) } + val logSize = (limit * 2) + 2 + assertEquals(logSize, logs.size) + + logs.forEachIndexed { k, log -> + assertEmbraceHostedLogSent(log, byteArray.size.toLong()) + + val errCode = log.attributes?.findAttributeValue(ATTR_KEY_ERR_CODE) + val expectedCode = when (k) { + limit, limit + limit + 1 -> "OVER_MAX_ATTACHMENTS" + else -> null + } + assertEquals(expectedCode, errCode) + } + + val attachments = getAttachments(10) + attachments.forEach { parts -> + verifyAttachment(parts, byteArray) + } + }) + } + + @Test + fun `embrace hosted file attachment equalling size limit`() { + val byteArray = ByteArray(1024 * 1024) + testRule.runTest(testCaseAction = { + recordSession { + logWithEmbraceHostedAttachment(byteArray) + } + }, assertAction = { + val parts = getSingleAttachment() + verifyAttachment(parts, byteArray) + + val log = getSingleLogEnvelope().getLastLog() + assertEmbraceHostedLogSent(log, byteArray.size.toLong()) + }) + } + + @Test + fun `embrace hosted file attachment exceeding size limit`() { + val byteArray = ByteArray(2 * 1024 * 1024) + testRule.runTest(testCaseAction = { + recordSession { + logWithEmbraceHostedAttachment(byteArray) + } + }, assertAction = { + val log = getSingleLogEnvelope().getLastLog() + assertEmbraceHostedLogSent(log, byteArray.size.toLong()) + assertTrue(getAttachments(0).isEmpty()) + }) + } + + @Test + fun `embrace multiple file attachments`() { + val a = "apple".toByteArray() + val b = "banana".toByteArray() + val c = "cherry".toByteArray() + + testRule.runTest(testCaseAction = { + recordSession { + logWithEmbraceHostedAttachment(a) + logWithEmbraceHostedAttachment(b) + logWithEmbraceHostedAttachment(c) + } + }, assertAction = { + val expectedSize = 3 + val logs = getLogEnvelopes(1).flatMap { checkNotNull(it.data.logs) } + assertEquals(expectedSize, logs.size) + + val attachments = getAttachments(expectedSize) + assertEquals(expectedSize, attachments.size) + + val aId = logs[0].attributes?.findAttributeValue(ATTR_KEY_ID) + val bId = logs[1].attributes?.findAttributeValue(ATTR_KEY_ID) + val cId = logs[2].attributes?.findAttributeValue(ATTR_KEY_ID) + + val attachmentA = attachments.single { it[1].data == aId } + val attachmentB = attachments.single { it[1].data == bId } + val attachmentC = attachments.single { it[1].data == cId } + + verifyAttachment(attachmentA, a) + verifyAttachment(attachmentB, b) + verifyAttachment(attachmentC, c) + + val uniqueIds = logs.distinctBy { it.attributes?.findAttributeValue(ATTR_KEY_ID) } + assertEquals(expectedSize, uniqueIds.size) + }) + } + + private fun verifyAttachment( + parts: List, + byteArray: ByteArray, + appId: String = "abcde", + ) { + assertEquals("form-data; name=\"app_id\"", parts[0].contentDisposition) + assertEquals(appId, parts[0].data) + assertEquals("form-data; name=\"attachment_id\"", parts[1].contentDisposition) + checkNotNull(UUID.fromString(parts[1].data)) + assertEquals( + "form-data; name=\"file\"; filename=\"file\"", parts[2].contentDisposition + ) + assertEquals(String(byteArray), parts[2].data) + } + + private fun assertUserHostedLogSent( + log: Log, + url: String, + id: String, + ) { + assertOtelLogReceived( + logReceived = log, + expectedMessage = "test message", + expectedSeverityNumber = getOtelSeverity(Severity.INFO).severityNumber, + expectedSeverityText = Severity.INFO.name, + expectedState = "foreground", + expectedTimeMs = null, + expectedProperties = mapOf( + "key" to "value", + ATTR_KEY_URL to url, + ATTR_KEY_ID to id, + ), + ) + } + + private fun assertEmbraceHostedLogSent( + log: Log, + size: Long, + ) { + assertOtelLogReceived( + logReceived = log, + expectedMessage = "test message", + expectedSeverityNumber = getOtelSeverity(Severity.INFO).severityNumber, + expectedSeverityText = Severity.INFO.name, + expectedState = "foreground", + expectedTimeMs = null, + expectedProperties = mapOf( + "key" to "value", + ATTR_KEY_SIZE to size.toString(), + ), + ) + val id = log.attributes?.findAttributeValue(ATTR_KEY_ID) + checkNotNull(UUID.fromString(id)) + } + + private fun EmbraceActionInterface.logWithUserHostedAttachment( + id: String, + url: String, + ) { + embrace.logMessage( + message = "test message", + severity = Severity.INFO, + properties = mapOf("key" to "value"), + attachmentId = id, + attachmentUrl = url, + ) + } + + private fun EmbraceActionInterface.logWithEmbraceHostedAttachment( + byteArray: ByteArray, + ) { + embrace.logMessage( + message = "test message", + severity = Severity.INFO, + properties = mapOf("key" to "value"), + attachment = byteArray + ) + } +} diff --git a/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testcases/features/EmbraceLoggingFeatureTest.kt b/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testcases/features/LogFeatureTest.kt similarity index 97% rename from embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testcases/features/EmbraceLoggingFeatureTest.kt rename to embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testcases/features/LogFeatureTest.kt index 70666ce0de..87006eba6c 100644 --- a/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testcases/features/EmbraceLoggingFeatureTest.kt +++ b/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testcases/features/LogFeatureTest.kt @@ -16,12 +16,13 @@ import io.embrace.android.embracesdk.testframework.IntegrationTestRule import io.embrace.android.embracesdk.testframework.actions.EmbraceSetupInterface import io.embrace.android.embracesdk.testframework.assertions.assertOtelLogReceived import io.embrace.android.embracesdk.testframework.assertions.getLastLog +import io.embrace.android.embracesdk.testframework.assertions.getOtelSeverity import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) -internal class EmbraceLoggingFeatureTest { +internal class LogFeatureTest { private val instrumentedConfig = FakeInstrumentedConfig(enabledFeatures = FakeEnabledFeatureConfig(bgActivityCapture = true)) @@ -399,14 +400,6 @@ internal class EmbraceLoggingFeatureTest { logOrchestrator.flush(false) } - private fun getOtelSeverity(severity: Severity): io.opentelemetry.api.logs.Severity { - return when (severity) { - Severity.INFO -> io.opentelemetry.api.logs.Severity.INFO - Severity.WARNING -> io.opentelemetry.api.logs.Severity.WARN - Severity.ERROR -> io.opentelemetry.api.logs.Severity.ERROR - } - } - private fun getEmbraceSeverity(severityNumber: Int): Severity { return when (severityNumber) { io.opentelemetry.api.logs.Severity.INFO.severityNumber -> Severity.INFO diff --git a/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testcases/session/PeriodicSessionCacheTest.kt b/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testcases/session/PeriodicSessionCacheTest.kt index 72bd16ed25..83507ea4b5 100644 --- a/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testcases/session/PeriodicSessionCacheTest.kt +++ b/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testcases/session/PeriodicSessionCacheTest.kt @@ -103,7 +103,7 @@ internal class PeriodicSessionCacheTest { val inputStream = checkNotNull(cacheStorageService.loadPayloadAsStream(metadata)) return TestPlatformSerializer().fromJson( inputStream, - SupportedEnvelopeType.SESSION.serializedType + checkNotNull(SupportedEnvelopeType.SESSION.serializedType) ) } diff --git a/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testcases/session/StatefulSessionTest.kt b/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testcases/session/StatefulSessionTest.kt index dff1bb83ba..837c223f3d 100644 --- a/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testcases/session/StatefulSessionTest.kt +++ b/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testcases/session/StatefulSessionTest.kt @@ -41,7 +41,7 @@ internal class StatefulSessionTest { }, assertAction = { // verify first session - val messages = getSessionEnvelopesFromMockServer(2) + val messages = getSessionEnvelopes(2) val first = messages[0] first.findSessionSpan().attributes?.assertMatches(mapOf( embSessionStartType.name to LifeEventType.STATE.name.lowercase(Locale.ENGLISH), diff --git a/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testframework/actions/EmbracePayloadAssertionInterface.kt b/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testframework/actions/EmbracePayloadAssertionInterface.kt index 344838f60a..915cc95bb1 100644 --- a/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testframework/actions/EmbracePayloadAssertionInterface.kt +++ b/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testframework/actions/EmbracePayloadAssertionInterface.kt @@ -26,16 +26,17 @@ import io.embrace.android.embracesdk.internal.spans.findAttributeValue import io.embrace.android.embracesdk.testframework.assertions.JsonComparator import io.embrace.android.embracesdk.testframework.assertions.assertMatches import io.embrace.android.embracesdk.testframework.server.FakeApiServer +import io.embrace.android.embracesdk.testframework.server.FormPart +import java.io.File +import java.io.IOException +import java.util.Locale +import java.util.concurrent.TimeoutException import org.json.JSONObject import org.junit.Assert import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull -import java.io.File -import java.io.IOException -import java.util.Locale -import java.util.concurrent.TimeoutException /** * Provides assertions that can be used in integration tests to validate the behavior of the SDK, @@ -64,20 +65,15 @@ internal class EmbracePayloadAssertionInterface( * it will wait a maximum of 1 second for the number of payloads that exist to equal * to that before returning, timing out if it doesn't. */ - internal fun getLogEnvelopes(expectedSize: Int): List> { - return retrieveLogEnvelopes(expectedSize) - } - - internal fun getSingleLogEnvelope(): Envelope { - return getLogEnvelopes(1).single() - } + internal fun getLogEnvelopes(expectedSize: Int) = retrieveLogEnvelopes(expectedSize) + internal fun getSingleLogEnvelope() = getLogEnvelopes(1).single() private fun retrieveLogEnvelopes( expectedSize: Int, ): List> { val supplier = { checkNotNull(apiServer).getLogEnvelopes() } try { - return retrievePayload(expectedSize, supplier) + return retrievePayload(expectedSize = expectedSize, supplier = supplier) } catch (exc: TimeoutException) { val envelopes: List> = supplier().map { envelope -> mapOf( @@ -119,20 +115,25 @@ internal class EmbracePayloadAssertionInterface( } - /*** SESSIONS ***/ + /*** ATTACHMENTS ***/ - /** - * Returns a list of sessions that were completed by the SDK & sent to a mock web server. - */ - internal fun getSessionEnvelopesFromMockServer( + internal fun getAttachments(expectedSize: Int) = retrieveAttachments(expectedSize) + internal fun getSingleAttachment() = getAttachments(1).single() + + private fun retrieveAttachments( expectedSize: Int, - state: ApplicationState = ApplicationState.FOREGROUND, - ): List> { - return retrievePayload(expectedSize) { - checkNotNull(apiServer).getSessionEnvelopes().filter { it.findAppState() == state } + ): List> { + val supplier = { checkNotNull(apiServer).getAttachments() } + try { + return retrievePayload(expectedSize = expectedSize, supplier = supplier) + } catch (exc: TimeoutException) { + throwPayloadErrMsg(expectedSize, supplier().size, emptyList(), exc) } } + + /*** SESSIONS ***/ + /** * Returns a list of sessions that were completed by the SDK. */ @@ -191,7 +192,7 @@ internal class EmbracePayloadAssertionInterface( checkNotNull(apiServer).getConfigRequests() } try { - retrievePayload(expectedRequests, supplier) + retrievePayload(expectedSize = expectedRequests, supplier = supplier) } catch (exc: TimeoutException) { throw IllegalStateException( "Expected $expectedRequests config requests, but got ${supplier().size}.", @@ -313,10 +314,6 @@ internal class EmbracePayloadAssertionInterface( /*** TEST INFRA ***/ - internal fun debugDeliveryLayer(): String { - error(deliveryTracer.generateReport()) - } - /** * Validates a payload against a golden file in the test resources. If the payload does not match * the golden file, the assertion fails. @@ -346,14 +343,6 @@ internal class EmbracePayloadAssertionInterface( } } - /** - * Retrieves a payload that was stored in the delivery service. - */ - private inline fun retrievePayload( - expectedSize: Int?, - supplier: () -> List, - ): List = retrievePayload(expectedSize, WAIT_TIME_MS, supplier) - /** * Retrieves a payload that was stored in the delivery service. */ diff --git a/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testframework/assertions/OTelLogAssertions.kt b/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testframework/assertions/OTelLogAssertions.kt index 73cfe30913..00db769c72 100644 --- a/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testframework/assertions/OTelLogAssertions.kt +++ b/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testframework/assertions/OTelLogAssertions.kt @@ -1,5 +1,6 @@ package io.embrace.android.embracesdk.testframework.assertions +import io.embrace.android.embracesdk.Severity import io.embrace.android.embracesdk.internal.opentelemetry.embExceptionHandling import io.embrace.android.embracesdk.internal.opentelemetry.embState import io.embrace.android.embracesdk.internal.payload.Log @@ -18,7 +19,7 @@ internal fun assertOtelLogReceived( expectedMessage: String, expectedSeverityNumber: Int, expectedSeverityText: String, - expectedTimeMs: Long = IntegrationTestRule.DEFAULT_SDK_START_TIME_MS, + expectedTimeMs: Long? = IntegrationTestRule.DEFAULT_SDK_START_TIME_MS, expectedType: String? = null, expectedExceptionName: String? = null, expectedExceptionMessage: String? = null, @@ -33,7 +34,9 @@ internal fun assertOtelLogReceived( assertEquals(expectedMessage, log.body) assertEquals(expectedSeverityNumber, log.severityNumber) assertEquals(expectedSeverityText, log.severityText) - assertEquals(expectedTimeMs * 1000000, log.timeUnixNano) + if (expectedTimeMs != null) { + assertEquals(expectedTimeMs * 1000000, log.timeUnixNano) + } assertFalse(log.attributes?.findAttributeValue(SessionIncubatingAttributes.SESSION_ID.key).isNullOrBlank()) expectedType?.let { assertAttribute(log, embExceptionHandling.name, it) } assertEquals(expectedState, log.attributes?.findAttributeValue(embState.attributeKey.key)) @@ -53,8 +56,16 @@ internal fun assertOtelLogReceived( } } +internal fun getOtelSeverity(severity: Severity): io.opentelemetry.api.logs.Severity { + return when (severity) { + Severity.INFO -> io.opentelemetry.api.logs.Severity.INFO + Severity.WARNING -> io.opentelemetry.api.logs.Severity.WARN + Severity.ERROR -> io.opentelemetry.api.logs.Severity.ERROR + } +} + private fun assertAttribute(log: Log, name: String, expectedValue: String) { val attribute = log.attributes?.find { it.key == name } - assertNotNull(attribute) + assertNotNull("Attribute not found: $name", attribute) assertEquals(expectedValue, attribute?.data) } diff --git a/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testframework/server/FakeApiServer.kt b/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testframework/server/FakeApiServer.kt index 6b3be03866..430a6fa5d2 100644 --- a/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testframework/server/FakeApiServer.kt +++ b/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testframework/server/FakeApiServer.kt @@ -35,6 +35,7 @@ internal class FakeApiServer( private val serializer by threadLocal { TestPlatformSerializer() } private val sessionRequests = CopyOnWriteArrayList>() private val logRequests = CopyOnWriteArrayList>() + private val attachments = CopyOnWriteArrayList>() private val configRequests = CopyOnWriteArrayList() /** @@ -47,6 +48,11 @@ internal class FakeApiServer( */ fun getLogEnvelopes(): List> = logRequests.toList() + /** + * Returns a list of attachments in the order in which the server received them. + */ + fun getAttachments(): List> = attachments.toList() + /** * Returns a list of config requests in the order in which the server received them. * The returned value is the query parameter. @@ -58,6 +64,7 @@ internal class FakeApiServer( deliveryTracer.onServerReceivedRequest(endpoint.name) return when (endpoint) { Endpoint.LOGS, Endpoint.SESSIONS -> handleEnvelopeRequest(request, endpoint) + Endpoint.ATTACHMENTS -> handleAttachmentRequest(request) // IMPORTANT NOTE: this response is not used until the SDK next starts! Endpoint.CONFIG -> handleConfigRequest(request) @@ -97,6 +104,17 @@ internal class FakeApiServer( ) } + private fun handleAttachmentRequest(request: RecordedRequest): MockResponse { + try { + MultipartFormReader().read(request).let { parts -> + attachments.add(parts) + } + return MockResponse().setResponseCode(200) + } catch (exc: Throwable) { + throw IllegalStateException("Failed to handle attachment request", exc) + } + } + private fun handleConfigRequest(request: RecordedRequest): MockResponse { configRequests.add(request.requestUrl?.toUrl()?.query) @@ -132,6 +150,7 @@ internal class FakeApiServer( "logs" -> Endpoint.LOGS "spans" -> Endpoint.SESSIONS "config" -> Endpoint.CONFIG + "attachments" -> Endpoint.ATTACHMENTS else -> error("Unsupported path $endpoint") } } diff --git a/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testframework/server/MultipartFormReader.kt b/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testframework/server/MultipartFormReader.kt new file mode 100644 index 0000000000..cebf574c05 --- /dev/null +++ b/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testframework/server/MultipartFormReader.kt @@ -0,0 +1,34 @@ +package io.embrace.android.embracesdk.testframework.server + +import okhttp3.MultipartReader +import okhttp3.mockwebserver.RecordedRequest + +internal class FormPart( + val contentDisposition: String, + val data: String?, + val contentType: String? = null, +) + +internal class MultipartFormReader { + + fun read(request: RecordedRequest): List { + val boundary = checkNotNull(request.headers["Content-Type"]).substringAfter("boundary=") + val reader = MultipartReader(request.body, boundary) + val parts = mutableListOf() + + reader.use { + while (true) { + val part = it.nextPart() ?: break + parts.add( + FormPart( + contentDisposition = part.headers["Content-Disposition"] + ?: error("Missing Content-Disposition"), + data = part.body.readUtf8(), + contentType = part.headers["Content-Type"] + ) + ) + } + } + return parts + } +} diff --git a/embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/Embrace.kt b/embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/Embrace.kt index 86bbd0b021..5f9789d6f1 100644 --- a/embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/Embrace.kt +++ b/embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/Embrace.kt @@ -139,6 +139,25 @@ public class Embrace private constructor( impl.logMessage(message, severity, properties) } + override fun logMessage( + message: String, + severity: Severity, + properties: Map?, + attachment: ByteArray, + ) { + impl.logMessage(message, severity, properties, attachment) + } + + override fun logMessage( + message: String, + severity: Severity, + properties: Map?, + attachmentId: String, + attachmentUrl: String, + ) { + impl.logMessage(message, severity, properties, attachmentId, attachmentUrl) + } + override fun logException(throwable: Throwable) { impl.logException(throwable) } diff --git a/embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/EmbraceImpl.kt b/embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/EmbraceImpl.kt index aaff31da05..fd6bb20116 100644 --- a/embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/EmbraceImpl.kt +++ b/embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/EmbraceImpl.kt @@ -314,7 +314,37 @@ internal class EmbraceImpl @JvmOverloads constructor( logExceptionType = logExceptionType, exceptionName = exceptionName, exceptionMessage = exceptionMessage, - customLogAttrs = customLogAttrs + customLogAttrs = customLogAttrs, + ) + } + + override fun logMessage( + message: String, + severity: Severity, + properties: Map?, + attachment: ByteArray, + ) { + logsApiDelegate.logMessage( + message, + severity, + properties, + attachment + ) + } + + override fun logMessage( + message: String, + severity: Severity, + properties: Map?, + attachmentId: String, + attachmentUrl: String, + ) { + logsApiDelegate.logMessage( + message, + severity, + properties, + attachmentId, + attachmentUrl, ) } diff --git a/embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/internal/api/delegate/LogsApiDelegate.kt b/embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/internal/api/delegate/LogsApiDelegate.kt index 7d55ce164a..de1fb06021 100644 --- a/embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/internal/api/delegate/LogsApiDelegate.kt +++ b/embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/internal/api/delegate/LogsApiDelegate.kt @@ -5,6 +5,11 @@ import io.embrace.android.embracesdk.Severity import io.embrace.android.embracesdk.internal.api.LogsApi import io.embrace.android.embracesdk.internal.injection.ModuleInitBootstrapper import io.embrace.android.embracesdk.internal.injection.embraceImplInject +import io.embrace.android.embracesdk.internal.logs.attachments.Attachment +import io.embrace.android.embracesdk.internal.logs.attachments.Attachment.EmbraceHosted +import io.embrace.android.embracesdk.internal.logs.attachments.AttachmentErrorCode.ATTACHMENT_TOO_LARGE +import io.embrace.android.embracesdk.internal.logs.attachments.AttachmentErrorCode.OVER_MAX_ATTACHMENTS +import io.embrace.android.embracesdk.internal.logs.attachments.AttachmentErrorCode.UNKNOWN import io.embrace.android.embracesdk.internal.payload.PushNotificationBreadcrumb import io.embrace.android.embracesdk.internal.serialization.truncatedStacktrace import io.embrace.android.embracesdk.internal.utils.getSafeStackTrace @@ -26,6 +31,12 @@ internal class LogsApiDelegate( private val serializer by embraceImplInject(sdkCallChecker) { bootstrapper.initModule.jsonSerializer } + private val attachmentService by embraceImplInject(sdkCallChecker) { + bootstrapper.logModule.attachmentService + } + private val logger by embraceImplInject(sdkCallChecker) { + bootstrapper.initModule.logger + } override fun logInfo(message: String) = logMessage(message, Severity.INFO) override fun logWarning(message: String) = logMessage(message, Severity.WARNING) @@ -79,6 +90,51 @@ internal class LogsApiDelegate( ) } + override fun logMessage( + message: String, + severity: Severity, + properties: Map?, + attachment: ByteArray, + ) { + val obj = attachmentService?.createAttachment(attachment) ?: return + logAttachmentErrorIfNeeded(obj) + logMessageImpl( + severity = severity, + message = message, + properties = properties, + attachment = obj, + ) + } + + override fun logMessage( + message: String, + severity: Severity, + properties: Map?, + attachmentId: String, + attachmentUrl: String, + ) { + val obj = attachmentService?.createAttachment(attachmentId, attachmentUrl) ?: return + logAttachmentErrorIfNeeded(obj) + logMessageImpl( + severity = severity, + message = message, + properties = properties, + attachment = obj, + ) + } + + private fun logAttachmentErrorIfNeeded(obj: Attachment) { + if (obj.errorCode != null) { + val msg = when (obj.errorCode) { + ATTACHMENT_TOO_LARGE -> "Supplied attachment exceeds 1Mb limit. This attachment will not be uploaded." + OVER_MAX_ATTACHMENTS -> "A maximum of 5 attachments are allowed per session. This attachment will not be uploaded." + UNKNOWN -> "An unknown error occurred while processing the attachment." + null -> null + } ?: return + logger?.logError(msg, RuntimeException(msg)) + } + } + override fun logException( throwable: Throwable, severity: Severity, @@ -93,7 +149,7 @@ internal class LogsApiDelegate( stackTraceElements = throwable.getSafeStackTrace(), logExceptionType = LogExceptionType.HANDLED, exceptionName = throwable.javaClass.simpleName, - exceptionMessage = exceptionMessage + exceptionMessage = exceptionMessage, ) } @@ -109,7 +165,7 @@ internal class LogsApiDelegate( properties = properties, stackTraceElements = stacktraceElements, logExceptionType = LogExceptionType.HANDLED, - exceptionMessage = message + exceptionMessage = message, ) } @@ -124,6 +180,7 @@ internal class LogsApiDelegate( exceptionName: String? = null, exceptionMessage: String? = null, customLogAttrs: Map, String> = emptyMap(), + attachment: Attachment? = null, ) { if (sdkCallChecker.check("log_message")) { runCatching { @@ -131,15 +188,25 @@ internal class LogsApiDelegate( exceptionName?.let { attrs[ExceptionAttributes.EXCEPTION_TYPE] = it } exceptionMessage?.let { attrs[ExceptionAttributes.EXCEPTION_MESSAGE] = it } - val stacktrace = stackTraceElements?.let(checkNotNull(serializer)::truncatedStacktrace) ?: customStackTrace + val stacktrace = + stackTraceElements?.let(checkNotNull(serializer)::truncatedStacktrace) ?: customStackTrace stacktrace?.let { attrs[ExceptionAttributes.EXCEPTION_STACKTRACE] = it } + if (attachment != null) { + attrs.putAll(attachment.attributes.mapKeys { it.key.attributeKey }) + } + + val logAttachment = when { + attachment is EmbraceHosted && attachment.shouldAttemptUpload() -> attachment + else -> null + } logService?.log( message, severity, logExceptionType, properties, - attrs.plus(customLogAttrs) + attrs.plus(customLogAttrs), + logAttachment ) sessionOrchestrator?.reportBackgroundActivityStateChange() } diff --git a/embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/internal/injection/ModuleInitBootstrapper.kt b/embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/internal/injection/ModuleInitBootstrapper.kt index cf9b9062d3..02d223e7a0 100644 --- a/embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/internal/injection/ModuleInitBootstrapper.kt +++ b/embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/internal/injection/ModuleInitBootstrapper.kt @@ -376,6 +376,7 @@ internal class ModuleInitBootstrapper( } postInit(LogModule::class) { + serviceRegistry.registerService(lazy { logModule.attachmentService }) serviceRegistry.registerService(lazy { logModule.logService }) // Start the log orchestrator openTelemetryModule.logSink.registerLogStoredCallback { diff --git a/embrace-android-sdk/src/test/java/io/embrace/android/embracesdk/internal/api/delegate/LogsApiDelegateTest.kt b/embrace-android-sdk/src/test/java/io/embrace/android/embracesdk/internal/api/delegate/LogsApiDelegateTest.kt index a82e904f3b..546512e61e 100644 --- a/embrace-android-sdk/src/test/java/io/embrace/android/embracesdk/internal/api/delegate/LogsApiDelegateTest.kt +++ b/embrace-android-sdk/src/test/java/io/embrace/android/embracesdk/internal/api/delegate/LogsApiDelegateTest.kt @@ -66,7 +66,7 @@ internal class LogsApiDelegateTest { @Test fun logMessage() { - delegate.logMessageImpl(message = "test", severity = Severity.WARNING) + delegate.logMessageImpl(severity = Severity.WARNING, message = "test") val log = logService.loggedMessages.single() assertEquals("test", log.message) assertEquals(Severity.WARNING, log.severity) diff --git a/embrace-android-sdk/src/test/java/io/embrace/android/embracesdk/internal/logs/attachments/AttachmentCounterTest.kt b/embrace-android-sdk/src/test/java/io/embrace/android/embracesdk/internal/logs/attachments/AttachmentCounterTest.kt deleted file mode 100644 index 0704a974ab..0000000000 --- a/embrace-android-sdk/src/test/java/io/embrace/android/embracesdk/internal/logs/attachments/AttachmentCounterTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package io.embrace.android.embracesdk.internal.logs.attachments - -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -internal class AttachmentCounterTest { - - @Test - fun `limit exceeded`() { - val counter = AttachmentCounter() - assertLimitRespected(counter) - - counter.cleanCollections() - assertLimitRespected(counter) - } - - private fun assertLimitRespected(counter: AttachmentCounter) { - repeat(5) { - assertTrue(counter.incrementAndCheckAttachmentLimit()) - } - assertFalse(counter.incrementAndCheckAttachmentLimit()) - } -} diff --git a/embrace-test-fakes/src/main/kotlin/io/embrace/android/embracesdk/fakes/FakeIntakeService.kt b/embrace-test-fakes/src/main/kotlin/io/embrace/android/embracesdk/fakes/FakeIntakeService.kt index e170e1728e..0b42b86f5a 100644 --- a/embrace-test-fakes/src/main/kotlin/io/embrace/android/embracesdk/fakes/FakeIntakeService.kt +++ b/embrace-test-fakes/src/main/kotlin/io/embrace/android/embracesdk/fakes/FakeIntakeService.kt @@ -3,8 +3,6 @@ package io.embrace.android.embracesdk.fakes import io.embrace.android.embracesdk.internal.delivery.StoredTelemetryMetadata import io.embrace.android.embracesdk.internal.delivery.intake.IntakeService import io.embrace.android.embracesdk.internal.payload.Envelope -import io.embrace.android.embracesdk.internal.payload.LogPayload -import io.embrace.android.embracesdk.internal.payload.SessionPayload class FakeIntakeService : IntakeService { @@ -14,9 +12,6 @@ class FakeIntakeService : IntakeService { @Suppress("UNCHECKED_CAST") inline fun getIntakes(complete: Boolean = true): List> { - if (T::class != SessionPayload::class && T::class != LogPayload::class) { - error("Unsupported type: ${T::class}") - } val dst = when (complete) { true -> intakeList false -> cacheList diff --git a/embrace-test-fakes/src/main/kotlin/io/embrace/android/embracesdk/fakes/FakeLogService.kt b/embrace-test-fakes/src/main/kotlin/io/embrace/android/embracesdk/fakes/FakeLogService.kt index f127be26fc..2fd46dd2a8 100644 --- a/embrace-test-fakes/src/main/kotlin/io/embrace/android/embracesdk/fakes/FakeLogService.kt +++ b/embrace-test-fakes/src/main/kotlin/io/embrace/android/embracesdk/fakes/FakeLogService.kt @@ -3,6 +3,7 @@ package io.embrace.android.embracesdk.fakes import io.embrace.android.embracesdk.LogExceptionType import io.embrace.android.embracesdk.Severity import io.embrace.android.embracesdk.internal.logs.LogService +import io.embrace.android.embracesdk.internal.logs.attachments.Attachment import io.opentelemetry.api.common.AttributeKey class FakeLogService : LogService { @@ -23,6 +24,7 @@ class FakeLogService : LogService { logExceptionType: LogExceptionType, properties: Map?, customLogAttrs: Map, String>, + logAttachment: Attachment.EmbraceHosted?, ) { loggedMessages.add( LogData( diff --git a/embrace-test-fakes/src/main/kotlin/io/embrace/android/embracesdk/fakes/FakePayloadStorageService.kt b/embrace-test-fakes/src/main/kotlin/io/embrace/android/embracesdk/fakes/FakePayloadStorageService.kt index 22a2aba9b8..4e9c9179fb 100644 --- a/embrace-test-fakes/src/main/kotlin/io/embrace/android/embracesdk/fakes/FakePayloadStorageService.kt +++ b/embrace-test-fakes/src/main/kotlin/io/embrace/android/embracesdk/fakes/FakePayloadStorageService.kt @@ -1,5 +1,6 @@ package io.embrace.android.embracesdk.fakes +import io.embrace.android.embracesdk.internal.delivery.PayloadType import io.embrace.android.embracesdk.internal.delivery.StoredTelemetryMetadata import io.embrace.android.embracesdk.internal.delivery.storage.PayloadStorageService import io.embrace.android.embracesdk.internal.injection.SerializationAction @@ -30,7 +31,11 @@ class FakePayloadStorageService( } val baos = ByteArrayOutputStream() - action(GZIPOutputStream(baos)) + if (metadata.payloadType != PayloadType.ATTACHMENT) { + action(GZIPOutputStream(baos)) + } else { + action(baos) + } cachedPayloads[metadata] = baos.toByteArray() } @@ -58,7 +63,7 @@ class FakePayloadStorageService( fun addPayload(metadata: StoredTelemetryMetadata, data: T) { store(metadata) { stream -> - serializer.toJson(data, metadata.envelopeType.serializedType, stream) + serializer.toJson(data, checkNotNull(metadata.envelopeType.serializedType), stream) } } diff --git a/embrace-test-fakes/src/main/kotlin/io/embrace/android/embracesdk/fakes/FakePayloadStore.kt b/embrace-test-fakes/src/main/kotlin/io/embrace/android/embracesdk/fakes/FakePayloadStore.kt index c2a3c2094f..d9b043725a 100644 --- a/embrace-test-fakes/src/main/kotlin/io/embrace/android/embracesdk/fakes/FakePayloadStore.kt +++ b/embrace-test-fakes/src/main/kotlin/io/embrace/android/embracesdk/fakes/FakePayloadStore.kt @@ -13,6 +13,7 @@ class FakePayloadStore( val storedSessionPayloads = mutableListOf, TransitionType>>() val storedLogPayloads = mutableListOf, Boolean>>() + val storedAttachments = mutableListOf>>() val cachedSessionPayloads = mutableListOf>() val cachedEmptyCrashPayloads = mutableListOf>() var crashCount: Int = 0 @@ -33,6 +34,10 @@ class FakePayloadStore( storedLogPayloads.add(Pair(envelope, attemptImmediateRequest)) } + override fun storeAttachment(envelope: Envelope>) { + storedAttachments.add(envelope) + } + override fun cacheEmptyCrashEnvelope(envelope: Envelope) { cachedEmptyCrashPayloads.add(envelope) } diff --git a/embrace-test-fakes/src/main/kotlin/io/embrace/android/embracesdk/fakes/FakeRequestExecutionService.kt b/embrace-test-fakes/src/main/kotlin/io/embrace/android/embracesdk/fakes/FakeRequestExecutionService.kt index 1956564887..48fb71e709 100644 --- a/embrace-test-fakes/src/main/kotlin/io/embrace/android/embracesdk/fakes/FakeRequestExecutionService.kt +++ b/embrace-test-fakes/src/main/kotlin/io/embrace/android/embracesdk/fakes/FakeRequestExecutionService.kt @@ -40,7 +40,7 @@ class FakeRequestExecutionService( ): ExecutionResult { exceptionOnExecution?.run { throw this } val bufferedStream = GZIPInputStream(payloadStream()) - val envelope: Envelope<*> = serializer.fromJson(bufferedStream, envelopeType.serializedType) + val envelope: Envelope<*> = serializer.fromJson(bufferedStream, checkNotNull(envelopeType.serializedType)) processEnvelope(envelope) return responseAction(envelope) } diff --git a/embrace-test-fakes/src/main/kotlin/io/embrace/android/embracesdk/fakes/injection/FakeLogModule.kt b/embrace-test-fakes/src/main/kotlin/io/embrace/android/embracesdk/fakes/injection/FakeLogModule.kt index 6c36977c3e..9dc9cde3cc 100644 --- a/embrace-test-fakes/src/main/kotlin/io/embrace/android/embracesdk/fakes/injection/FakeLogModule.kt +++ b/embrace-test-fakes/src/main/kotlin/io/embrace/android/embracesdk/fakes/injection/FakeLogModule.kt @@ -6,11 +6,13 @@ import io.embrace.android.embracesdk.fakes.FakeLogWriter import io.embrace.android.embracesdk.fakes.FakeNetworkCaptureDataSource import io.embrace.android.embracesdk.fakes.FakeNetworkCaptureService import io.embrace.android.embracesdk.fakes.FakeNetworkLoggingService +import io.embrace.android.embracesdk.fakes.FakePayloadStore import io.embrace.android.embracesdk.fakes.FakeSessionPropertiesService import io.embrace.android.embracesdk.internal.injection.LogModule import io.embrace.android.embracesdk.internal.logs.EmbraceLogService import io.embrace.android.embracesdk.internal.logs.LogOrchestrator import io.embrace.android.embracesdk.internal.logs.LogService +import io.embrace.android.embracesdk.internal.logs.attachments.AttachmentService import io.embrace.android.embracesdk.internal.network.logging.NetworkCaptureDataSource import io.embrace.android.embracesdk.internal.network.logging.NetworkCaptureService import io.embrace.android.embracesdk.internal.network.logging.NetworkLoggingService @@ -21,7 +23,8 @@ class FakeLogModule( override val logService: LogService = EmbraceLogService( FakeLogWriter(), FakeConfigService(), - FakeSessionPropertiesService() + FakeSessionPropertiesService(), + FakePayloadStore(), ), ) : LogModule { @@ -33,4 +36,6 @@ class FakeLogModule( override val networkCaptureDataSource: NetworkCaptureDataSource get() = FakeNetworkCaptureDataSource() + + override val attachmentService: AttachmentService = AttachmentService() } diff --git a/examples/ExampleApp/app/build.gradle.kts b/examples/ExampleApp/app/build.gradle.kts index 1846d50bb3..680e69e821 100644 --- a/examples/ExampleApp/app/build.gradle.kts +++ b/examples/ExampleApp/app/build.gradle.kts @@ -62,10 +62,12 @@ dependencies { implementation(libs.opentelemetry.sdk) // uncomment to enable debugging through source contained in those modules +// implementation(libs.embrace.android.api) // implementation(libs.embrace.android.sdk) // implementation(libs.embrace.android.core) // implementation(libs.embrace.android.features) // implementation(libs.embrace.android.payload) +// implementation(libs.embrace.android.delivery) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) diff --git a/examples/ExampleApp/app/src/main/kotlin/io/embrace/android/exampleapp/ui/CodeExample.kt b/examples/ExampleApp/app/src/main/kotlin/io/embrace/android/exampleapp/ui/CodeExample.kt index dfc3d11986..3ebbcead1d 100644 --- a/examples/ExampleApp/app/src/main/kotlin/io/embrace/android/exampleapp/ui/CodeExample.kt +++ b/examples/ExampleApp/app/src/main/kotlin/io/embrace/android/exampleapp/ui/CodeExample.kt @@ -9,6 +9,7 @@ enum class CodeExample( TRACING("Tracing API"), ADD_BREADCRUMB("Add Breadcrumb"), LOG_MESSAGE("Send Log Message"), + LOG_MESSAGE_ATTACHMENT("Log with Attachment"), RECORD_NETWORK_REQUEST("Network Requests"), SESSION_PROPERTIES("Session Properties"), END_SESSION("End Session"), diff --git a/examples/ExampleApp/app/src/main/kotlin/io/embrace/android/exampleapp/ui/ExampleContent.kt b/examples/ExampleApp/app/src/main/kotlin/io/embrace/android/exampleapp/ui/ExampleContent.kt index 611bde53bf..6e87f6bbfe 100644 --- a/examples/ExampleApp/app/src/main/kotlin/io/embrace/android/exampleapp/ui/ExampleContent.kt +++ b/examples/ExampleApp/app/src/main/kotlin/io/embrace/android/exampleapp/ui/ExampleContent.kt @@ -5,6 +5,7 @@ import io.embrace.android.exampleapp.ui.examples.AddBreadcrumbExample import io.embrace.android.exampleapp.ui.examples.AnrDetectionExample import io.embrace.android.exampleapp.ui.examples.EndSessionExample import io.embrace.android.exampleapp.ui.examples.JvmCrashExample +import io.embrace.android.exampleapp.ui.examples.LogMessageAttachmentsExample import io.embrace.android.exampleapp.ui.examples.NdkCrashExample import io.embrace.android.exampleapp.ui.examples.LogMessageExample import io.embrace.android.exampleapp.ui.examples.NetworkRequestExample @@ -20,6 +21,7 @@ fun ExampleContent(example: CodeExample) { CodeExample.TRACING -> TracingApiExample() CodeExample.ADD_BREADCRUMB -> AddBreadcrumbExample() CodeExample.LOG_MESSAGE -> LogMessageExample() + CodeExample.LOG_MESSAGE_ATTACHMENT -> LogMessageAttachmentsExample() CodeExample.RECORD_NETWORK_REQUEST -> NetworkRequestExample() CodeExample.SESSION_PROPERTIES -> SessionPropertiesExample() CodeExample.END_SESSION -> EndSessionExample() diff --git a/examples/ExampleApp/app/src/main/kotlin/io/embrace/android/exampleapp/ui/examples/LogMessageAttachmentsExample.kt b/examples/ExampleApp/app/src/main/kotlin/io/embrace/android/exampleapp/ui/examples/LogMessageAttachmentsExample.kt new file mode 100644 index 0000000000..e54d506cc6 --- /dev/null +++ b/examples/ExampleApp/app/src/main/kotlin/io/embrace/android/exampleapp/ui/examples/LogMessageAttachmentsExample.kt @@ -0,0 +1,64 @@ +package io.embrace.android.exampleapp.ui.examples + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import io.embrace.android.embracesdk.Embrace +import io.embrace.android.embracesdk.Severity +import io.embrace.android.exampleapp.ui.RadioButtonList +import java.util.UUID + +@Composable +fun LogMessageAttachmentsExample() { + var textValue by remember { mutableStateOf("My log message") } + var severityValue by remember { mutableStateOf(Severity.INFO) } + var includeProps by remember { mutableStateOf(true) } + var hostFileOn3rdPartyServer by remember { mutableStateOf(false) } + + TextField(value = textValue, onValueChange = { + textValue = it + }) + RadioButtonList( + items = Severity.entries, + selectedItem = severityValue + ) { + severityValue = it + } + + Row { + Checkbox( + checked = includeProps, + onCheckedChange = { + includeProps = it + } + ) + Text("Include properties", Modifier.padding(top = 8.dp)) + } + + Row { + Checkbox( + checked = hostFileOn3rdPartyServer, + onCheckedChange = { + hostFileOn3rdPartyServer = it + } + ) + Text("Host files on a 3rd party server (not Embrace)", Modifier.padding(top = 8.dp)) + } + Spacer(Modifier.padding(8.dp)) + Button(onClick = { + TODO("Not supported in 7.0.") + }) { + Text("Send Log") + } +} diff --git a/examples/ExampleApp/gradle/libs.versions.toml b/examples/ExampleApp/gradle/libs.versions.toml index 7bc9ea020a..e90c26f283 100644 --- a/examples/ExampleApp/gradle/libs.versions.toml +++ b/examples/ExampleApp/gradle/libs.versions.toml @@ -29,15 +29,18 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } -embrace-android-sdk = { group = "io.embrace", name = "embrace-android-sdk", version.ref = "embrace" } -embrace-android-core = { group = "io.embrace", name = "embrace-android-core", version.ref = "embrace" } -embrace-android-features = { group = "io.embrace", name = "embrace-android-features", version.ref = "embrace" } -embrace-android-payload = { group = "io.embrace", name = "embrace-android-payload", version.ref = "embrace" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } opentelemetry-bom = { group = "io.opentelemetry", name = "opentelemetry-bom", version.ref = "otel" } opentelemetry-api = { group = "io.opentelemetry", name = "opentelemetry-api"} opentelemetry-sdk = { group = "io.opentelemetry", name = "opentelemetry-sdk"} +embrace-android-api = { group = "io.embrace", name = "embrace-android-api", version.ref = "embrace" } +embrace-android-sdk = { group = "io.embrace", name = "embrace-android-sdk", version.ref = "embrace" } +embrace-android-core = { group = "io.embrace", name = "embrace-android-core", version.ref = "embrace" } +embrace-android-features = { group = "io.embrace", name = "embrace-android-features", version.ref = "embrace" } +embrace-android-payload = { group = "io.embrace", name = "embrace-android-payload", version.ref = "embrace" } +embrace-android-delivery = { group = "io.embrace", name = "embrace-android-delivery", version.ref = "embrace" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }