From 7e3ea2c89d333f7c0879009a1b7fc51e2f5d61ac Mon Sep 17 00:00:00 2001 From: Ignacio Tischelman <114942102+NachoEmbrace@users.noreply.github.com> Date: Tue, 21 Jan 2025 11:22:58 -0300 Subject: [PATCH] OTel Logs with file attachments (#156) --- .../Protocols/DispatchableQueue.swift | 2 +- .../EmbraceCore/Internal/Embrace+Setup.swift | 9 +- .../Logs/EmbraceLogAttributesBuilder.swift | 2 +- .../Internal/Logs/LogController.swift | 99 +++++++++++++ Sources/EmbraceCore/Public/Embrace+OTel.swift | 103 +++++++++++--- .../Session/SessionControllable.swift | 3 + .../Session/SessionController.swift | 10 +- Sources/EmbraceCore/Utils/URL+Embrace.swift | 4 + Sources/EmbraceOTelInternal/EmbraceOTel.swift | 42 +++--- .../EmbraceOpenTelemetry.swift | 22 +++ .../Span/Processor/SingleSpanProcessor.swift | 2 +- .../EmbraceSemantics/Logs/LogSemantics.swift | 8 ++ .../EmbraceUploadInternal/EmbraceUpload.swift | 102 +++++++++++--- .../EmbraceUploadType.swift | 1 + .../EmbraceAttachmentUploadOperation.swift | 67 +++++++++ .../Operations/EmbraceUploadOperation.swift | 17 ++- .../Options/EmbraceUpload+CacheOptions.swift | 4 +- .../EmbraceUpload+EndpointOptions.swift | 6 +- .../EmbraceUpload+MetadataOptions.swift | 6 +- .../EmbraceUpload+RedundancyOptions.swift | 8 +- .../Logs/EmbraceLoggerSharedStateTests.swift | 6 +- .../Internal/Logs/LogControllerTests.swift | 131 +++++++++++++++++- .../Session/SessionControllerTests.swift | 7 +- .../Session/UnsentDataHandlerTests.swift | 9 +- .../TestDoubles/MockSessionController.swift | 6 + .../TestDoubles/SpyEmbraceLogUploader.swift | 16 ++- .../EmbraceOTelTests.swift | 43 +----- .../Logs/GenericLogExporterTests.swift | 4 - .../EmbraceAttachmentOperationTests.swift | 69 +++++++++ .../EmbraceUploadTests.swift | 23 ++- .../Mocks/DummyLogControllable.swift | 30 ++++ .../Mocks/MockEmbraceOTelBridge.swift | 24 ++++ .../Mocks/MockEmbraceOpenTelemetry.swift | 28 +++- 33 files changed, 767 insertions(+), 146 deletions(-) create mode 100644 Sources/EmbraceUploadInternal/Operations/EmbraceAttachmentUploadOperation.swift create mode 100644 Tests/EmbraceUploadInternalTests/EmbraceAttachmentOperationTests.swift create mode 100644 Tests/TestSupport/Mocks/DummyLogControllable.swift create mode 100644 Tests/TestSupport/Mocks/MockEmbraceOTelBridge.swift diff --git a/Sources/EmbraceCommonInternal/Protocols/DispatchableQueue.swift b/Sources/EmbraceCommonInternal/Protocols/DispatchableQueue.swift index 52598d15..4397a064 100644 --- a/Sources/EmbraceCommonInternal/Protocols/DispatchableQueue.swift +++ b/Sources/EmbraceCommonInternal/Protocols/DispatchableQueue.swift @@ -23,7 +23,7 @@ public class DefaultDispatchableQueue: DispatchableQueue { public func sync(execute block: () -> Void) { queue.sync(execute: block) } - + public static func with(label: String) -> DispatchableQueue { DefaultDispatchableQueue(queue: .init(label: label)) } diff --git a/Sources/EmbraceCore/Internal/Embrace+Setup.swift b/Sources/EmbraceCore/Internal/Embrace+Setup.swift index afa8ab73..6e88a222 100644 --- a/Sources/EmbraceCore/Internal/Embrace+Setup.swift +++ b/Sources/EmbraceCore/Internal/Embrace+Setup.swift @@ -40,12 +40,17 @@ extension Embrace { let baseUrl = EMBDevice.isDebuggerAttached ? endpoints.developmentBaseURL : endpoints.baseURL guard let spansURL = URL.spansEndpoint(basePath: baseUrl), - let logsURL = URL.logsEndpoint(basePath: baseUrl) else { + let logsURL = URL.logsEndpoint(basePath: baseUrl), + let attachmentsURL = URL.attachmentsEndpoint(basePath: baseUrl) else { Embrace.logger.error("Failed to initialize endpoints!") return nil } - let uploadEndpoints = EmbraceUpload.EndpointOptions(spansURL: spansURL, logsURL: logsURL) + let uploadEndpoints = EmbraceUpload.EndpointOptions( + spansURL: spansURL, + logsURL: logsURL, + attachmentsURL: attachmentsURL + ) // cache guard let cacheUrl = EmbraceFileSystem.uploadsDirectoryPath( diff --git a/Sources/EmbraceCore/Internal/Logs/EmbraceLogAttributesBuilder.swift b/Sources/EmbraceCore/Internal/Logs/EmbraceLogAttributesBuilder.swift index 120f97cd..fcbd1e33 100644 --- a/Sources/EmbraceCore/Internal/Logs/EmbraceLogAttributesBuilder.swift +++ b/Sources/EmbraceCore/Internal/Logs/EmbraceLogAttributesBuilder.swift @@ -18,7 +18,7 @@ class EmbraceLogAttributesBuilder { session ?? sessionControllable?.currentSession } - init(storage: EmbraceStorageMetadataFetcher, + init(storage: EmbraceStorageMetadataFetcher?, sessionControllable: SessionControllable, initialAttributes: [String: String]) { self.storage = storage diff --git a/Sources/EmbraceCore/Internal/Logs/LogController.swift b/Sources/EmbraceCore/Internal/Logs/LogController.swift index 0b537945..e747c3c9 100644 --- a/Sources/EmbraceCore/Internal/Logs/LogController.swift +++ b/Sources/EmbraceCore/Internal/Logs/LogController.swift @@ -8,9 +8,22 @@ import EmbraceUploadInternal import EmbraceCommonInternal import EmbraceSemantics import EmbraceConfigInternal +import EmbraceOTelInternal protocol LogControllable: LogBatcherDelegate { func uploadAllPersistedLogs() + func createLog( + _ message: String, + severity: LogSeverity, + type: LogType, + timestamp: Date, + attachment: Data?, + attachmentId: String?, + attachmentUrl: URL?, + attachmentSize: Int?, + attributes: [String: String], + stackTraceBehavior: StackTraceBehavior + ) } class LogController: LogControllable { @@ -18,10 +31,16 @@ class LogController: LogControllable { private weak var storage: Storage? private weak var upload: EmbraceLogUploader? private weak var config: EmbraceConfig? + + var otel: EmbraceOTelBridge = EmbraceOTel() // var so we can inject a mock for testing + /// This will probably be injected eventually. /// For consistency, I created a constant static let maxLogsPerBatch: Int = 20 + static let attachmentLimit: Int = 5 + static let attachmentSizeLimit: Int = 1048576 // 1 MiB + private var isSDKEnabled: Bool { guard let config = config else { return true @@ -53,6 +72,86 @@ class LogController: LogControllable { try? storage.removeAllLogs() } } + + public func createLog( + _ message: String, + severity: LogSeverity, + type: LogType = .message, + timestamp: Date = Date(), + attachment: Data? = nil, + attachmentId: String? = nil, + attachmentUrl: URL? = nil, + attachmentSize: Int? = nil, + attributes: [String: String] = [:], + stackTraceBehavior: StackTraceBehavior = .default + ) { + guard let sessionController = sessionController else { + return + } + + // generate attributes + let attributesBuilder = EmbraceLogAttributesBuilder( + storage: storage, + sessionControllable: sessionController, + initialAttributes: attributes + ) + + /* + If we want to keep this method cleaner, we could move this log to `EmbraceLogAttributesBuilder` + However that would cause to always add a frame to the stacktrace. + */ + if stackTraceBehavior == .default && (severity == .warn || severity == .error) { + let stackTrace: [String] = Thread.callStackSymbols + attributesBuilder.addStackTrace(stackTrace) + } + + var finalAttributes = attributesBuilder + .addLogType(type) + .addApplicationState() + .addApplicationProperties() + .addSessionIdentifier() + .build() + + // handle attachment data + if let attachment = attachment { + + sessionController.increaseAttachmentCount() + + let id = UUID().withoutHyphen + finalAttributes[LogSemantics.keyAttachmentId] = id + + let size = attachment.count + finalAttributes[LogSemantics.keyAttachmentSize] = String(size) + + // check attachment count limit + if sessionController.attachmentCount >= Self.attachmentLimit { + finalAttributes[LogSemantics.keyAttachmentErrorCode] = LogSemantics.attachmentLimitReached + + // check attachment size limit + } else if size > Self.attachmentSizeLimit { + finalAttributes[LogSemantics.keyAttachmentErrorCode] = LogSemantics.attachmentTooLarge + } + + // upload attachment + else { + upload?.uploadAttachment(id: id, data: attachment, completion: nil) + } + } + + // handle pre-uploaded attachment + else if let attachmentId = attachmentId, + let attachmentUrl = attachmentUrl { + + finalAttributes[LogSemantics.keyAttachmentId] = attachmentId + finalAttributes[LogSemantics.keyAttachmentUrl] = attachmentUrl.absoluteString + + if let attachmentSize = attachmentSize { + finalAttributes[LogSemantics.keyAttachmentSize] = String(attachmentSize) + } + } + + otel.log(message, severity: severity, timestamp: timestamp, attributes: finalAttributes) + } } extension LogController { diff --git a/Sources/EmbraceCore/Public/Embrace+OTel.swift b/Sources/EmbraceCore/Public/Embrace+OTel.swift index fb0e9f20..192de7f8 100644 --- a/Sources/EmbraceCore/Public/Embrace+OTel.swift +++ b/Sources/EmbraceCore/Public/Embrace+OTel.swift @@ -17,7 +17,7 @@ extension Embrace: EmbraceOpenTelemetry { ) } - private var otel: EmbraceOTel { EmbraceOTel() } + var otel: EmbraceOTel { EmbraceOTel() } /// - Parameters: /// - instrumentationName: The name of the instrumentation library requesting the tracer. @@ -118,6 +118,7 @@ extension Embrace: EmbraceOpenTelemetry { /// - message: Body of the log. /// - severity: `LogSeverity` for the log. /// - attributes: Attributes for the log. + /// - stackTraceBehavior: Defines if the stack trace information should be added to the log public func log( _ message: String, severity: LogSeverity, @@ -141,37 +142,95 @@ extension Embrace: EmbraceOpenTelemetry { /// - severity: `LogSeverity` for the log. /// - timestamp: Timestamp for the log. /// - attributes: Attributes for the log. + /// - stackTraceBehavior: Defines if the stack trace information should be added to the log public func log( _ message: String, severity: LogSeverity, type: LogType = .message, timestamp: Date, - attributes: [String: String], + attributes: [String: String] = [:], stackTraceBehavior: StackTraceBehavior = .default ) { - let attributesBuilder = EmbraceLogAttributesBuilder( - storage: storage, - sessionControllable: sessionController, - initialAttributes: attributes + logController.createLog( + message, + severity: severity, + type: type, + timestamp: timestamp, + attachment: nil, + attachmentId: nil, + attachmentUrl: nil, + attachmentSize: nil, + attributes: attributes, + stackTraceBehavior: stackTraceBehavior ) + } - /* - If we want to keep this method cleaner, we could move this log to `EmbraceLogAttributesBuilder` - However that would cause to always add a frame to the stacktrace. - */ - if stackTraceBehavior == .default && (severity == .warn || severity == .error) { - let stackTrace: [String] = Thread.callStackSymbols - attributesBuilder.addStackTrace(stackTrace) - } - - let finalAttributes = attributesBuilder - .addLogType(type) - .addApplicationState() - .addApplicationProperties() - .addSessionIdentifier() - .build() + /// Creates and adds a log with the given data as an attachment for the current session span. + /// The attachment will be hosted by Embrace and will be accessible through the dashboard. + /// - Parameters: + /// - message: Body of the log. + /// - severity: `LogSeverity` for the log. + /// - timestamp: Timestamp for the log. + /// - attachment: Data of the attachment + /// - attributes: Attributes for the log. + /// - stackTraceBehavior: Defines if the stack trace information should be added to the log + public func log( + _ message: String, + severity: LogSeverity, + type: LogType = .message, + timestamp: Date = Date(), + attachment: Data, + attributes: [String: String] = [:], + stackTraceBehavior: StackTraceBehavior = .default + ) { + logController.createLog( + message, + severity: severity, + type: type, + timestamp: timestamp, + attachment: attachment, + attachmentId: nil, + attachmentUrl: nil, + attachmentSize: nil, + attributes: attributes, + stackTraceBehavior: stackTraceBehavior + ) + } - otel.log(message, severity: severity, attributes: finalAttributes) + /// Creates and adds a log with the given attachment info for the current session span. + /// Use this method for attachments hosted outside of Embrace. + /// - Parameters: + /// - message: Body of the log. + /// - severity: `LogSeverity` for the log. + /// - timestamp: Timestamp for the log. + /// - attachmentId: Identifier of the attachment + /// - attachmentUrl: URL to dowload the attachment data + /// - attachmentSize: Size of the attachment (optional) + /// - attributes: Attributes for the log. + /// - stackTraceBehavior: Defines if the stack trace information should be added to the log + public func log( + _ message: String, + severity: LogSeverity, + type: LogType = .message, + timestamp: Date = Date(), + attachmentId: String, + attachmentUrl: URL, + attachmentSize: Int? = nil, + attributes: [String: String], + stackTraceBehavior: StackTraceBehavior = .default + ) { + logController.createLog( + message, + severity: severity, + type: type, + timestamp: timestamp, + attachment: nil, + attachmentId: attachmentId, + attachmentUrl: attachmentUrl, + attachmentSize: attachmentSize, + attributes: attributes, + stackTraceBehavior: stackTraceBehavior + ) } } diff --git a/Sources/EmbraceCore/Session/SessionControllable.swift b/Sources/EmbraceCore/Session/SessionControllable.swift index 4e7b9454..e1de3838 100644 --- a/Sources/EmbraceCore/Session/SessionControllable.swift +++ b/Sources/EmbraceCore/Session/SessionControllable.swift @@ -20,4 +20,7 @@ protocol SessionControllable: AnyObject { func update(state: SessionState) func update(appTerminated: Bool) + + var attachmentCount: Int { get } + func increaseAttachmentCount() } diff --git a/Sources/EmbraceCore/Session/SessionController.swift b/Sources/EmbraceCore/Session/SessionController.swift index 54e040ac..99af4a04 100644 --- a/Sources/EmbraceCore/Session/SessionController.swift +++ b/Sources/EmbraceCore/Session/SessionController.swift @@ -31,6 +31,9 @@ class SessionController: SessionControllable { @ThreadSafe private(set) var currentSessionSpan: Span? + @ThreadSafe + private(set) var attachmentCount: Int = 0 + // Lock used for session boundaries. Will be shared at both start/end of session private let lock = UnfairLock() @@ -138,6 +141,7 @@ class SessionController: SessionControllable { NotificationCenter.default.post(name: .embraceSessionDidStart, object: session) firstSession = false + attachmentCount = 0 return session } @@ -222,6 +226,10 @@ class SessionController: SessionControllable { UnsentDataHandler.sendSession(session, storage: storage, upload: upload) } } + + func increaseAttachmentCount() { + attachmentCount += 1 + } } extension SessionController { @@ -253,7 +261,7 @@ extension SessionController { currentSession = nil currentSessionSpan = nil } - + private var isSDKEnabled: Bool { guard let config = config else { return true diff --git a/Sources/EmbraceCore/Utils/URL+Embrace.swift b/Sources/EmbraceCore/Utils/URL+Embrace.swift index 351af0d9..ee133f25 100644 --- a/Sources/EmbraceCore/Utils/URL+Embrace.swift +++ b/Sources/EmbraceCore/Utils/URL+Embrace.swift @@ -18,4 +18,8 @@ extension URL { static func logsEndpoint(basePath: String) -> URL? { return endpoint(basePath: basePath, apiPath: "/v2/logs") } + + static func attachmentsEndpoint(basePath: String) -> URL? { + return endpoint(basePath: basePath, apiPath: "/v2/attachments") + } } diff --git a/Sources/EmbraceOTelInternal/EmbraceOTel.swift b/Sources/EmbraceOTelInternal/EmbraceOTel.swift index 03c125b2..0c87a0f1 100644 --- a/Sources/EmbraceOTelInternal/EmbraceOTel.swift +++ b/Sources/EmbraceOTelInternal/EmbraceOTel.swift @@ -54,22 +54,9 @@ public final class EmbraceOTel: NSObject { instrumentationVersion: instrumentationVersion ) } +} - // MARK: - Tracing - - public func recordSpan( - name: String, - type: SpanType, - attributes: [String: String] = [:], - spanOperation: () -> T - ) -> T { - let span = buildSpan(name: name, type: type, attributes: attributes) - .startSpan() - let result = spanOperation() - span.end() - - return result - } +extension EmbraceOTel: EmbraceOTelBridge { public func buildSpan( name: String, @@ -90,16 +77,6 @@ public final class EmbraceOTel: NSObject { return builder } - // MARK: - Logging - - public func log( - _ message: String, - severity: LogSeverity, - attributes: [String: String] - ) { - log(message, severity: severity, timestamp: Date(), attributes: attributes) - } - public func log( _ message: String, severity: LogSeverity, @@ -118,3 +95,18 @@ public final class EmbraceOTel: NSObject { .emit() } } + +public protocol EmbraceOTelBridge: AnyObject { + func buildSpan( + name: String, + type: SpanType, + attributes: [String: String] + ) -> SpanBuilder + + func log( + _ message: String, + severity: LogSeverity, + timestamp: Date, + attributes: [String: String] + ) +} diff --git a/Sources/EmbraceOTelInternal/EmbraceOpenTelemetry.swift b/Sources/EmbraceOTelInternal/EmbraceOpenTelemetry.swift index 0aa3ef68..8969e12c 100644 --- a/Sources/EmbraceOTelInternal/EmbraceOpenTelemetry.swift +++ b/Sources/EmbraceOTelInternal/EmbraceOpenTelemetry.swift @@ -45,4 +45,26 @@ public protocol EmbraceOpenTelemetry: AnyObject { attributes: [String: String], stackTraceBehavior: StackTraceBehavior ) + + func log( + _ message: String, + severity: LogSeverity, + type: LogType, + timestamp: Date, + attachment: Data, + attributes: [String: String], + stackTraceBehavior: StackTraceBehavior + ) + + func log( + _ message: String, + severity: LogSeverity, + type: LogType, + timestamp: Date, + attachmentId: String, + attachmentUrl: URL, + attachmentSize: Int?, + attributes: [String: String], + stackTraceBehavior: StackTraceBehavior + ) } diff --git a/Sources/EmbraceOTelInternal/Trace/Tracer/Span/Processor/SingleSpanProcessor.swift b/Sources/EmbraceOTelInternal/Trace/Tracer/Span/Processor/SingleSpanProcessor.swift index 008295d6..ec64fe30 100644 --- a/Sources/EmbraceOTelInternal/Trace/Tracer/Span/Processor/SingleSpanProcessor.swift +++ b/Sources/EmbraceOTelInternal/Trace/Tracer/Span/Processor/SingleSpanProcessor.swift @@ -50,7 +50,7 @@ public class SingleSpanProcessor: SpanProcessor { autoTerminationSpans[data.spanId] = SpanAutoTerminationData( span: span, spanData: data, - code: code, + code: code, parentId: data.parentSpanId ) } diff --git a/Sources/EmbraceSemantics/Logs/LogSemantics.swift b/Sources/EmbraceSemantics/Logs/LogSemantics.swift index 8060f95c..c602eef2 100644 --- a/Sources/EmbraceSemantics/Logs/LogSemantics.swift +++ b/Sources/EmbraceSemantics/Logs/LogSemantics.swift @@ -12,4 +12,12 @@ public struct LogSemantics { public static let keySessionId = "session.id" public static let keyStackTrace = "emb.stacktrace.ios" public static let keyPropertiesPrefix = "emb.properties.%@" + + public static let keyAttachmentId = "emb.attachment_id" + public static let keyAttachmentSize = "emb.attachment_size" + public static let keyAttachmentUrl = "emb.attachment_url" + public static let keyAttachmentErrorCode = "emb.attachment_error_code" + + public static let attachmentTooLarge = "ATTACHMENT_TOO_LARGE" + public static let attachmentLimitReached = "OVER_MAX_ATTACHMENTS" } diff --git a/Sources/EmbraceUploadInternal/EmbraceUpload.swift b/Sources/EmbraceUploadInternal/EmbraceUpload.swift index 0079b938..7992e6b3 100644 --- a/Sources/EmbraceUploadInternal/EmbraceUpload.swift +++ b/Sources/EmbraceUploadInternal/EmbraceUpload.swift @@ -7,6 +7,7 @@ import EmbraceCommonInternal public protocol EmbraceLogUploader: AnyObject { func uploadLog(id: String, data: Data, completion: ((Result<(), Error>) -> Void)?) + func uploadAttachment(id: String, data: Data, completion: ((Result<(), Error>) -> Void)?) } /// Class in charge of uploading all the data collected by the Embrace SDK. @@ -79,7 +80,7 @@ public class EmbraceUpload: EmbraceLogUploader { // clear data from cache that shouldn't be retried as it's stale self.clearCacheFromStaleData() - + // get all the data cached first, is the only thing that could throw let cachedObjects = try self.cache.fetchAllUploadData() @@ -139,14 +140,30 @@ public class EmbraceUpload: EmbraceLogUploader { } } + /// Uploads the given attachment data + /// - Parameters: + /// - id: Identifier of the attachment + /// - data: The attachment's data + /// - completion: Completion block called when the data is successfully uploaded, or when an `Error` occurs + public func uploadAttachment(id: String, data: Data, completion: ((Result<(), Error>) -> Void)?) { + queue.async { [weak self] in + self?.uploadData( + id: id, + data: data, + type: .attachment, + completion: completion + ) + } + } + // MARK: - Internal private func uploadData( id: String, data: Data, type: EmbraceUploadType, attemptCount: Int = 0, - retryCount: Int? = nil, - completion: ((Result<(), Error>) -> Void)?) { + completion: ((Result<(), Error>) -> Void)? + ) { // validate identifier guard id.isEmpty == false else { @@ -164,7 +181,7 @@ public class EmbraceUpload: EmbraceLogUploader { let cacheOperation = BlockOperation { [weak self] in do { try self?.cache.saveUploadData(id: id, type: type, data: data) - completion?(.success(())) + completion?(.success(())) } catch { self?.logger.debug("Error caching upload data: \(error.localizedDescription)") completion?(.failure(error)) @@ -172,27 +189,26 @@ public class EmbraceUpload: EmbraceLogUploader { } // upload operation - let uploadOperation = EmbraceUploadOperation( + let uploadOperation = createUploadOperation( + id: id, + type: type, urlSession: urlSession, - queue: queue, - metadataOptions: options.metadata, - endpoint: endpoint(for: type), - identifier: id, data: data, - retryCount: retryCount ?? options.redundancy.automaticRetryCount, - exponentialBackoffBehavior: options.redundancy.exponentialBackoffBehavior, - attemptCount: attemptCount, - logger: logger) { [weak self] (result, attemptCount) in - self?.queue.async { [weak self] in - self?.handleOperationFinished( - id: id, - type: type, - result: result, - attemptCount: attemptCount - ) - self?.clearCacheFromStaleData() - } + retryCount: options.redundancy.automaticRetryCount, + attemptCount: attemptCount) { [weak self] (result, attemptCount) in + + self?.queue.async { [weak self] in + + self?.handleOperationFinished( + id: id, + type: type, + result: result, + attemptCount: attemptCount + ) + + self?.clearCacheFromStaleData() } + } // queue operations uploadOperation.addDependency(cacheOperation) @@ -232,6 +248,47 @@ public class EmbraceUpload: EmbraceLogUploader { operationQueue.addOperation(uploadOperation) } + private func createUploadOperation( + id: String, + type: EmbraceUploadType, + urlSession: URLSession, + data: Data, + retryCount: Int, + attemptCount: Int, + completion: @escaping EmbraceUploadOperationCompletion + ) -> EmbraceUploadOperation { + + if type == .attachment { + return EmbraceAttachmentUploadOperation( + urlSession: urlSession, + queue: queue, + metadataOptions: options.metadata, + endpoint: endpoint(for: type), + identifier: id, + data: data, + retryCount: retryCount, + exponentialBackoffBehavior: options.redundancy.exponentialBackoffBehavior, + attemptCount: attemptCount, + logger: logger, + completion: completion + ) + } + + return EmbraceUploadOperation( + urlSession: urlSession, + queue: queue, + metadataOptions: options.metadata, + endpoint: endpoint(for: type), + identifier: id, + data: data, + retryCount: retryCount, + exponentialBackoffBehavior: options.redundancy.exponentialBackoffBehavior, + attemptCount: attemptCount, + logger: logger, + completion: completion + ) + } + private func handleOperationFinished( id: String, type: EmbraceUploadType, @@ -282,6 +339,7 @@ public class EmbraceUpload: EmbraceLogUploader { switch type { case .spans: return options.endpoints.spansURL case .log: return options.endpoints.logsURL + case .attachment: return options.endpoints.attachmentsURL } } } diff --git a/Sources/EmbraceUploadInternal/EmbraceUploadType.swift b/Sources/EmbraceUploadInternal/EmbraceUploadType.swift index 07ddfecc..1cf0a339 100644 --- a/Sources/EmbraceUploadInternal/EmbraceUploadType.swift +++ b/Sources/EmbraceUploadInternal/EmbraceUploadType.swift @@ -5,4 +5,5 @@ enum EmbraceUploadType: Int { case spans = 0 case log + case attachment } diff --git a/Sources/EmbraceUploadInternal/Operations/EmbraceAttachmentUploadOperation.swift b/Sources/EmbraceUploadInternal/Operations/EmbraceAttachmentUploadOperation.swift new file mode 100644 index 00000000..c8ee8fb3 --- /dev/null +++ b/Sources/EmbraceUploadInternal/Operations/EmbraceAttachmentUploadOperation.swift @@ -0,0 +1,67 @@ +// +// Copyright © 2025 Embrace Mobile, Inc. All rights reserved. +// + +import Foundation + +class EmbraceAttachmentUploadOperation: EmbraceUploadOperation { + + override func createRequest( + endpoint: URL, + data: Data, + identifier: String, + metadataOptions: EmbraceUpload.MetadataOptions + ) -> URLRequest { + + let boundary = UUID().uuidString + + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + + request.setValue(metadataOptions.userAgent, forHTTPHeaderField: "User-Agent") + request.setValue(metadataOptions.apiKey, forHTTPHeaderField: "X-EM-AID") + request.setValue(metadataOptions.deviceId, forHTTPHeaderField: "X-EM-DID") + + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + + var multiPartData = Data() + + // app_id + multiPartData.appendString("--\(boundary)\r\n") + multiPartData.appendString("Content-Disposition: form-data; name=\"app_id\"\r\n") + multiPartData.appendString("Content-Type: text/plain\r\n") + multiPartData.appendString("\r\n") + multiPartData.appendString(metadataOptions.apiKey) + multiPartData.appendString("\r\n") + + // attachment_id + multiPartData.appendString("--\(boundary)\r\n") + multiPartData.appendString("Content-Disposition: form-data; name=\"attachment_id\"\r\n") + multiPartData.appendString("Content-Type: text/plain\r\n") + multiPartData.appendString("\r\n") + multiPartData.appendString(identifier) + multiPartData.appendString("\r\n") + + // data + multiPartData.appendString("--\(boundary)\r\n") + multiPartData.appendString("Content-Disposition: form-data; name=\"file\"; filename=\"\(identifier)\"\r\n") + multiPartData.appendString("\r\n") + multiPartData.append(data) + multiPartData.appendString("\r\n") + + multiPartData.appendString("--\(boundary)--") + + request.httpBody = multiPartData + + return request + } +} + +extension Data { + mutating func appendString(_ string: String) { + if let data = string.data(using: .utf8) { + self.append(data) + } + } +} diff --git a/Sources/EmbraceUploadInternal/Operations/EmbraceUploadOperation.swift b/Sources/EmbraceUploadInternal/Operations/EmbraceUploadOperation.swift index 0f608bc4..08d8a930 100644 --- a/Sources/EmbraceUploadInternal/Operations/EmbraceUploadOperation.swift +++ b/Sources/EmbraceUploadInternal/Operations/EmbraceUploadOperation.swift @@ -62,7 +62,12 @@ class EmbraceUploadOperation: AsyncOperation { } override func execute() { - let request = createRequest() + let request = createRequest( + endpoint: endpoint, + data: data, + identifier: identifier, + metadataOptions: metadataOptions + ) sendRequest(request, retryCount: retryCount) } @@ -157,7 +162,7 @@ class EmbraceUploadOperation: AsyncOperation { // retry for all other non-handled cases with errors return error != nil } - + /// Extracts the suggested delay from `Retry-After` header from the `URLResponse` if present. /// - Parameter response: the URLResponse recevied when executing a request. /// - Returns:the time in seconds (as `Int`) extracted from the `Retry-After` header. @@ -171,7 +176,13 @@ class EmbraceUploadOperation: AsyncOperation { return retryAfterDelay } - private func createRequest() -> URLRequest { + func createRequest( + endpoint: URL, + data: Data, + identifier: String, + metadataOptions: EmbraceUpload.MetadataOptions + ) -> URLRequest { + var request = URLRequest(url: endpoint) request.httpMethod = "POST" request.httpBody = data diff --git a/Sources/EmbraceUploadInternal/Options/EmbraceUpload+CacheOptions.swift b/Sources/EmbraceUploadInternal/Options/EmbraceUpload+CacheOptions.swift index a1ec3240..2451d543 100644 --- a/Sources/EmbraceUploadInternal/Options/EmbraceUpload+CacheOptions.swift +++ b/Sources/EmbraceUploadInternal/Options/EmbraceUpload+CacheOptions.swift @@ -15,10 +15,10 @@ public extension EmbraceUpload { let storageMechanism: StorageMechanism /// Determines the maximum amount of cached requests that will be cached. Use 0 to disable. - public var cacheLimit: UInt + public let cacheLimit: UInt /// Determines the maximum amount of days a request will be cached. Use 0 to disable. - public var cacheDaysLimit: UInt + public let cacheDaysLimit: UInt public init?( cacheBaseUrl: URL, diff --git a/Sources/EmbraceUploadInternal/Options/EmbraceUpload+EndpointOptions.swift b/Sources/EmbraceUploadInternal/Options/EmbraceUpload+EndpointOptions.swift index fa8c8b52..193d3e74 100644 --- a/Sources/EmbraceUploadInternal/Options/EmbraceUpload+EndpointOptions.swift +++ b/Sources/EmbraceUploadInternal/Options/EmbraceUpload+EndpointOptions.swift @@ -12,9 +12,13 @@ public extension EmbraceUpload { /// URL for the logs upload endpoint public let logsURL: URL - public init(spansURL: URL, logsURL: URL) { + /// URL for the attachments upload endpoint + public let attachmentsURL: URL + + public init(spansURL: URL, logsURL: URL, attachmentsURL: URL) { self.spansURL = spansURL self.logsURL = logsURL + self.attachmentsURL = attachmentsURL } } } diff --git a/Sources/EmbraceUploadInternal/Options/EmbraceUpload+MetadataOptions.swift b/Sources/EmbraceUploadInternal/Options/EmbraceUpload+MetadataOptions.swift index 70107d63..60c68758 100644 --- a/Sources/EmbraceUploadInternal/Options/EmbraceUpload+MetadataOptions.swift +++ b/Sources/EmbraceUploadInternal/Options/EmbraceUpload+MetadataOptions.swift @@ -7,9 +7,9 @@ import Foundation public extension EmbraceUpload { /// Used to construct the http request headers class MetadataOptions { - public var apiKey: String - public var userAgent: String - public var deviceId: String + public let apiKey: String + public let userAgent: String + public let deviceId: String public init(apiKey: String, userAgent: String, deviceId: String) { self.apiKey = apiKey diff --git a/Sources/EmbraceUploadInternal/Options/EmbraceUpload+RedundancyOptions.swift b/Sources/EmbraceUploadInternal/Options/EmbraceUpload+RedundancyOptions.swift index 59f0b19e..d9fe6b23 100644 --- a/Sources/EmbraceUploadInternal/Options/EmbraceUpload+RedundancyOptions.swift +++ b/Sources/EmbraceUploadInternal/Options/EmbraceUpload+RedundancyOptions.swift @@ -7,16 +7,16 @@ import Foundation public extension EmbraceUpload { class RedundancyOptions { /// Total amount of times a request will be immediately retried in case of error. Use 0 to disable. - public var automaticRetryCount: Int + public let automaticRetryCount: Int /// Total amount of times a request could be retried. - public var maximumAmountOfRetries: Int + public let maximumAmountOfRetries: Int /// Enable to automatically try to send any unsent cached data when the phone regains internet connection. - public var retryOnInternetConnected: Bool + public let retryOnInternetConnected: Bool /// Defines the behavior to use when retrying requests - public var exponentialBackoffBehavior: ExponentialBackoff + public let exponentialBackoffBehavior: ExponentialBackoff public init( automaticRetryCount: Int = 3, diff --git a/Tests/EmbraceCoreTests/Internal/Logs/EmbraceLoggerSharedStateTests.swift b/Tests/EmbraceCoreTests/Internal/Logs/EmbraceLoggerSharedStateTests.swift index 9eb78369..c171c9ad 100644 --- a/Tests/EmbraceCoreTests/Internal/Logs/EmbraceLoggerSharedStateTests.swift +++ b/Tests/EmbraceCoreTests/Internal/Logs/EmbraceLoggerSharedStateTests.swift @@ -7,16 +7,12 @@ import XCTest @testable import EmbraceOTelInternal @testable import EmbraceStorageInternal import OpenTelemetrySdk +import TestSupport class DummyEmbraceResourceProvider: EmbraceResourceProvider { func getResource() -> Resource { Resource() } } -class DummyLogControllable: LogControllable { - func uploadAllPersistedLogs() {} - func batchFinished(withLogs logs: [LogRecord]) {} -} - class EmbraceLoggerSharedStateTests: XCTestCase { private var sut: DefaultEmbraceLogSharedState! diff --git a/Tests/EmbraceCoreTests/Internal/Logs/LogControllerTests.swift b/Tests/EmbraceCoreTests/Internal/Logs/LogControllerTests.swift index c4308325..9df90363 100644 --- a/Tests/EmbraceCoreTests/Internal/Logs/LogControllerTests.swift +++ b/Tests/EmbraceCoreTests/Internal/Logs/LogControllerTests.swift @@ -9,6 +9,7 @@ import EmbraceStorageInternal import EmbraceUploadInternal import EmbraceCommonInternal import EmbraceConfigInternal +import TestSupport class LogControllerTests: XCTestCase { private var sut: LogController! @@ -16,8 +17,10 @@ class LogControllerTests: XCTestCase { private var sessionController: MockSessionController! private var upload: SpyEmbraceLogUploader! private var config: EmbraceConfig! + private var otelBridge: MockEmbraceOTelBridge! override func setUp() { + givenOTelBridge() givenEmbraceLogUploader() givenConfig() givenSessionControllerWithSession() @@ -199,6 +202,48 @@ class LogControllerTests: XCTestCase { XCTAssertTrue(convertedError.userInfo.isEmpty) } } + + // MARK: - createLog + func test_createLog() throws { + givenLogController() + whenCreatingLog() + thenLogIsCreatedCorrectly() + } + + func test_createLogWithAttachment_success() throws { + givenEmbraceLogUploader() + givenLogController() + whenCreatingLogWithAttachment() + thenLogWithSuccessfulAttachmentIsCreatedCorrectly() + } + + func test_createLogWithAttachment_tooLarge() throws { + givenEmbraceLogUploader() + givenLogController() + whenCreatingLogWithBigAttachment() + thenLogWithUnsuccessfulAttachmentIsCreatedCorrectly(errorCode: "ATTACHMENT_TOO_LARGE") + } + + func test_createLogWithAttachment_limitReached() throws { + givenEmbraceLogUploader() + givenLogController() + whenAttachmentLimitIsReached() + whenCreatingLogWithAttachment() + thenLogWithUnsuccessfulAttachmentIsCreatedCorrectly(errorCode: "OVER_MAX_ATTACHMENTS") + } + + func test_createLogWithAttachment_serverError() throws { + givenFailingLogUploader() + givenLogController() + whenCreatingLogWithAttachment() + thenLogWithUnsuccessfulAttachmentIsCreatedCorrectly(errorCode: nil) + } + + func test_createLogWithPreuploadedAttachment() throws { + givenLogController() + whenCreatingLogWithPreUploadedAttachment() + thenLogWithPreuploadedAttachmentIsCreatedCorrectly() + } } private extension LogControllerTests { @@ -209,6 +254,8 @@ private extension LogControllerTests { controller: sessionController, config: config ) + + sut.otel = otelBridge } func givenLogController() { @@ -218,22 +265,30 @@ private extension LogControllerTests { controller: sessionController, config: config ) + + sut.otel = otelBridge } func givenEmbraceLogUploader() { upload = .init() - upload.stubbedCompletion = .success(()) + upload.stubbedLogCompletion = .success(()) + upload.stubbedAttachmentCompletion = .success(()) } func givenFailingLogUploader() { upload = .init() - upload.stubbedCompletion = .failure(RandomError()) + upload.stubbedLogCompletion = .failure(RandomError()) + upload.stubbedAttachmentCompletion = .failure(RandomError()) } func givenConfig(sdkEnabled: Bool = true) { config = EmbraceConfigMock.default(sdkEnabled: sdkEnabled) } + func givenOTelBridge() { + otelBridge = MockEmbraceOTelBridge() + } + func givenSessionControllerWithoutSession() { sessionController = .init() } @@ -267,6 +322,35 @@ private extension LogControllerTests { sut.batchFinished(withLogs: logs) } + func whenAttachmentLimitIsReached() { + sut.sessionController?.increaseAttachmentCount() + sut.sessionController?.increaseAttachmentCount() + sut.sessionController?.increaseAttachmentCount() + sut.sessionController?.increaseAttachmentCount() + sut.sessionController?.increaseAttachmentCount() + } + + func whenCreatingLog() { + sut.createLog("test", severity: .info) + } + + func whenCreatingLogWithAttachment() { + sut.createLog("test", severity: .info, attachment: TestConstants.data) + } + + func whenCreatingLogWithBigAttachment() { + var str = "" + for _ in 1...1048600 { + str += "." + } + sut.createLog("test", severity: .info, attachment: str.data(using: .utf8)!) + } + + func whenCreatingLogWithPreUploadedAttachment() { + let url = URL(string: "http//embrace.test.com/attachment/123", testName: testName)! + sut.createLog("test", severity: .info, attachmentId: UUID().withoutHyphen, attachmentUrl: url, attachmentSize: 12345) + } + func thenDoesntTryToUploadAnything() { XCTAssertFalse(upload.didCallUploadLog) } @@ -328,6 +412,49 @@ private extension LogControllerTests { XCTAssertEqual(unwrappedStorage.fetchPersonaTagsForProcessIdReceivedParameter, processId) } + func thenLogIsCreatedCorrectly() { + let log = otelBridge.otel.logs.first + XCTAssertNotNil(log) + XCTAssertEqual(log!.body!.description, "test") + XCTAssertEqual(log!.severity, .info) + XCTAssertEqual(log!.attributes["emb.type"]!.description, "sys.log") + } + + func thenLogWithSuccessfulAttachmentIsCreatedCorrectly() { + wait { + let log = self.otelBridge.otel.logs.first + + let attachmentIdFound = log!.attributes["emb.attachment_id"] != nil + let attachmentSizeFound = log!.attributes["emb.attachment_size"] != nil + + return attachmentIdFound && attachmentSizeFound + } + } + + func thenLogWithUnsuccessfulAttachmentIsCreatedCorrectly(errorCode: String?) { + wait { + let log = self.otelBridge.otel.logs.first + + let attachmentIdFound = log!.attributes["emb.attachment_id"] != nil + let attachmentSizeFound = log!.attributes["emb.attachment_size"] != nil + let attachmentErrorFound = errorCode == nil || log!.attributes["emb.attachment_error_code"]!.description == errorCode + + return attachmentIdFound && attachmentSizeFound && attachmentErrorFound + } + } + + func thenLogWithPreuploadedAttachmentIsCreatedCorrectly() { + wait { + let log = self.otelBridge.otel.logs.first + + let attachmentIdFound = log!.attributes["emb.attachment_id"] != nil + let attachmentUrlFound = log!.attributes["emb.attachment_url"] != nil + let attachmentSizeFound = log!.attributes["emb.attachment_size"] != nil + + return attachmentIdFound && attachmentUrlFound && attachmentSizeFound + } + } + func randomLogRecord(sessionId: SessionIdentifier? = nil) -> LogRecord { var attributes: [String: PersistableValue] = [:] diff --git a/Tests/EmbraceCoreTests/Session/SessionControllerTests.swift b/Tests/EmbraceCoreTests/Session/SessionControllerTests.swift index 9c5bbddd..b6c677c0 100644 --- a/Tests/EmbraceCoreTests/Session/SessionControllerTests.swift +++ b/Tests/EmbraceCoreTests/Session/SessionControllerTests.swift @@ -400,7 +400,8 @@ private extension SessionControllerTests { func testEndpointOptions(testName: String) -> EmbraceUpload.EndpointOptions { .init( spansURL: testSessionsUrl(testName: testName), - logsURL: testLogsUrl(testName: testName) + logsURL: testLogsUrl(testName: testName), + attachmentsURL: testAttachmentsUrl(testName: testName) ) } @@ -412,6 +413,10 @@ private extension SessionControllerTests { URL(string: "https://embrace.\(testName).com/session_controller/logs")! } + func testAttachmentsUrl(testName: String = #function) -> URL { + URL(string: "https://embrace.\(testName).com/session_controller/attachments")! + } + private var configBaseUrl: String { "https://embrace.\(testName).com/config" } diff --git a/Tests/EmbraceCoreTests/Session/UnsentDataHandlerTests.swift b/Tests/EmbraceCoreTests/Session/UnsentDataHandlerTests.swift index e64414be..0202fd8f 100644 --- a/Tests/EmbraceCoreTests/Session/UnsentDataHandlerTests.swift +++ b/Tests/EmbraceCoreTests/Session/UnsentDataHandlerTests.swift @@ -765,7 +765,8 @@ private extension UnsentDataHandlerTests { func testEndpointOptions(forTest testName: String) -> EmbraceUpload.EndpointOptions { .init( spansURL: testSpansUrl(forTest: testName), - logsURL: testLogsUrl(forTest: testName) + logsURL: testLogsUrl(forTest: testName), + attachmentsURL: testAttachmentsUrl(forTest: testName) ) } @@ -780,4 +781,10 @@ private extension UnsentDataHandlerTests { url.testName = testName return url } + + func testAttachmentsUrl(forTest testName: String = #function) -> URL { + var url = URL(string: "https://embrace.test.com/attachments")! + url.testName = testName + return url + } } diff --git a/Tests/EmbraceCoreTests/TestSupport/TestDoubles/MockSessionController.swift b/Tests/EmbraceCoreTests/TestSupport/TestDoubles/MockSessionController.swift index d363bc84..8ee2d786 100644 --- a/Tests/EmbraceCoreTests/TestSupport/TestDoubles/MockSessionController.swift +++ b/Tests/EmbraceCoreTests/TestSupport/TestDoubles/MockSessionController.swift @@ -69,4 +69,10 @@ class MockSessionController: SessionControllable { func onUpdateSession(_ callback: @escaping ((SessionRecord?, SessionState?, Bool?) -> Void)) { updateSessionCallback = callback } + + var attachmentCount: Int = 0 + + func increaseAttachmentCount() { + attachmentCount += 1 + } } diff --git a/Tests/EmbraceCoreTests/TestSupport/TestDoubles/SpyEmbraceLogUploader.swift b/Tests/EmbraceCoreTests/TestSupport/TestDoubles/SpyEmbraceLogUploader.swift index 39b424b7..2f568468 100644 --- a/Tests/EmbraceCoreTests/TestSupport/TestDoubles/SpyEmbraceLogUploader.swift +++ b/Tests/EmbraceCoreTests/TestSupport/TestDoubles/SpyEmbraceLogUploader.swift @@ -8,12 +8,24 @@ import EmbraceUploadInternal class SpyEmbraceLogUploader: EmbraceLogUploader { var didCallUploadLog = false var didCallUploadLogCount = 0 - var stubbedCompletion: (Result<(), Error>)? + var stubbedLogCompletion: (Result<(), Error>)? func uploadLog(id: String, data: Data, completion: ((Result<(), Error>) -> Void)?) { didCallUploadLogCount += 1 didCallUploadLog = true - if let result = stubbedCompletion { + if let result = stubbedLogCompletion { completion?(result) } } + + var didCallUploadAttachment = false + var didCallUploadAttachmentCount = 0 + var stubbedAttachmentCompletion: (Result<(), Error>)? + func uploadAttachment(id: String, data: Data, completion: ((Result<(), any Error>) -> Void)?) { + didCallUploadAttachmentCount += 1 + didCallUploadAttachment = true + if let result = stubbedAttachmentCompletion { + completion?(result) + } + } + } diff --git a/Tests/EmbraceOTelInternalTests/EmbraceOTelTests.swift b/Tests/EmbraceOTelInternalTests/EmbraceOTelTests.swift index b1a9a992..40905d98 100644 --- a/Tests/EmbraceOTelInternalTests/EmbraceOTelTests.swift +++ b/Tests/EmbraceOTelInternalTests/EmbraceOTelTests.swift @@ -2,7 +2,7 @@ import XCTest @testable import EmbraceOTelInternal @testable import EmbraceCore - +import EmbraceCommonInternal import OpenTelemetryApi import OpenTelemetrySdk import EmbraceStorageInternal @@ -10,11 +10,6 @@ import TestSupport final class EmbraceOTelTests: XCTestCase { - class DummyLogControllable: LogControllable { - func uploadAllPersistedLogs() {} - func batchFinished(withLogs logs: [LogRecord]) {} - } - var logExporter = InMemoryLogRecordExporter() override func setUpWithError() throws { @@ -90,40 +85,6 @@ final class EmbraceOTelTests: XCTestCase { XCTAssertTrue(first === second) } -// MARK: recordSpan with block - - func test_recordSpan_returnsGenericResult_whenInt() throws { - let otel = EmbraceOTel() - - let spanResult = otel.recordSpan(name: "math_test", type: .performance) { - var result = 0 - for i in 0...10 { - // 1 + 4 + 9 + 16 + 25 + 36 + 49 + 64 + 81 + 100 - result += i * i - } - - XCTAssertEqual(result, 385) - return result - } - - XCTAssertEqual(spanResult, 385) - } - - func test_recordSpan_returnsGenericResult_whenString() throws { - let otel = EmbraceOTel() - - let spanResult = otel.recordSpan(name: "math_test", type: .performance) { - for i in 0...10 { - // 1 + 4 + 9 + 16 + 25 + 36 + 49 + 64 + 81 + 100 - _ = i * i - } - - return "example_result" - } - - XCTAssertEqual(spanResult, "example_result") - } - // MARK: buildSpan // Test failing consistently in CI @@ -160,7 +121,7 @@ final class EmbraceOTelTests: XCTestCase { func test_log_emitsLogToExporter() throws { let otel = EmbraceOTel() - otel.log("example message", severity: .info, attributes: [:]) + otel.log("example message", severity: .info, timestamp: Date(), attributes: [:]) let record = logExporter.finishedLogRecords.first { $0.body == .string("example message") } XCTAssertNotNil(record) diff --git a/Tests/EmbraceOTelInternalTests/Logs/GenericLogExporterTests.swift b/Tests/EmbraceOTelInternalTests/Logs/GenericLogExporterTests.swift index 2fd2a99e..1fb9bc95 100644 --- a/Tests/EmbraceOTelInternalTests/Logs/GenericLogExporterTests.swift +++ b/Tests/EmbraceOTelInternalTests/Logs/GenericLogExporterTests.swift @@ -11,10 +11,6 @@ import OpenTelemetryApi import OpenTelemetrySdk final class GenericLogExporterTests: XCTestCase { - class DummyLogControllable: LogControllable { - func uploadAllPersistedLogs() {} - func batchFinished(withLogs logs: [LogRecord]) {} - } func test_genericExporter_isCalled_whenConfiguredInSharedState() throws { let exporter = InMemoryLogRecordExporter() diff --git a/Tests/EmbraceUploadInternalTests/EmbraceAttachmentOperationTests.swift b/Tests/EmbraceUploadInternalTests/EmbraceAttachmentOperationTests.swift new file mode 100644 index 00000000..e156daf6 --- /dev/null +++ b/Tests/EmbraceUploadInternalTests/EmbraceAttachmentOperationTests.swift @@ -0,0 +1,69 @@ +// +// Copyright © 2025 Embrace Mobile, Inc. All rights reserved. +// +import XCTest +import TestSupport +@testable import EmbraceUploadInternal + + +class EmbraceAttachmentUploadOperationTests: XCTestCase { + + let testMetadataOptions = EmbraceUpload.MetadataOptions( + apiKey: "apiKey", + userAgent: "userAgent", + deviceId: "12345678" + ) + + var urlSession: URLSession! + var queue: DispatchQueue! + + override func setUpWithError() throws { + let urlSessionconfig = URLSessionConfiguration.ephemeral + urlSessionconfig.httpMaximumConnectionsPerHost = .max + urlSessionconfig.protocolClasses = [EmbraceHTTPMock.self] + + self.urlSession = URLSession(configuration: urlSessionconfig) + self.queue = .main + } + + func test_createRequest() { + + let data = "12345".data(using: .utf8)! + let attachmentId = "987654321" + + let operation = EmbraceAttachmentUploadOperation( + urlSession: urlSession, + queue: queue, + metadataOptions: testMetadataOptions, + endpoint: TestConstants.url, + identifier: attachmentId, + data: data, + retryCount: 0, + exponentialBackoffBehavior: .init(), + attemptCount: 0 + ) + + let request = operation.createRequest( + endpoint: TestConstants.url, + data: data, + identifier: attachmentId, + metadataOptions: testMetadataOptions + ) + + XCTAssert(request.allHTTPHeaderFields!["Content-Type"]!.contains("multipart/form-data;")) + + let body = String(data: request.httpBody!, encoding: .utf8) + + // app id + XCTAssert(body!.contains("Content-Disposition: form-data; name=\"app_id\"")) + XCTAssert(body!.contains(testMetadataOptions.apiKey)) + + // attachment id + XCTAssert(body!.contains("Content-Disposition: form-data; name=\"attachment_id\"")) + XCTAssert(body!.contains(attachmentId)) + + // attachment data + XCTAssert(body!.contains("Content-Disposition: form-data; name=\"file\"; filename=\"987654321\"")) + XCTAssert(body!.contains("12345")) + } +} diff --git a/Tests/EmbraceUploadInternalTests/EmbraceUploadTests.swift b/Tests/EmbraceUploadInternalTests/EmbraceUploadTests.swift index d77409e5..114e043f 100644 --- a/Tests/EmbraceUploadInternalTests/EmbraceUploadTests.swift +++ b/Tests/EmbraceUploadInternalTests/EmbraceUploadTests.swift @@ -201,6 +201,7 @@ class EmbraceUploadTests: XCTestCase { // then requests are made XCTAssertEqual(EmbraceHTTPMock.requestsForUrl(testSpansUrl()).count, 1) XCTAssertEqual(EmbraceHTTPMock.requestsForUrl(testLogsUrl()).count, 1) + XCTAssertEqual(EmbraceHTTPMock.requestsForUrl(testAttachmentsUrl()).count, 0) } func test_retryCachedData_emptyCache() throws { @@ -214,6 +215,7 @@ class EmbraceUploadTests: XCTestCase { // then no requests are made XCTAssertEqual(EmbraceHTTPMock.requestsForUrl(testSpansUrl()).count, 0) XCTAssertEqual(EmbraceHTTPMock.requestsForUrl(testLogsUrl()).count, 0) + XCTAssertEqual(EmbraceHTTPMock.requestsForUrl(testAttachmentsUrl()).count, 0) } func test_spansEndpoint() throws { @@ -225,6 +227,7 @@ class EmbraceUploadTests: XCTestCase { // then a request to the right endpoint is made XCTAssertEqual(EmbraceHTTPMock.requestsForUrl(testSpansUrl()).count, 1) XCTAssertEqual(EmbraceHTTPMock.requestsForUrl(testLogsUrl()).count, 0) + XCTAssertEqual(EmbraceHTTPMock.requestsForUrl(testAttachmentsUrl()).count, 0) } func test_logsEndpoint() throws { @@ -236,6 +239,19 @@ class EmbraceUploadTests: XCTestCase { // then a request to the right endpoint is made XCTAssertEqual(EmbraceHTTPMock.requestsForUrl(testSpansUrl()).count, 0) XCTAssertEqual(EmbraceHTTPMock.requestsForUrl(testLogsUrl()).count, 1) + XCTAssertEqual(EmbraceHTTPMock.requestsForUrl(testAttachmentsUrl()).count, 0) + } + + func test_attachmentsEndpoint() throws { + // when uploading attachment data + module.uploadAttachment(id: "id", data: TestConstants.data, completion: nil) + + wait(delay: .defaultTimeout) + + // then a request to the right endpoint is made + XCTAssertEqual(EmbraceHTTPMock.requestsForUrl(testSpansUrl()).count, 0) + XCTAssertEqual(EmbraceHTTPMock.requestsForUrl(testLogsUrl()).count, 0) + XCTAssertEqual(EmbraceHTTPMock.requestsForUrl(testAttachmentsUrl()).count, 1) } } @@ -248,10 +264,15 @@ private extension EmbraceUploadTests { URL(string: "https://embrace.\(testName).com/upload/logs")! } + func testAttachmentsUrl(testName: String = #function) -> URL { + URL(string: "https://embrace.\(testName).com/upload/attachments")! + } + func testEndpointOptions(testName: String) -> EmbraceUpload.EndpointOptions { .init( spansURL: testSpansUrl(testName: testName), - logsURL: testLogsUrl(testName: testName) + logsURL: testLogsUrl(testName: testName), + attachmentsURL: testAttachmentsUrl(testName: testName) ) } } diff --git a/Tests/TestSupport/Mocks/DummyLogControllable.swift b/Tests/TestSupport/Mocks/DummyLogControllable.swift new file mode 100644 index 00000000..c38e58fc --- /dev/null +++ b/Tests/TestSupport/Mocks/DummyLogControllable.swift @@ -0,0 +1,30 @@ +// +// Copyright © 2025 Embrace Mobile, Inc. All rights reserved. +// + +import Foundation +@testable import EmbraceCore +import EmbraceCommonInternal +import EmbraceStorageInternal + +public class DummyLogControllable: LogControllable { + + public init() {} + + public func uploadAllPersistedLogs() {} + + public func createLog( + _ message: String, + severity: LogSeverity, + type: LogType, + timestamp: Date, + attachment: Data?, + attachmentId: String?, + attachmentUrl: URL?, + attachmentSize: Int?, + attributes: [String : String], + stackTraceBehavior: StackTraceBehavior + ) { } + + public func batchFinished(withLogs logs: [LogRecord]) {} +} diff --git a/Tests/TestSupport/Mocks/MockEmbraceOTelBridge.swift b/Tests/TestSupport/Mocks/MockEmbraceOTelBridge.swift new file mode 100644 index 00000000..7e17ef14 --- /dev/null +++ b/Tests/TestSupport/Mocks/MockEmbraceOTelBridge.swift @@ -0,0 +1,24 @@ +// +// Copyright © 2025 Embrace Mobile, Inc. All rights reserved. +// + +import Foundation +@testable import EmbraceOTelInternal +import EmbraceCommonInternal +import OpenTelemetryApi +import OpenTelemetrySdk + +public class MockEmbraceOTelBridge: EmbraceOTelBridge { + + public let otel = MockEmbraceOpenTelemetry() + + public init() {} + + public func buildSpan(name: String, type: SpanType, attributes: [String : String]) -> any SpanBuilder { + return otel.buildSpan(name: name, type: type, attributes: attributes) + } + + public func log(_ message: String, severity: LogSeverity, timestamp: Date, attributes: [String : String]) { + otel.log(message, severity: severity, timestamp: timestamp, attributes: attributes) + } +} diff --git a/Tests/TestSupport/Mocks/MockEmbraceOpenTelemetry.swift b/Tests/TestSupport/Mocks/MockEmbraceOpenTelemetry.swift index e838d8eb..17f226f4 100644 --- a/Tests/TestSupport/Mocks/MockEmbraceOpenTelemetry.swift +++ b/Tests/TestSupport/Mocks/MockEmbraceOpenTelemetry.swift @@ -10,7 +10,7 @@ import OpenTelemetryApi import OpenTelemetrySdk import EmbraceSemantics -public class MockEmbraceOpenTelemetry: NSObject, EmbraceOpenTelemetry { +public class MockEmbraceOpenTelemetry: NSObject, EmbraceOpenTelemetry { private(set) public var spanProcessor = MockSpanProcessor() private(set) public var events: [SpanEvent] = [] private(set) public var logs: [ReadableLogRecord] = [] @@ -100,4 +100,30 @@ public class MockEmbraceOpenTelemetry: NSObject, EmbraceOpenTelemetry { logs.append(log) } + + public func log( + _ message: String, + severity: LogSeverity, + type: LogType = .performance, + timestamp: Date = Date(), + attachment: Data, + attributes: [String : String] = [:], + stackTraceBehavior: StackTraceBehavior = .default + ) { + + } + + public func log( + _ message: String, + severity: LogSeverity, + type: LogType = .performance, + timestamp: Date = Date(), + attachmentId: String, + attachmentUrl: URL, + attachmentSize: Int?, + attributes: [String : String] = [:], + stackTraceBehavior: StackTraceBehavior = .default + ) { + + } }