Skip to content
Draft
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
2 changes: 1 addition & 1 deletion Sources/DatadogSDKTesting/KnownTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ struct KnownTestsFactory: FeatureFactory {
repositoryURL: repository,
configurations: configurations,
customConfigurations: customConfigurations,
observer: telemetry?.knownTestsRequestObserver).tests
observer: telemetry?.knownTestsRequestObserver)
} catch {
throw LibraryConfigurationCommunicationError(
requestName: "Known Tests Request",
Expand Down
6 changes: 6 additions & 0 deletions Sources/DatadogSDKTesting/Telemetry/TelemetryMetrics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -556,13 +556,19 @@ extension Telemetry.Metrics {
let requestMs: Telemetry.Distribution<Telemetry.EmptyMetricTags>
let responseBytes: Telemetry.Distribution<Telemetry.EmptyMetricTags>
let responseTests: Telemetry.Distribution<Telemetry.EmptyMetricTags>
let pagesFetched: Telemetry.Distribution<Telemetry.EmptyMetricTags>
let totalFetchMs: Telemetry.Distribution<Telemetry.EmptyMetricTags>
let totalRequestMs: Telemetry.Distribution<Telemetry.EmptyMetricTags>

init(_ f: Telemetry.Factory) {
request = f.counter("known_tests.request")
requestErrors = f.counter("known_tests.request_errors")
requestMs = f.distribution("known_tests.request_ms")
responseBytes = f.distribution("known_tests.response_bytes")
responseTests = f.distribution("known_tests.response_tests")
pagesFetched = f.distribution("known_tests.pages_fetched")
totalFetchMs = f.distribution("known_tests.total_fetch_ms")
totalRequestMs = f.distribution("known_tests.total_request_ms")
}
}

Expand Down
19 changes: 13 additions & 6 deletions Sources/DatadogSDKTesting/Telemetry/TelemetryObservers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -131,13 +131,20 @@ extension Telemetry {
)
}

var knownTestsRequestObserver: RequestMetricsObserver {
var knownTestsRequestObserver: PagedRequestObserver {
let m = metrics.knownTests
return RequestMetricsObserver(
onRequest: { m.request.add() },
onDurationMs: { m.requestMs.record($0) },
onResponseBytes: { m.responseBytes.record(Double($0)) },
onError: { m.requestErrors.add(errorType: $0) }
return PagedRequestObserver(
wrapping: RequestMetricsObserver(
onRequest: { m.request.add() },
onDurationMs: { m.requestMs.record($0) },
onResponseBytes: { m.responseBytes.record(Double($0)) },
onError: { m.requestErrors.add(errorType: $0) }
),
onPagesFetched: { count, totalFetchMs, totalRequestMs in
m.pagesFetched.record(Double(count))
m.totalFetchMs.record(totalFetchMs)
m.totalRequestMs.record(totalRequestMs)
}
)
}

Expand Down
115 changes: 53 additions & 62 deletions Sources/EventsExporter/API/KnownTestsApi.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,25 +21,43 @@ public struct KnownTestsResult {
public var isAllTests: Bool { !pageInfo.hasNext }
}

/// Pagination metadata from the Known Tests API (cursor for next page, page size, size, has_next).
public struct KnownTestsPageInfo {
/// Pagination metadata for the Known Tests API call (cursor for next page, page size).
public struct KnownTestsPageCursor: Encodable, CustomDebugStringConvertible {
public let pageState: String?
public let pageSize: Int?

public init(pageSize: Int? = nil, pageState: String? = nil) {
self.pageState = pageState
self.pageSize = pageSize
}

public var debugDescription: String {
let size = pageSize.map { "\($0)" } ?? "auto"
let state = pageState.map { #""\#($0)""# } ?? "null"
return #"{"page_size": \#(size), "page_state": \#(state)}"#
}
}

/// Pagination metadata from the Known Tests API (cursor for next page, size, has_next).
public struct KnownTestsPageInfo: Decodable, CustomDebugStringConvertible {
public let cursor: String?
public let pageSize: Int
public let size: Int
public let hasNext: Bool

public init(cursor: String?, pageSize: Int, size: Int, hasNext: Bool) {
public init(cursor: String?, size: Int, hasNext: Bool) {
self.cursor = cursor
self.pageSize = pageSize

self.size = size
self.hasNext = hasNext
}

public init(pageSize: Int = 2000) {
self.pageSize = pageSize
self.size = 0
self.hasNext = false
self.cursor = nil

public func next(pageSize: Int? = nil) -> KnownTestsPageCursor? {
hasNext ? .init(pageSize: pageSize, pageState: cursor) : nil
}

public var debugDescription: String {
let cursorStr = cursor.map { #""\#($0)""# } ?? "null"
return #"{"cursor": \#(cursorStr), "size": \#(size), "has_next": \#(hasNext)}"#
}
}

Expand All @@ -49,7 +67,7 @@ public protocol KnownTestsApi: APIService {
service: String, env: String, repositoryURL: String,
configurations: [String: String],
customConfigurations: [String: String],
page: KnownTestsPageInfo,
page: KnownTestsPageCursor,
observer: RequestObserver?
) async throws(APICallError) -> KnownTestsResult

Expand All @@ -58,40 +76,40 @@ public protocol KnownTestsApi: APIService {
service: String, env: String, repositoryURL: String,
configurations: [String: String],
customConfigurations: [String: String],
observer: RequestObserver?
) async throws(APICallError) -> KnownTestsResult
observer: PagedRequestObserver?
) async throws(APICallError) -> KnownTestsMap
}

extension KnownTestsApi {
public func tests(
service: String, env: String, repositoryURL: String,
configurations: [String: String],
customConfigurations: [String: String],
observer: RequestObserver?
) async throws(APICallError) -> KnownTestsResult {
observer: PagedRequestObserver?
) async throws(APICallError) -> KnownTestsMap {
let startTime = Date().timeIntervalSince1970 * 1000
var tests: KnownTestsMap = [:]
var page: KnownTestsPageInfo = .init()
var size: Int = 0
var page: KnownTestsPageCursor? = .init()

repeat {
let result = try await self.tests(
service: service, env: env, repositoryURL: repositoryURL,
configurations: configurations, customConfigurations: customConfigurations,
page: page, observer: observer
page: page!, observer: observer
)

tests = tests.merging(result.tests) { (current, new) in
current.merging(new) { (current, new) in
Array(Set(current).union(new))
}
}
page = result.pageInfo
size += result.pageInfo.size
} while page.hasNext

return KnownTestsResult(tests: tests,
pageInfo: .init(cursor: page.cursor,
pageSize: page.pageSize,
size: size,
hasNext: page.hasNext))
page = result.pageInfo.next()
} while page != nil

let totalFetchMs = Date().timeIntervalSince1970 * 1000 - startTime
observer?.finished(totalFetchMs: totalFetchMs)

return tests
}

/// Convenience without a telemetry observer.
Expand All @@ -100,7 +118,7 @@ extension KnownTestsApi {
service: String, env: String, repositoryURL: String,
configurations: [String: String],
customConfigurations: [String: String],
page: KnownTestsPageInfo
page: KnownTestsPageCursor
) async throws(APICallError) -> KnownTestsResult {
try await tests(service: service, env: env, repositoryURL: repositoryURL,
configurations: configurations, customConfigurations: customConfigurations,
Expand All @@ -113,7 +131,7 @@ extension KnownTestsApi {
service: String, env: String, repositoryURL: String,
configurations: [String: String],
customConfigurations: [String: String]
) async throws(APICallError) -> KnownTestsResult {
) async throws(APICallError) -> KnownTestsMap {
try await tests(service: service, env: env, repositoryURL: repositoryURL,
configurations: configurations, customConfigurations: customConfigurations,
observer: nil)
Expand Down Expand Up @@ -143,15 +161,15 @@ struct KnownTestsApiService: KnownTestsApi, APIServiceConstructible {
repositoryURL: String,
configurations: [String: String],
customConfigurations: [String: String],
page: KnownTestsPageInfo,
page: KnownTestsPageCursor,
observer: RequestObserver?) async throws(APICallError) -> KnownTestsResult
{
var configurations: [String: JSONGeneric] = configurations.mapValues { .string($0) }
configurations["custom"] = JSONGeneric(customConfigurations)

let request = TestsRequest(repositoryUrl: repositoryURL, env: env,
service: service, configurations: configurations,
pageInfo: .init(pageSize: page.pageSize, pageState: page.cursor))
pageInfo: page)
let log = self.log
log.debug("Known tests request: \(request)")
let response = try await httpClient.call(KnownTestsCall.self,
Expand All @@ -162,46 +180,19 @@ struct KnownTestsApiService: KnownTestsApi, APIServiceConstructible {
observer: observer)
log.debug("Known tests response: \(response.data.attributes)")
let attrs = response.data.attributes
return KnownTestsResult(
tests: attrs.tests,
pageInfo: .init(cursor: attrs.pageInfo.cursor,
pageSize: page.pageSize,
size: attrs.pageInfo.size,
hasNext: attrs.pageInfo.hasNext)
)
return KnownTestsResult(tests: attrs.tests, pageInfo: attrs.pageInfo)
}

var endpointURLs: Set<URL> { [endpoint.knownTestsURL] }
}

extension KnownTestsApiService {
struct PageInfoRequest: Encodable, CustomDebugStringConvertible {
let pageSize: Int
let pageState: String?

var debugDescription: String {
let state = pageState.map { #""\#($0)""# } ?? "null"
return #"{"page_size": \#(pageSize), "page_state": \#(state)}"#
}
}

struct PageInfoResponse: Decodable, CustomDebugStringConvertible {
let cursor: String?
let size: Int
let hasNext: Bool

var debugDescription: String {
let cursorStr = cursor.map { #""\#($0)""# } ?? "null"
return #"{"cursor": \#(cursorStr), "size": \#(size), "has_next": \#(hasNext)}"#
}
}

struct TestsRequest: Encodable, APIAttributesUUID, CustomDebugStringConvertible {
let repositoryUrl: String
let env: String
let service: String
let configurations: [String: JSONGeneric]
let pageInfo: PageInfoRequest
let pageInfo: KnownTestsPageCursor

static var apiType: String = "ci_app_libraries_tests_request"

Expand All @@ -219,7 +210,7 @@ extension KnownTestsApiService {
APIResponseAttributesBrokenId, CustomDebugStringConvertible
{
let tests: KnownTestsMap
let pageInfo: PageInfoResponse
let pageInfo: KnownTestsPageInfo

static var apiType: String = "ci_app_libraries_tests"

Expand Down
38 changes: 38 additions & 0 deletions Sources/EventsExporter/Telemetry/MetricObservers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,44 @@ public protocol RequestObserver: Sendable {
statusCode: Int?, transportError: (any Error)?, failed: Bool)
}

/// Observes a sequence of paginated requests (e.g. Known Tests). Conforms to
/// `RequestObserver` so the same instance can be handed to each per-page HTTP
/// call: it forwards every call unchanged to the wrapped observer while
/// summing the `durationMs` of every call (successful or not — retries still
/// cost wall-clock time) and counting only the succeeded pages, so the caller
/// never times a page itself. Call `finished(totalFetchMs:)` once, after the
/// last page, to report the pagination-level aggregate.
public final class PagedRequestObserver: RequestObserver, Sendable {
private let wrapped: RequestObserver?
private let onPagesFetched: (@Sendable (_ count: Int, _ totalFetchMs: Double, _ totalRequestMs: Double) -> Void)?
private let accumulated = Synced<(pageCount: Int, totalRequestMs: Double)>((0, 0))

public init(wrapping observer: RequestObserver? = nil,
onPagesFetched: (@Sendable (_ count: Int, _ totalFetchMs: Double, _ totalRequestMs: Double) -> Void)? = nil) {
self.wrapped = observer
self.onPagesFetched = onPagesFetched
}

public func requestFinished(durationMs: Double, requestBytes: Int, responseBytes: Int,
statusCode: Int?, transportError: (any Error)?, failed: Bool) {
accumulated.update { state in
state.totalRequestMs += durationMs
if !failed { state.pageCount += 1 }
}
wrapped?.requestFinished(durationMs: durationMs, requestBytes: requestBytes,
responseBytes: responseBytes, statusCode: statusCode,
transportError: transportError, failed: failed)
}

/// Reports the pagination-level aggregate; call once after the last page.
/// `totalFetchMs` is the wall-clock time from the first page request to
/// the last, supplied by the caller since only it spans the whole loop.
public func finished(totalFetchMs: Double) {
let (pageCount, totalRequestMs) = accumulated.value
onPagesFetched?(pageCount, totalFetchMs, totalRequestMs)
}
}

/// Observes the background upload pipeline that drains stored batches to the
/// intake (one observer per feature store, e.g. spans vs coverage).
///
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ final class LibraryConfigurationServiceThrowTests: XCTestCase {

let error = expectError {
_ = try invokeConfigurationApi(requestName: "Known Tests Request",
payload: "") { () async throws(APICallError) -> KnownTestsResult in
payload: "") { () async throws(APICallError) -> KnownTestsMap in
try await api.tests(service: "service", env: "env", repositoryURL: "repo",
configurations: [:], customConfigurations: [:])
}
Expand Down
Loading