Skip to content

Commit

Permalink
RUMM-110 Add basic components for logs upload
Browse files Browse the repository at this point in the history
  • Loading branch information
ncreated committed Dec 16, 2019
1 parent b293145 commit c5657e5
Show file tree
Hide file tree
Showing 21 changed files with 723 additions and 34 deletions.
4 changes: 4 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import PackageDescription

let package = Package(
name: "Datadog",
platforms: [
.iOS(.v11),
.macOS(.v10_12),
],
products: [
// Products define the executables and libraries produced by a package, and make them visible to other packages.
.library(
Expand Down
33 changes: 32 additions & 1 deletion Sources/Datadog/Datadog.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,36 @@
import Foundation

/// An exception thrown during Datadog SDK initialization if configuration is invalid. Check `description` for details.
public struct DatadogInitializationException: Error {
/// Describes the reason of failed initialization.
public let description: String
}

/// Datadog SDK configuration object.
public struct Datadog {
public init() {}
/// URL to upload logs to.
internal let logsUploadURL: URL

public init(
logsEndpoint: String,
clientToken: String
) throws {
self.logsUploadURL = try buildLogsUploadURLOrThrow(logsEndpoint: logsEndpoint, clientToken: clientToken)
}
}

private func buildLogsUploadURLOrThrow(logsEndpoint: String, clientToken: String) throws -> URL {
guard !logsEndpoint.isEmpty else {
throw DatadogInitializationException(description: "`logsEndpoint` cannot be empty.")
}
guard !clientToken.isEmpty else {
throw DatadogInitializationException(description: "`clientToken` cannot be empty.")
}
guard let endpointWithClientToken = URL(string: logsEndpoint)?.appendingPathComponent(clientToken) else {
throw DatadogInitializationException(description: "Invalid `logsEndpoint` or `clientToken`.")
}
guard let url = URL(string: "\(endpointWithClientToken.absoluteString)?ddsource=mobile") else {
throw DatadogInitializationException(description: "Cannot build logs upload URL.")
}
return url
}
19 changes: 0 additions & 19 deletions Sources/Datadog/Logger/Logger.swift

This file was deleted.

9 changes: 9 additions & 0 deletions Sources/Datadog/Logs/Log.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Foundation

/// Representation of log uploaded to server.
struct Log: Codable, Equatable {
let date: Date
let status: String
let message: String
let service: String
}
45 changes: 45 additions & 0 deletions Sources/Datadog/Logs/Logger.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import Foundation

public class Logger {
private let uploader: LogsUploader

public convenience init(configuration: Datadog) {
self.init(
uploader: LogsUploader(
configuration: configuration,
httpClient: HTTPClient(transport: URLSessionTransport())
)
)
}

internal init(uploader: LogsUploader) {
self.uploader = uploader
}

/// Logs INFO message.
///
/// - Parameter message: the message
public func info(_ message: String) {
log(status: "INFO", message: message)
}

private func log(status: String, message: String) {
let log = Log(date: Date(), status: status, message: message, service: "ios-sdk-test-service")
do {
try uploader.upload(logs: [log]) { (status) in
print("ℹ️ logs delivery status: \(status)")
}
} catch {
print("🔥 logs not delivered due to: \(error)")
}
}
}

//private func createLog() -> Log {
// return Log(
// date: ISO8601DateFormatter().string(from: Date()),
// status: "INFO",
// message: "Random value: \(Int.random(in: 100..<200))",
// service: "ios-app-example"
// )
//}
30 changes: 30 additions & 0 deletions Sources/Datadog/Logs/Upload/LogsDeliveryStatus.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import Foundation

enum LogsDeliveryStatus: Equatable {
/// Corresponds to HTTP 2xx response status codes.
case success(logs: [Log])
/// Corresponds to HTTP 3xx response status codes.
case redirection(logs: [Log])
/// Corresponds to HTTP 4xx response status codes.
case clientError(logs: [Log])
/// Corresponds to HTTP 5xx response status codes.
case serverError(logs: [Log])
/// Means transportation error and no delivery at all.
case networkError(logs: [Log])
/// Corresponds to unknown HTTP response status code.
case unknown(logs: [Log])

init(from httpResponse: HTTPResponse, logs: [Log]) {
switch httpResponse.code {
case 200...299: self = .success(logs: logs)
case 300...399: self = .redirection(logs: logs)
case 400...499: self = .clientError(logs: logs)
case 500...599: self = .serverError(logs: logs)
default: self = .unknown(logs: logs)
}
}

init(from httpRequestDeliveryError: HTTPRequestDeliveryError, logs: [Log]) {
self = .networkError(logs: logs)
}
}
19 changes: 19 additions & 0 deletions Sources/Datadog/Logs/Upload/LogsUploadRequestEncoder.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Foundation

/// Builds `HTTPRequest` for sending logs to the server.
struct LogsUploadRequestEncoder {
private let url: URL
private let headers = ["Content-Type": "application/json"]
private let method = "POST"
private let jsonEncoder: JSONEncoder

init(uploadURL: URL) {
self.url = uploadURL
self.jsonEncoder = JSONEncoder()
jsonEncoder.dateEncodingStrategy = .iso8601
}

func encodeRequest(with logs: [Log]) throws -> HTTPRequest {
return HTTPRequest(url: url, headers: headers, method: method, body: try jsonEncoder.encode(logs))
}
}
25 changes: 25 additions & 0 deletions Sources/Datadog/Logs/Upload/LogsUploader.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Foundation

/// Sends logs to server.
final class LogsUploader {

private let httpClient: HTTPClient
private let requestBuilder: LogsUploadRequestEncoder

init(configuration: Datadog, httpClient: HTTPClient) {
self.httpClient = httpClient
self.requestBuilder = LogsUploadRequestEncoder(uploadURL: configuration.logsUploadURL)
}

func upload(logs: [Log], completion: @escaping (LogsDeliveryStatus) -> Void) throws {
let request = try requestBuilder.encodeRequest(with: logs)
httpClient.send(request: request) { (result) in
switch result {
case .success(let httpResponse):
completion(LogsDeliveryStatus(from: httpResponse, logs: logs))
case .failure(let httpRequestDeliveryError):
completion(LogsDeliveryStatus(from: httpRequestDeliveryError, logs: logs))
}
}
}
}
51 changes: 40 additions & 11 deletions Sources/Datadog/Network/HTTPClient.swift
Original file line number Diff line number Diff line change
@@ -1,19 +1,48 @@
import Foundation

/// Basic HTTP request representation - limited to information that SDK needs to use.
struct HTTPRequest {
let url: URL
let headers: [String: String]
let method: String
let body: Data
}

/// Basic HTTP response representation - limited to information that SDK needs to use.
struct HTTPResponse {
let code: Int
}

/// Error related to request delivery, like unreachable server, no internet connection etc.
struct HTTPRequestDeliveryError: Error {
let details: Error
}

/// Client for sending requests over HTTP.
final class HTTPClient {
private let transport: HTTPTransport

private let session: URLSession

init() {
self.session = URLSession(configuration: .default)
init(transport: HTTPTransport) {
self.transport = transport
}

func send(request: URLRequest) {
let task = session.dataTask(with: request) { (data, response, error) in
print("🔥 error: \(error.debugDescription)")
print("⭐️ response: \(response?.description ?? "")")
print("⭐️ data of size: \(data?.count ?? 0)")

func send(request: HTTPRequest, completion: @escaping (Result<HTTPResponse, HTTPRequestDeliveryError>) -> Void) {
let urlRequest = buildURLRequest(from: request)
transport.send(request: urlRequest) { result in
switch result {
case .response(let response, _):
completion(.success(HTTPResponse(code: response.statusCode)))
case .error(let error, _):
completion(.failure(HTTPRequestDeliveryError(details: error)))
}
}
task.resume()
}

private func buildURLRequest(from httpRequest: HTTPRequest) -> URLRequest {
var request = URLRequest(url: httpRequest.url)
request.httpMethod = httpRequest.method
request.allHTTPHeaderFields = httpRequest.headers
request.httpBody = httpRequest.body
return request
}
}
14 changes: 14 additions & 0 deletions Sources/Datadog/Network/Transport/HTTPTransport.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import Foundation

/// A result of request sending request with `HTTPTransport`.
enum HTTPTransportResult {
/// Means successful request delivery.
case response(HTTPURLResponse, Data?)
/// Means transportation error (unreachable server, no internet connection, ...).
case error(Error, Data?)
}

/// A type sending requests over HTTP.
protocol HTTPTransport {
func send(request: URLRequest, callback: @escaping (HTTPTransportResult) -> Void)
}
43 changes: 43 additions & 0 deletions Sources/Datadog/Network/Transport/URLSessionTransport.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import Foundation

/// An implementation of `HTTPTransport` which uses `URLSession` for requests delivery.
final class URLSessionTransport: HTTPTransport {
private let session: URLSession

convenience init() {
let configuration: URLSessionConfiguration = .ephemeral
// TODO: RUMM-123 Optimize `URLSessionConfiguration` for good traffic performance
self.init(session: URLSession(configuration: configuration))
}

init(session: URLSession) {
self.session = session
}

func send(request: URLRequest, callback: @escaping (HTTPTransportResult) -> Void) {
let task = session.dataTask(with: request) { (data, response, error) in
callback(transportResult(for: (data, response, error)))
}
task.resume()
}
}

/// An error returned if given `URLSession` response state is inconsistent (like no data, no response and no error).
/// The code execution in `URLSessionTransport` should never reach its initialization.
struct URLSessionTransportInconsistencyException: Error {}

/// As `URLSession` returns 3-values-touple for request execution, this function applies consistency constraints and turns
/// it into only two possible states of `HTTPTransportResult`.
private func transportResult(for urlSessionTaskCompletion: (Data?, URLResponse?, Error?)) -> HTTPTransportResult {
let (data, response, error) = urlSessionTaskCompletion

if let error = error {
return .error(error, data)
}

if let httpResponse = response as? HTTPURLResponse, let data = data {
return .response(httpResponse, data)
}

return .error(URLSessionTransportInconsistencyException(), data)
}
17 changes: 17 additions & 0 deletions Tests/DatadogTests/DatadogMocks.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Foundation
@testable import Datadog

/*
A collection of mock configurations for SDK.
It follows the mocking conventions described in `FoundationMocks.swift`.
*/

extension Datadog {
static func mockAny() -> Datadog {
return .mockUsing(logsEndpoint: "https://api.example.com/v1", clientToken: "abcdefghi")
}

static func mockUsing(logsEndpoint: String, clientToken: String) -> Datadog {
return try! Datadog(logsEndpoint: logsEndpoint, clientToken: clientToken)
}
}
29 changes: 27 additions & 2 deletions Tests/DatadogTests/DatadogTests.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,33 @@
import XCTest
@testable import Datadog

class DatadogTests: XCTestCase {

func testItDoesSomething() {
// 🐶
func testWhenCorrectEndpointAndClientTokenAreSet_itBuildsLogsUploadURL() throws {
let datadog1 = try Datadog(
logsEndpoint: "https://api.example.com/v1/logs/",
clientToken: "abcdefghi"
)
XCTAssertEqual(datadog1.logsUploadURL, URL(string: "https://api.example.com/v1/logs/abcdefghi?ddsource=mobile")!)
XCTAssertEqual(datadog1.logsUploadURL.query, "ddsource=mobile")

let datadog2 = try Datadog(
logsEndpoint: "https://api.example.com/v1/logs", // not normalized URL
clientToken: "abcdefghi"
)
XCTAssertEqual(datadog2.logsUploadURL, URL(string: "https://api.example.com/v1/logs/abcdefghi?ddsource=mobile")!)
XCTAssertEqual(datadog2.logsUploadURL.query, "ddsource=mobile")
}

func testWhenEmptyClientTokenIsNotSet_itThrows() {
XCTAssertThrowsError(try Datadog(logsEndpoint: "https://api.example.com/v1/logs", clientToken: "")) { (error) in
XCTAssertTrue((error as? DatadogInitializationException)?.description == "`clientToken` cannot be empty.")
}
}

func testWhenLogsEndpointIsNotSet_itThrows() {
XCTAssertThrowsError(try Datadog(logsEndpoint: "", clientToken: "abcdefghi")) { (error) in
XCTAssertTrue((error as? DatadogInitializationException)?.description == "`logsEndpoint` cannot be empty.")
}
}
}
Loading

0 comments on commit c5657e5

Please sign in to comment.