Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 CHANGELOG version conflicts with PR title

The changelog entry is ## 0.18.0 but the PR is titled 0.17.1. Since this change introduces three new public types (PlayolaNetworkLogEvent, PlayolaNetworkLogger, PlayolaNetworkLoggingSession) it qualifies as a minor version bump under SemVer, making 0.18.0 the semantically correct version — but the PR title and any git tag should match. One of the two needs to be updated before the tag is pushed, otherwise consumers pinning to the version string will get mismatched release notes.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

### 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.
76 changes: 76 additions & 0 deletions Sources/PlayolaCore/PlayolaNetworkLogger.swift
Original file line number Diff line number Diff line change
@@ -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 } }
}
}
63 changes: 63 additions & 0 deletions Sources/PlayolaCore/Protocols/PlayolaNetworkLoggingSession.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
4 changes: 2 additions & 2 deletions Sources/PlayolaPlayer/Player/ListeningSessionReporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Comment on lines +405 to +406

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Logging session created per call, not injectable

A fresh PlayolaNetworkLoggingSession is constructed on every schedule fetch. PlayolaNetworkLoggingSession is a lightweight struct so this is not a performance concern, but it means there is no way to swap the session out in tests the way ListeningSessionReporter allows via its urlSession parameter. If testability for this specific request path ever matters, consider storing an injectable urlSession property on PlayolaStationPlayer and assigning PlayolaNetworkLoggingSession(wrapping: tls12Session) as its default, mirroring the pattern already used in ListeningSessionReporter.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

let httpResponse = try validateHTTPResponse(response, url: url)
try await validateStatusCode(httpResponse, data: data, stationId: stationId)

Expand Down
142 changes: 142 additions & 0 deletions Tests/PlayolaPlayerTests/PlayolaNetworkLoggingSessionTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
Loading