diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..bac59ca --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,29 @@ +# Changelog + +All notable changes to PlayolaPlayer are documented here. This project follows +[Semantic Versioning](https://semver.org/). Versions correspond to git tags, +which Swift Package Manager consumers pin to. + +## 0.18.0 + +### Added + +- **Network logging seam** for observing the library's JSON API traffic + (binary audio downloads are intentionally excluded). New public API in + `PlayolaCore`: + - `PlayolaNetworkLogEvent` — a `Sendable` value describing a single + request/response (method, url, request headers/body, status code, + response body, duration, and any error description). Events are raw and + unredacted; the consuming app is responsible for redacting sensitive + values such as `Authorization` headers before storing them. + - `PlayolaNetworkLogger.handler` — a thread-safe, `Sendable` hook the + consuming app sets at startup to receive events. `nil` (the default) + disables logging, leaving behavior and performance unchanged. + - `PlayolaNetworkLoggingSession` — a transparent `URLSessionProtocol` + wrapper that times each call and emits an event on both success and + thrown error without altering return values or rethrown errors. + + The schedule fetch (`PlayolaStationPlayer`) and the listening-session + reporter (`ListeningSessionReporter`) now route their JSON API calls through + this wrapper by default. No breaking API changes: existing initializers and + call sites compile unchanged. diff --git a/Sources/PlayolaCore/PlayolaNetworkLogger.swift b/Sources/PlayolaCore/PlayolaNetworkLogger.swift new file mode 100644 index 0000000..f62aba9 --- /dev/null +++ b/Sources/PlayolaCore/PlayolaNetworkLogger.swift @@ -0,0 +1,76 @@ +// +// PlayolaNetworkLogger.swift +// PlayolaPlayer +// +// Created by Brian D Keane on 6/4/26. +// +import Foundation +import os + +/// A single JSON API request/response observed by the library. +/// +/// The library emits these raw — it does **not** redact, persist, or format +/// anything. A consuming app is responsible for redacting sensitive values +/// (e.g. `Authorization` headers) before storing or displaying them. +public struct PlayolaNetworkLogEvent: Sendable { + /// The time the request started. + public let timestamp: Date + /// The HTTP method (e.g. `GET`, `POST`). + public let method: String + /// The request URL, if available. + public let url: URL? + /// All HTTP header fields sent with the request, unredacted. + public let requestHeaders: [String: String] + /// The raw request body, if any. + public let requestBody: Data? + /// The HTTP status code of the response, or `nil` if the request threw. + public let statusCode: Int? + /// The raw response body, or `nil` if the request threw. + public let responseBody: Data? + /// The wall-clock duration of the request in milliseconds. + public let durationMS: Int? + /// A description of the thrown error, or `nil` on success. + public let errorDescription: String? + + public init( + timestamp: Date, + method: String, + url: URL?, + requestHeaders: [String: String], + requestBody: Data?, + statusCode: Int?, + responseBody: Data?, + durationMS: Int?, + errorDescription: String? + ) { + self.timestamp = timestamp + self.method = method + self.url = url + self.requestHeaders = requestHeaders + self.requestBody = requestBody + self.statusCode = statusCode + self.responseBody = responseBody + self.durationMS = durationMS + self.errorDescription = errorDescription + } +} + +/// A logging seam for the library's JSON API traffic. +/// +/// A consuming app sets ``handler`` at startup to observe the library's JSON +/// API calls (binary audio downloads are intentionally excluded). When +/// ``handler`` is `nil` (the default) no logging occurs and behavior and +/// performance are unchanged. +public enum PlayolaNetworkLogger { + private static let _handler = + OSAllocatedUnfairLock<(@Sendable (PlayolaNetworkLogEvent) -> Void)?>(initialState: nil) + + /// The handler invoked after each JSON API request completes. + /// + /// Set by the consuming app at startup. `nil` (the default) disables logging. + /// Access is thread-safe. + public static var handler: (@Sendable (PlayolaNetworkLogEvent) -> Void)? { + get { _handler.withLock { $0 } } + set { _handler.withLock { $0 = newValue } } + } +} diff --git a/Sources/PlayolaCore/Protocols/PlayolaNetworkLoggingSession.swift b/Sources/PlayolaCore/Protocols/PlayolaNetworkLoggingSession.swift new file mode 100644 index 0000000..db5dd12 --- /dev/null +++ b/Sources/PlayolaCore/Protocols/PlayolaNetworkLoggingSession.swift @@ -0,0 +1,63 @@ +// +// PlayolaNetworkLoggingSession.swift +// PlayolaPlayer +// +// Created by Brian D Keane on 6/4/26. +// +import Foundation + +/// A ``URLSessionProtocol`` wrapper that times each call and emits a +/// ``PlayolaNetworkLogEvent`` to ``PlayolaNetworkLogger/handler`` on both +/// success and thrown error. +/// +/// It is a transparent pass-through: it returns exactly what the wrapped +/// session returns and rethrows any error unchanged — it never swallows or +/// alters the result. When ``PlayolaNetworkLogger/handler`` is `nil` no event +/// is built and the wrapper adds negligible overhead. +public struct PlayolaNetworkLoggingSession: URLSessionProtocol, Sendable { + private let base: any URLSessionProtocol & Sendable + private let dateProvider: DateProviderProtocol + + public init( + wrapping base: any URLSessionProtocol & Sendable, + dateProvider: DateProviderProtocol = DateProvider.shared + ) { + self.base = base + self.dateProvider = dateProvider + } + + public func data(for request: URLRequest) async throws -> (Data, URLResponse) { + let start = dateProvider.now() + do { + let (data, response) = try await base.data(for: request) + emit(request: request, start: start, data: data, response: response, error: nil) + return (data, response) + } catch { + emit(request: request, start: start, data: nil, response: nil, error: error) + throw error + } + } + + private func emit( + request: URLRequest, + start: Date, + data: Data?, + response: URLResponse?, + error: Error? + ) { + guard let handler = PlayolaNetworkLogger.handler else { return } + let durationMS = Int(dateProvider.now().timeIntervalSince(start) * 1000) + let event = PlayolaNetworkLogEvent( + timestamp: start, + method: request.httpMethod ?? "GET", + url: request.url, + requestHeaders: request.allHTTPHeaderFields ?? [:], + requestBody: request.httpBody, + statusCode: (response as? HTTPURLResponse)?.statusCode, + responseBody: data, + durationMS: durationMS, + errorDescription: error?.localizedDescription + ) + handler(event) + } +} diff --git a/Sources/PlayolaPlayer/Player/ListeningSessionReporter.swift b/Sources/PlayolaPlayer/Player/ListeningSessionReporter.swift index ea3c9cc..a1e3c57 100644 --- a/Sources/PlayolaPlayer/Player/ListeningSessionReporter.swift +++ b/Sources/PlayolaPlayer/Player/ListeningSessionReporter.swift @@ -66,7 +66,7 @@ public class ListeningSessionReporter { init( stationPlayer: PlayolaStationPlayer, authProvider: PlayolaAuthenticationProvider? = nil, - urlSession: URLSessionProtocol = tls12Session, + urlSession: URLSessionProtocol = PlayolaNetworkLoggingSession(wrapping: tls12Session), baseURL: URL = URL(string: "https://admin-api.playola.fm")! ) { self.stationPlayer = stationPlayer @@ -307,7 +307,7 @@ public class ListeningSessionReporter { #if DEBUG internal init( authProvider: PlayolaAuthenticationProvider? = nil, - urlSession: URLSessionProtocol = tls12Session, + urlSession: URLSessionProtocol = PlayolaNetworkLoggingSession(wrapping: tls12Session), baseURL: URL = URL(string: "https://admin-api.playola.fm")! ) { self.stationPlayer = nil diff --git a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift index 2f893b3..072a8f5 100644 --- a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift +++ b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift @@ -402,7 +402,8 @@ final public class PlayolaStationPlayer: ObservableObject { let url = createScheduleURL(for: stationId) do { - let (data, response) = try await tls12Session.data(from: url) + let loggingSession = PlayolaNetworkLoggingSession(wrapping: tls12Session) + let (data, response) = try await loggingSession.data(for: URLRequest(url: url)) let httpResponse = try validateHTTPResponse(response, url: url) try await validateStatusCode(httpResponse, data: data, stationId: stationId) diff --git a/Tests/PlayolaPlayerTests/PlayolaNetworkLoggingSessionTests.swift b/Tests/PlayolaPlayerTests/PlayolaNetworkLoggingSessionTests.swift new file mode 100644 index 0000000..aa0db3b --- /dev/null +++ b/Tests/PlayolaPlayerTests/PlayolaNetworkLoggingSessionTests.swift @@ -0,0 +1,142 @@ +// +// PlayolaNetworkLoggingSessionTests.swift +// PlayolaPlayer +// +// Created by Brian D Keane on 6/4/26. +// +import Foundation +import PlayolaCore +import Testing +import os + +@testable import PlayolaPlayer + +/// Records events emitted to `PlayolaNetworkLogger.handler` in a thread-safe way. +private final class EventRecorder: Sendable { + private let storage = OSAllocatedUnfairLock<[PlayolaNetworkLogEvent]>(initialState: []) + func record(_ event: PlayolaNetworkLogEvent) { storage.withLock { $0.append(event) } } + var events: [PlayolaNetworkLogEvent] { storage.withLock { $0 } } +} + +/// A `DateProviderProtocol` that returns queued dates in order, falling back to +/// `Date()` once exhausted. Lets tests assert an exact `durationMS`. +private final class QueueDateProvider: Sendable, DateProviderProtocol { + private let dates: OSAllocatedUnfairLock<[Date]> + init(_ dates: [Date]) { self.dates = OSAllocatedUnfairLock(initialState: dates) } + func now() -> Date { + dates.withLock { $0.isEmpty ? Date() : $0.removeFirst() } + } +} + +/// A session that always throws, to verify error pass-through. +private struct ThrowingURLSession: URLSessionProtocol { + let error: any Error & Sendable + func data(for request: URLRequest) async throws -> (Data, URLResponse) { throw error } +} + +private struct StubError: Error, Equatable { + let id: Int +} + +@Suite("PlayolaNetworkLoggingSession", .serialized) +struct PlayolaNetworkLoggingSessionTests { + + @Test("Emits an event on success with correct method, url, status, and duration") + func emitsEventOnSuccess() async throws { + let recorder = EventRecorder() + PlayolaNetworkLogger.handler = { recorder.record($0) } + defer { PlayolaNetworkLogger.handler = nil } + + let url = URL(string: "https://test.com/v1/listeningSessions")! + let requestBody = Data("{\"deviceId\":\"abc\"}".utf8) + let responseBody = Data("{\"ok\":true}".utf8) + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.httpBody = requestBody + request.addValue("Bearer secret-token", forHTTPHeaderField: "Authorization") + + let base = MockURLSession() + base.addResponse(data: responseBody, statusCode: 200, url: url) + + let start = Date(timeIntervalSince1970: 1000) + let session = PlayolaNetworkLoggingSession( + wrapping: base, + dateProvider: QueueDateProvider([start, start.addingTimeInterval(0.25)])) + + _ = try await session.data(for: request) + + let events = recorder.events + #expect(events.count == 1) + let event = try #require(events.first) + #expect(event.method == "POST") + #expect(event.url == url) + #expect(event.statusCode == 200) + #expect(event.durationMS == 250) + #expect(event.timestamp == start) + #expect(event.requestBody == requestBody) + #expect(event.responseBody == responseBody) + #expect(event.requestHeaders["Authorization"] == "Bearer secret-token") + #expect(event.errorDescription == nil) + } + + @Test("Transparently passes through the wrapped session's data and response") + func passesThroughData() async throws { + PlayolaNetworkLogger.handler = nil + + let url = URL(string: "https://test.com/v1/stations/123/schedule")! + let responseBody = Data("schedule-json".utf8) + + let base = MockURLSession() + base.addResponse(data: responseBody, statusCode: 201, url: url) + + let session = PlayolaNetworkLoggingSession(wrapping: base) + let (data, response) = try await session.data(for: URLRequest(url: url)) + + #expect(data == responseBody) + #expect((response as? HTTPURLResponse)?.statusCode == 201) + #expect(base.requestCallCount == 1) + } + + @Test("Emits an event on a thrown error and rethrows the original error unchanged") + func emitsEventOnErrorAndRethrows() async throws { + let recorder = EventRecorder() + PlayolaNetworkLogger.handler = { recorder.record($0) } + defer { PlayolaNetworkLogger.handler = nil } + + let url = URL(string: "https://test.com/v1/listeningSessions")! + let thrownError = StubError(id: 42) + let session = PlayolaNetworkLoggingSession(wrapping: ThrowingURLSession(error: thrownError)) + + var caught: Error? + do { + _ = try await session.data(for: URLRequest(url: url)) + } catch { + caught = error + } + + #expect(caught as? StubError == thrownError) + + let events = recorder.events + #expect(events.count == 1) + let event = try #require(events.first) + #expect(event.statusCode == nil) + #expect(event.responseBody == nil) + #expect(event.errorDescription != nil) + #expect(event.url == url) + } + + @Test("Does not emit when no handler is set") + func doesNotEmitWithoutHandler() async throws { + PlayolaNetworkLogger.handler = nil + + let url = URL(string: "https://test.com")! + let base = MockURLSession() + base.addResponse(data: Data(), statusCode: 200, url: url) + + let session = PlayolaNetworkLoggingSession(wrapping: base) + _ = try await session.data(for: URLRequest(url: url)) + + #expect(base.requestCallCount == 1) + } +} diff --git a/Tests/PlayolaPlayerTests/Test Utilities/MockUrlSession.swift b/Tests/PlayolaPlayerTests/Test Utilities/MockUrlSession.swift index 6a40c27..dd174b0 100644 --- a/Tests/PlayolaPlayerTests/Test Utilities/MockUrlSession.swift +++ b/Tests/PlayolaPlayerTests/Test Utilities/MockUrlSession.swift @@ -8,7 +8,7 @@ import Foundation @testable import PlayolaPlayer -class MockURLSession: URLSessionProtocol { +final class MockURLSession: URLSessionProtocol, @unchecked Sendable { var responses: [(Data, URLResponse)] = [] var requestCallCount = 0 var lastRequest: URLRequest?