diff --git a/Benchmarks/OTelTraceIdRatioBasedSamplerBenchmark/OTelTraceIdRatioBasedSamplerBenchmark.swift b/Benchmarks/OTelTraceIdRatioBasedSamplerBenchmark/OTelTraceIdRatioBasedSamplerBenchmark.swift new file mode 100644 index 0000000..a393379 --- /dev/null +++ b/Benchmarks/OTelTraceIdRatioBasedSamplerBenchmark/OTelTraceIdRatioBasedSamplerBenchmark.swift @@ -0,0 +1,21 @@ +// Benchmark boilerplate generated by Benchmark + +import Benchmark +import OTel + +let benchmarks = { + Benchmark("OTelTraceIdRatioBasedSampler") { benchmark in + + let sampler = OTelTraceIdRatioBasedSampler(ratio: 0.5) + for _ in benchmark.scaledIterations { + _ = sampler.samplingResult( + operationName: "some-op", + kind: .internal, + traceID: .random(), + attributes: [:], + links: [], + parentContext: .topLevel) + } + } + // Add additional benchmarks here +} diff --git a/Package.swift b/Package.swift index 7bb0956..98a6724 100644 --- a/Package.swift +++ b/Package.swift @@ -19,7 +19,9 @@ let package = Package( .package(url: "https://github.com/apple/swift-nio.git", from: "2.0.0"), .package(url: "https://github.com/apple/swift-atomics.git", from: "1.0.2"), .package(url: "https://github.com/apple/swift-metrics.git", from: "2.4.1"), - .package(url: "https://github.com/slashmo/swift-w3c-trace-context.git", from: "1.0.0-beta.1"), + //.package(url: "https://github.com/slashmo/swift-w3c-trace-context.git", from: "1.0.0-beta.1"), + //.package(name: "swift-w3c-trace-context", path: "../swift-w3c-trace-context"), + .package(url: "https://github.com/t089/swift-w3c-trace-context.git", branch: "trace-id-bytes"), // MARK: - OTLP @@ -30,6 +32,9 @@ let package = Package( // MARK: - Plugins .package(url: "https://github.com/apple/swift-docc-plugin.git", from: "1.0.0"), + + // MARK: - Benchmarks + .package(url: "https://github.com/ordo-one/package-benchmark", .upToNextMajor(from: "1.4.0")), ], targets: [ .target( @@ -110,3 +115,18 @@ let package = Package( ], swiftLanguageVersions: [.version("6"), .v5] ) + +// Benchmark of OTelTraceIdRatioBasedSamplerBenchmark +package.targets += [ + .executableTarget( + name: "OTelTraceIdRatioBasedSamplerBenchmark", + dependencies: [ + .product(name: "Benchmark", package: "package-benchmark"), + "OTel", + ], + path: "Benchmarks/OTelTraceIdRatioBasedSamplerBenchmark", + plugins: [ + .plugin(name: "BenchmarkPlugin", package: "package-benchmark") + ] + ), +] \ No newline at end of file diff --git a/Sources/OTLPCore/Tracing/OTelFinishedSpan+Proto.swift b/Sources/OTLPCore/Tracing/OTelFinishedSpan+Proto.swift index 62a4c00..c3d7558 100644 --- a/Sources/OTLPCore/Tracing/OTelFinishedSpan+Proto.swift +++ b/Sources/OTLPCore/Tracing/OTelFinishedSpan+Proto.swift @@ -14,21 +14,22 @@ import struct Foundation.Data import OTel import Tracing +import W3CTraceContext extension Opentelemetry_Proto_Trace_V1_Span { /// Create a span from an `OTelFinishedSpan`. /// /// - Parameter finishedSpan: The `OTelFinishedSpan` to cast. public init(_ finishedSpan: OTelFinishedSpan) { - traceID = Data(finishedSpan.spanContext.traceID.bytes) - spanID = Data(finishedSpan.spanContext.spanID.bytes) + traceID = finishedSpan.spanContext.traceID.data + spanID = finishedSpan.spanContext.spanID.data if let traceStateHeaderValue = finishedSpan.spanContext.traceStateHeaderValue { self.traceState = traceStateHeaderValue } if let parentSpanID = finishedSpan.spanContext.parentSpanID { - self.parentSpanID = Data(parentSpanID.bytes) + self.parentSpanID = parentSpanID.data } name = finishedSpan.operationName @@ -46,3 +47,19 @@ extension Opentelemetry_Proto_Trace_V1_Span { links = finishedSpan.links.compactMap(Opentelemetry_Proto_Trace_V1_Span.Link.init) } } + +extension TraceID { + var data: Data { + self.withUnsafeBytes { + Data($0) + } + } +} + +extension SpanID { + var data: Data { + self.withUnsafeBytes { + Data($0) + } + } +} \ No newline at end of file diff --git a/Sources/OTLPCore/Tracing/SpanLink+Proto.swift b/Sources/OTLPCore/Tracing/SpanLink+Proto.swift index bf5a337..fb5ff94 100644 --- a/Sources/OTLPCore/Tracing/SpanLink+Proto.swift +++ b/Sources/OTLPCore/Tracing/SpanLink+Proto.swift @@ -21,8 +21,8 @@ extension Opentelemetry_Proto_Trace_V1_Span.Link { /// - Returns: `nil` if the `SpanLink`s context does not contain a span context. public init?(_ link: SpanLink) { guard let spanContext = link.context.spanContext else { return nil } - self.traceID = Data(spanContext.traceID.bytes) - self.spanID = Data(spanContext.spanID.bytes) + self.traceID = spanContext.traceID.data + self.spanID = spanContext.spanID.data if let traceStateHeaderValue = spanContext.traceStateHeaderValue { self.traceState = traceStateHeaderValue } diff --git a/Sources/OTel/Tracing/Sampling/OTelTraceIdRatioBasedSampler.swift b/Sources/OTel/Tracing/Sampling/OTelTraceIdRatioBasedSampler.swift new file mode 100644 index 0000000..4a57714 --- /dev/null +++ b/Sources/OTel/Tracing/Sampling/OTelTraceIdRatioBasedSampler.swift @@ -0,0 +1,53 @@ +import Tracing +import W3CTraceContext + +/// An `OTelSampler` based on a given `TraceID` and `ratio`. +/// [Spec](https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/sdk.md#traceidratiobased) +public struct OTelTraceIdRatioBasedSampler: OTelSampler, Equatable, Hashable, CustomStringConvertible { + + let idUpperBound : UInt64 + public let ratio: Double + + public init(ratio: Double) { + precondition(ratio >= 0.0 && ratio <= 1.0, "ratio must be between 0.0 and 1.0") + + self.ratio = ratio + if ratio == 0.0 { + self.idUpperBound = .min + } else if ratio == 1.0 { + self.idUpperBound = .max + } else { + self.idUpperBound = UInt64(ratio * Double(UInt64.max)) + } + } + + public func samplingResult( + operationName: String, + kind: SpanKind, + traceID: TraceID, + attributes: SpanAttributes, + links: [SpanLink], + parentContext: ServiceContext + ) -> OTelSamplingResult { + + let value = traceID.withUnsafeBytes { $0[8...].load(as: UInt64.self) } + + if value < idUpperBound { + return .init(decision: .recordAndSample) + } else { + return .init(decision: .drop) + } + } + + public static func ==(lhs: Self, rhs: Self) -> Bool { + lhs.idUpperBound == rhs.idUpperBound + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(self.idUpperBound) + } + + public var description: String { + "TraceIdRatioBased{\(ratio)}" + } +} \ No newline at end of file diff --git a/Tests/OTelTests/Tracing/Sampling/OTelTraceIdRatioBasedSamplerTests.swift b/Tests/OTelTests/Tracing/Sampling/OTelTraceIdRatioBasedSamplerTests.swift new file mode 100644 index 0000000..4a7826c --- /dev/null +++ b/Tests/OTelTests/Tracing/Sampling/OTelTraceIdRatioBasedSamplerTests.swift @@ -0,0 +1,70 @@ +@testable import OTel +import XCTest +import Tracing + +final class OTelTraceIdRatioBasedSamplerTests: XCTestCase { + func test_zero_ratio_does_not_sample() { + let sampler = OTelTraceIdRatioBasedSampler(ratio: 0.0) + + let result = sampler.samplingResult( + operationName: "does-not-matter", + kind: .internal, + traceID: .allZeroes, + attributes: [:], + links: [], + parentContext: .topLevel + ) + + XCTAssertEqual(result, OTelSamplingResult(decision: .drop, attributes: [:])) + } + + func test_one_ratio_does_sample() { + let sampler = OTelTraceIdRatioBasedSampler(ratio: 1.0) + + let result = sampler.samplingResult( + operationName: "does-not-matter", + kind: .internal, + traceID: .allZeroes, + attributes: [:], + links: [], + parentContext: .topLevel + ) + + XCTAssertEqual(result, OTelSamplingResult(decision: .recordAndSample, attributes: [:])) + } + + + func test_different_ratios() { + + let ratios = [0.0, 0.1, 0.25, 0.5, 0.75, 1.0] + + for ratio in ratios { + + let sampler = OTelTraceIdRatioBasedSampler(ratio: ratio) + + let N = 100_000 + var sampled = 0 + + for _ in 0 ..< N { + let result = sampler.samplingResult( + operationName: "does-not-matter", + kind: .internal, + traceID: .random(), + attributes: [:], + links: [], + parentContext: .topLevel + ) + + switch result.decision { + case .recordAndSample: + sampled += 1 + default: break + } + } + + let observedRatio = Double(sampled) / Double(N) + + XCTAssertEqual(ratio, observedRatio, accuracy: 0.05, "Expected ratio \(ratio) but observed \(observedRatio)") + } + } +} \ No newline at end of file