From 964e2bd79a2eb3677f881738bd2a0c1977de24a9 Mon Sep 17 00:00:00 2001 From: Prashant Srivastava <50466688+srprash@users.noreply.github.com> Date: Tue, 30 May 2023 09:32:44 -0700 Subject: [PATCH 1/2] [Sampler.AWS] Part-3: Update targets. Add rate limiting and fixed rate samplers. (#1151) --- .../AWSXRayRemoteSampler.cs | 52 +++++ .../AWSXRaySamplerClient.cs | 31 +++ src/OpenTelemetry.Sampler.AWS/Clock.cs | 2 +- .../FallbackSampler.cs | 16 +- .../GetSamplingTargetsRequest.cs | 31 +++ .../GetSamplingTargetsResponse.cs | 43 ++++ src/OpenTelemetry.Sampler.AWS/RateLimiter.cs | 62 ++++++ .../RateLimitingSampler.cs | 38 ++++ src/OpenTelemetry.Sampler.AWS/RulesCache.cs | 60 +++++ .../SamplingRuleApplier.cs | 144 +++++++++++- .../SamplingStatisticsDocument.cs | 50 +++++ .../SamplingTargetDocument.cs | 37 ++++ src/OpenTelemetry.Sampler.AWS/Statistics.cs | 8 +- src/OpenTelemetry.Sampler.AWS/SystemClock.cs | 8 +- .../UnprocessedStatistic.cs | 38 ++++ ...etSamplingRulesResponseOptionalFields.json | 42 ++++ .../Data/GetSamplingTargetsResponse.json | 23 ++ ...SamplingTargetsResponseOptionalFields.json | 16 ++ .../OpenTelemetry.Sampler.AWS.Tests.csproj | 7 +- .../TestAWSXRayRemoteSampler.cs | 76 ++++++- .../TestAWSXRaySamplerClient.cs | 85 ++++++++ .../TestClock.cs | 12 +- .../TestRateLimiter.cs | 163 ++++++++++++++ .../TestRateLimitingSampler.cs | 44 ++++ .../TestRulesCache.cs | 132 ++++++++++- .../TestSamplingRuleApplier.cs | 206 +++++++++++++++++- test/OpenTelemetry.Sampler.AWS.Tests/Utils.cs | 16 ++ 27 files changed, 1405 insertions(+), 37 deletions(-) create mode 100644 src/OpenTelemetry.Sampler.AWS/GetSamplingTargetsRequest.cs create mode 100644 src/OpenTelemetry.Sampler.AWS/GetSamplingTargetsResponse.cs create mode 100644 src/OpenTelemetry.Sampler.AWS/RateLimiter.cs create mode 100644 src/OpenTelemetry.Sampler.AWS/RateLimitingSampler.cs create mode 100644 src/OpenTelemetry.Sampler.AWS/SamplingStatisticsDocument.cs create mode 100644 src/OpenTelemetry.Sampler.AWS/SamplingTargetDocument.cs create mode 100644 src/OpenTelemetry.Sampler.AWS/UnprocessedStatistic.cs create mode 100644 test/OpenTelemetry.Sampler.AWS.Tests/Data/GetSamplingRulesResponseOptionalFields.json create mode 100644 test/OpenTelemetry.Sampler.AWS.Tests/Data/GetSamplingTargetsResponse.json create mode 100644 test/OpenTelemetry.Sampler.AWS.Tests/Data/GetSamplingTargetsResponseOptionalFields.json create mode 100644 test/OpenTelemetry.Sampler.AWS.Tests/TestRateLimiter.cs create mode 100644 test/OpenTelemetry.Sampler.AWS.Tests/TestRateLimitingSampler.cs diff --git a/src/OpenTelemetry.Sampler.AWS/AWSXRayRemoteSampler.cs b/src/OpenTelemetry.Sampler.AWS/AWSXRayRemoteSampler.cs index b688720016..179de0ec3b 100644 --- a/src/OpenTelemetry.Sampler.AWS/AWSXRayRemoteSampler.cs +++ b/src/OpenTelemetry.Sampler.AWS/AWSXRayRemoteSampler.cs @@ -47,12 +47,20 @@ internal AWSXRayRemoteSampler(Resource resource, TimeSpan pollingInterval, strin // upto 5 seconds of jitter for rule polling this.RulePollerJitter = TimeSpan.FromMilliseconds(Random.Next(1, 5000)); + // upto 100 milliseconds of jitter for target polling + this.TargetPollerJitter = TimeSpan.FromMilliseconds(Random.Next(1, 100)); + // execute the first update right away and schedule subsequent update later. this.RulePollerTimer = new Timer(this.GetAndUpdateRules, null, TimeSpan.Zero, Timeout.InfiniteTimeSpan); + + // set up the target poller to go off once after the default interval. We will update the timer later. + this.TargetPollerTimer = new Timer(this.GetAndUpdateTargets, null, DefaultTargetInterval, Timeout.InfiniteTimeSpan); } internal TimeSpan RulePollerJitter { get; set; } + internal TimeSpan TargetPollerJitter { get; set; } + internal Clock Clock { get; set; } internal string ClientId { get; set; } @@ -67,6 +75,8 @@ internal AWSXRayRemoteSampler(Resource resource, TimeSpan pollingInterval, strin internal Timer RulePollerTimer { get; set; } + internal Timer TargetPollerTimer { get; set; } + internal TimeSpan PollingInterval { get; set; } internal Trace.Sampler FallbackSampler { get; set; } @@ -137,4 +147,46 @@ private async void GetAndUpdateRules(object? state) // schedule the next rule poll. this.RulePollerTimer.Change(this.PollingInterval.Add(this.RulePollerJitter), Timeout.InfiniteTimeSpan); } + + private async void GetAndUpdateTargets(object? state) + { + List statistics = this.RulesCache.Snapshot(this.Clock.Now()); + + GetSamplingTargetsRequest request = new GetSamplingTargetsRequest(statistics); + GetSamplingTargetsResponse? response = await this.Client.GetSamplingTargets(request).ConfigureAwait(false); + if (response != null) + { + Dictionary targets = new Dictionary(); + foreach (SamplingTargetDocument target in response.SamplingTargetDocuments) + { + if (target.RuleName != null) + { + targets[target.RuleName] = target; + } + } + + this.RulesCache.UpdateTargets(targets); + + if (response.LastRuleModification > 0) + { + DateTime lastRuleModificationTime = this.Clock.ToDateTime(response.LastRuleModification); + + if (lastRuleModificationTime > this.RulesCache.GetUpdatedAt()) + { + // rules have been updated. fetch the new ones right away. + this.RulePollerTimer.Change(TimeSpan.Zero, Timeout.InfiniteTimeSpan); + } + } + } + + // schedule next target poll + DateTime nextTargetFetchTime = this.RulesCache.NextTargetFetchTime(); + TimeSpan nextTargetFetchInterval = nextTargetFetchTime.Subtract(this.Clock.Now()); + if (nextTargetFetchInterval < TimeSpan.Zero) + { + nextTargetFetchInterval = DefaultTargetInterval; + } + + this.TargetPollerTimer.Change(nextTargetFetchInterval.Add(this.TargetPollerJitter), Timeout.InfiniteTimeSpan); + } } diff --git a/src/OpenTelemetry.Sampler.AWS/AWSXRaySamplerClient.cs b/src/OpenTelemetry.Sampler.AWS/AWSXRaySamplerClient.cs index 1a40673fb5..64b7649e8f 100644 --- a/src/OpenTelemetry.Sampler.AWS/AWSXRaySamplerClient.cs +++ b/src/OpenTelemetry.Sampler.AWS/AWSXRaySamplerClient.cs @@ -26,12 +26,15 @@ namespace OpenTelemetry.Sampler.AWS; internal class AWSXRaySamplerClient : IDisposable { private readonly string getSamplingRulesEndpoint; + private readonly string getSamplingTargetsEndpoint; + private readonly HttpClient httpClient; private readonly string jsonContentType = "application/json"; public AWSXRaySamplerClient(string host) { this.getSamplingRulesEndpoint = host + "/GetSamplingRules"; + this.getSamplingTargetsEndpoint = host + "/SamplingTargets"; this.httpClient = new HttpClient(); } @@ -74,6 +77,34 @@ public async Task> GetSamplingRules() return samplingRules; } + public async Task GetSamplingTargets(GetSamplingTargetsRequest getSamplingTargetsRequest) + { + var content = new StringContent(JsonSerializer.Serialize(getSamplingTargetsRequest), Encoding.UTF8, this.jsonContentType); + + using var request = new HttpRequestMessage(HttpMethod.Post, this.getSamplingTargetsEndpoint) + { + Content = content, + }; + + var responseJson = await this.DoRequestAsync(this.getSamplingTargetsEndpoint, request).ConfigureAwait(false); + + try + { + GetSamplingTargetsResponse? getSamplingTargetsResponse = JsonSerializer + .Deserialize(responseJson); + + return getSamplingTargetsResponse; + } + catch (Exception ex) + { + AWSSamplerEventSource.Log.FailedToDeserializeResponse( + nameof(AWSXRaySamplerClient.GetSamplingTargets), + ex.Message); + } + + return null; + } + public void Dispose() { this.Dispose(true); diff --git a/src/OpenTelemetry.Sampler.AWS/Clock.cs b/src/OpenTelemetry.Sampler.AWS/Clock.cs index 39218fbdf2..1043e4aa36 100644 --- a/src/OpenTelemetry.Sampler.AWS/Clock.cs +++ b/src/OpenTelemetry.Sampler.AWS/Clock.cs @@ -28,7 +28,7 @@ public static Clock GetDefault() public abstract DateTime Now(); - public abstract long NowInSeconds(); + public abstract long NowInMilliSeconds(); public abstract DateTime ToDateTime(double seconds); diff --git a/src/OpenTelemetry.Sampler.AWS/FallbackSampler.cs b/src/OpenTelemetry.Sampler.AWS/FallbackSampler.cs index 3917c407fe..72d756b322 100644 --- a/src/OpenTelemetry.Sampler.AWS/FallbackSampler.cs +++ b/src/OpenTelemetry.Sampler.AWS/FallbackSampler.cs @@ -20,19 +20,25 @@ namespace OpenTelemetry.Sampler.AWS; internal class FallbackSampler : Trace.Sampler { - private static readonly Trace.Sampler AlwaysOn = new AlwaysOnSampler(); - + private readonly Trace.Sampler reservoirSampler; + private readonly Trace.Sampler fixedRateSampler; private readonly Clock clock; public FallbackSampler(Clock clock) { this.clock = clock; + this.reservoirSampler = new ParentBasedSampler(new RateLimitingSampler(1, clock)); + this.fixedRateSampler = new ParentBasedSampler(new TraceIdRatioBasedSampler(0.05)); } public override SamplingResult ShouldSample(in SamplingParameters samplingParameters) { - // For now just do an always on sampler. - // TODO: update to a rate limiting sampler. - return AlwaysOn.ShouldSample(samplingParameters); + SamplingResult result = this.reservoirSampler.ShouldSample(in samplingParameters); + if (result.Decision != SamplingDecision.Drop) + { + return result; + } + + return this.fixedRateSampler.ShouldSample(in samplingParameters); } } diff --git a/src/OpenTelemetry.Sampler.AWS/GetSamplingTargetsRequest.cs b/src/OpenTelemetry.Sampler.AWS/GetSamplingTargetsRequest.cs new file mode 100644 index 0000000000..f7bafb0921 --- /dev/null +++ b/src/OpenTelemetry.Sampler.AWS/GetSamplingTargetsRequest.cs @@ -0,0 +1,31 @@ +// +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace OpenTelemetry.Sampler.AWS; + +internal class GetSamplingTargetsRequest +{ + public GetSamplingTargetsRequest(List documents) + { + this.SamplingStatisticsDocuments = documents; + } + + [JsonPropertyName("SamplingStatisticsDocuments")] + public List SamplingStatisticsDocuments { get; set; } +} diff --git a/src/OpenTelemetry.Sampler.AWS/GetSamplingTargetsResponse.cs b/src/OpenTelemetry.Sampler.AWS/GetSamplingTargetsResponse.cs new file mode 100644 index 0000000000..1490e5e6d7 --- /dev/null +++ b/src/OpenTelemetry.Sampler.AWS/GetSamplingTargetsResponse.cs @@ -0,0 +1,43 @@ +// +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace OpenTelemetry.Sampler.AWS; + +internal class GetSamplingTargetsResponse +{ + public GetSamplingTargetsResponse( + double lastRuleModification, + List samplingTargetDocuments, + List unprocessedStatistics) + { + this.LastRuleModification = lastRuleModification; + this.SamplingTargetDocuments = samplingTargetDocuments; + this.UnprocessedStatistics = unprocessedStatistics; + } + + // This is actually a time in unix seconds. + [JsonPropertyName("LastRuleModification")] + public double LastRuleModification { get; set; } + + [JsonPropertyName("SamplingTargetDocuments")] + public List SamplingTargetDocuments { get; set; } + + [JsonPropertyName("UnprocessedStatistics")] + public List UnprocessedStatistics { get; set; } +} diff --git a/src/OpenTelemetry.Sampler.AWS/RateLimiter.cs b/src/OpenTelemetry.Sampler.AWS/RateLimiter.cs new file mode 100644 index 0000000000..aba7ea134d --- /dev/null +++ b/src/OpenTelemetry.Sampler.AWS/RateLimiter.cs @@ -0,0 +1,62 @@ +// +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Threading; + +namespace OpenTelemetry.Sampler.AWS; +internal sealed class RateLimiter +{ + private readonly Clock clock; + private readonly double creditsPerMillisecond; + private readonly long maxBalance; + private long currentBalance; + + internal RateLimiter(double creditsPerSecond, double maxBalance, Clock clock) + { + this.clock = clock; + this.creditsPerMillisecond = creditsPerSecond / 1.0e3; + this.maxBalance = (long)(maxBalance / this.creditsPerMillisecond); + this.currentBalance = this.clock.NowInMilliSeconds() - this.maxBalance; + } + + public bool TrySpend(double itemCost) + { + long cost = (long)(itemCost / this.creditsPerMillisecond); + long currentMillis; + long currentBalanceMillis; + long availableBalanceAfterWithdrawal; + + do + { + currentBalanceMillis = Interlocked.Read(ref this.currentBalance); + currentMillis = this.clock.NowInMilliSeconds(); + long currentAvailableBalance = currentMillis - currentBalanceMillis; + if (currentAvailableBalance > this.maxBalance) + { + currentAvailableBalance = this.maxBalance; + } + + availableBalanceAfterWithdrawal = currentAvailableBalance - cost; + if (availableBalanceAfterWithdrawal < 0) + { + return false; + } + } + while (Interlocked.CompareExchange(ref this.currentBalance, currentMillis - availableBalanceAfterWithdrawal, currentBalanceMillis) != currentBalanceMillis); + + return true; + } +} diff --git a/src/OpenTelemetry.Sampler.AWS/RateLimitingSampler.cs b/src/OpenTelemetry.Sampler.AWS/RateLimitingSampler.cs new file mode 100644 index 0000000000..2a315787c7 --- /dev/null +++ b/src/OpenTelemetry.Sampler.AWS/RateLimitingSampler.cs @@ -0,0 +1,38 @@ +// +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using OpenTelemetry.Trace; + +namespace OpenTelemetry.Sampler.AWS; +internal class RateLimitingSampler : Trace.Sampler +{ + private readonly RateLimiter limiter; + + public RateLimitingSampler(long numPerSecond, Clock clock) + { + this.limiter = new RateLimiter(numPerSecond, numPerSecond, clock); + } + + public override SamplingResult ShouldSample(in SamplingParameters samplingParameters) + { + if (this.limiter.TrySpend(1)) + { + return new SamplingResult(SamplingDecision.RecordAndSample); + } + + return new SamplingResult(SamplingDecision.Drop); + } +} diff --git a/src/OpenTelemetry.Sampler.AWS/RulesCache.cs b/src/OpenTelemetry.Sampler.AWS/RulesCache.cs index 182ab0b013..3e8efce9c3 100644 --- a/src/OpenTelemetry.Sampler.AWS/RulesCache.cs +++ b/src/OpenTelemetry.Sampler.AWS/RulesCache.cs @@ -109,6 +109,66 @@ public SamplingResult ShouldSample(in SamplingParameters samplingParameters) return this.FallbackSampler.ShouldSample(in samplingParameters); } + public List Snapshot(DateTime now) + { + List snapshots = new List(); + foreach (var ruleApplier in this.RuleAppliers) + { + snapshots.Add(ruleApplier.Snapshot(now)); + } + + return snapshots; + } + + public void UpdateTargets(Dictionary targets) + { + List newRuleAppliers = new List(); + foreach (var ruleApplier in this.RuleAppliers) + { + targets.TryGetValue(ruleApplier.RuleName, out SamplingTargetDocument? target); + if (target != null) + { + newRuleAppliers.Add(ruleApplier.WithTarget(target, this.Clock.Now())); + } + else + { + // did not get target for this rule. Will be updated in future target poll. + newRuleAppliers.Add(ruleApplier); + } + } + + this.rwLock.EnterWriteLock(); + try + { + this.RuleAppliers = newRuleAppliers; + } + finally + { + this.rwLock.ExitWriteLock(); + } + } + + public DateTime NextTargetFetchTime() + { + var defaultPollingTime = this.Clock.Now().AddSeconds(AWSXRayRemoteSampler.DefaultTargetInterval.TotalSeconds); + + if (this.RuleAppliers.Count == 0) + { + return defaultPollingTime; + } + + var minPollingTime = this.RuleAppliers + .Select(r => r.NextSnapshotTime) + .Min(); + + if (minPollingTime < this.Clock.Now()) + { + return defaultPollingTime; + } + + return minPollingTime; + } + public void Dispose() { this.Dispose(true); diff --git a/src/OpenTelemetry.Sampler.AWS/SamplingRuleApplier.cs b/src/OpenTelemetry.Sampler.AWS/SamplingRuleApplier.cs index abfba82618..56cc0f0443 100644 --- a/src/OpenTelemetry.Sampler.AWS/SamplingRuleApplier.cs +++ b/src/OpenTelemetry.Sampler.AWS/SamplingRuleApplier.cs @@ -15,8 +15,8 @@ // using System; -using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Threading; using OpenTelemetry.Resources; using OpenTelemetry.Trace; @@ -31,6 +31,51 @@ public SamplingRuleApplier(string clientId, Clock clock, SamplingRule rule, Stat this.Rule = rule; this.RuleName = this.Rule.RuleName; this.Statistics = statistics ?? new Statistics(); + + if (rule.ReservoirSize > 0) + { + // Until calling GetSamplingTargets, the default is to borrow 1/s if reservoir size is + // positive. + this.ReservoirSampler = new ParentBasedSampler(new RateLimitingSampler(1, this.Clock)); + this.Borrowing = true; + } + else + { + // No reservoir sampling, we will always use the fixed rate. + this.ReservoirSampler = new AlwaysOffSampler(); + this.Borrowing = false; + } + + this.FixedRateSampler = new ParentBasedSampler(new TraceIdRatioBasedSampler(rule.FixedRate)); + + // We either have no reservoir sampling or borrow until we get a quota so have no end time. + this.ReservoirEndTime = DateTime.MaxValue; + + // We don't have a SamplingTarget so are ready to report a snapshot right away. + this.NextSnapshotTime = this.Clock.Now(); + } + + private SamplingRuleApplier( + string clientId, + SamplingRule rule, + Clock clock, + Trace.Sampler reservoirSampler, + Trace.Sampler fixedRateSampler, + bool borrowing, + Statistics statistics, + DateTime reservoirEndTime, + DateTime nextSnapshotTime) + { + this.ClientId = clientId; + this.Rule = rule; + this.RuleName = rule.RuleName; + this.Clock = clock; + this.ReservoirSampler = reservoirSampler; + this.FixedRateSampler = fixedRateSampler; + this.Borrowing = borrowing; + this.Statistics = statistics; + this.ReservoirEndTime = reservoirEndTime; + this.NextSnapshotTime = nextSnapshotTime; } internal string ClientId { get; set; } @@ -43,6 +88,16 @@ public SamplingRuleApplier(string clientId, Clock clock, SamplingRule rule, Stat internal Statistics Statistics { get; set; } + internal Trace.Sampler ReservoirSampler { get; set; } + + internal Trace.Sampler FixedRateSampler { get; set; } + + internal bool Borrowing { get; set; } + + internal DateTime ReservoirEndTime { get; set; } + + internal DateTime NextSnapshotTime { get; set; } + // check if this rule applier matches the request public bool Matches(SamplingParameters samplingParameters, Resource resource) { @@ -107,12 +162,91 @@ public bool Matches(SamplingParameters samplingParameters, Resource resource) Matcher.WildcardMatch(GetArn(in samplingParameters, resource), this.Rule.ResourceArn); } - [SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "method work in progress")] public SamplingResult ShouldSample(in SamplingParameters samplingParameters) { - // for now return drop sampling result. - // TODO: use reservoir and fixed rate sampler - return new SamplingResult(false); + Interlocked.Increment(ref this.Statistics.RequestCount); + bool reservoirExpired = this.Clock.Now() >= this.ReservoirEndTime; + SamplingResult result = !reservoirExpired + ? this.ReservoirSampler.ShouldSample(in samplingParameters) + : new SamplingResult(SamplingDecision.Drop); + + if (result.Decision != SamplingDecision.Drop) + { + if (this.Borrowing) + { + Interlocked.Increment(ref this.Statistics.BorrowCount); + } + + Interlocked.Increment(ref this.Statistics.SampleCount); + + return result; + } + + result = this.FixedRateSampler.ShouldSample(samplingParameters); + if (result.Decision != SamplingDecision.Drop) + { + Interlocked.Increment(ref this.Statistics.SampleCount); + } + + return result; + } + + // take the snapshot and reset the statistics. + public SamplingStatisticsDocument Snapshot(DateTime now) + { + double timestamp = this.Clock.ToDouble(now); + + long matchedRequests = Interlocked.Exchange(ref this.Statistics.RequestCount, 0L); + long sampledRequests = Interlocked.Exchange(ref this.Statistics.SampleCount, 0L); + long borrowedRequests = Interlocked.Exchange(ref this.Statistics.BorrowCount, 0L); + + SamplingStatisticsDocument statiscticsDocument = new SamplingStatisticsDocument( + this.ClientId, + this.RuleName, + matchedRequests, + sampledRequests, + borrowedRequests, + timestamp); + + return statiscticsDocument; + } + + public SamplingRuleApplier WithTarget(SamplingTargetDocument target, DateTime now) + { + Trace.Sampler newFixedRateSampler = target.FixedRate != null + ? new ParentBasedSampler(new TraceIdRatioBasedSampler(target.FixedRate.Value)) + : this.FixedRateSampler; + + Trace.Sampler newReservoirSampler = new AlwaysOffSampler(); + DateTime newReservoirEndTime = DateTime.MaxValue; + if (target.ReservoirQuota != null && target.ReservoirQuotaTTL != null) + { + if (target.ReservoirQuota > 0) + { + newReservoirSampler = new ParentBasedSampler(new RateLimitingSampler(target.ReservoirQuota.Value, this.Clock)); + } + else + { + newReservoirSampler = new AlwaysOffSampler(); + } + + newReservoirEndTime = this.Clock.ToDateTime(target.ReservoirQuotaTTL.Value); + } + + DateTime newNextSnapshotTime = target.Interval != null + ? now.AddSeconds(target.Interval.Value) + : now.Add(AWSXRayRemoteSampler.DefaultTargetInterval); + + return new SamplingRuleApplier( + this.ClientId, + this.Rule, + this.Clock, + newReservoirSampler, + newFixedRateSampler, + false, // no need for borrow + this.Statistics, + newReservoirEndTime, + newNextSnapshotTime); } private static string GetServiceType(Resource resource) diff --git a/src/OpenTelemetry.Sampler.AWS/SamplingStatisticsDocument.cs b/src/OpenTelemetry.Sampler.AWS/SamplingStatisticsDocument.cs new file mode 100644 index 0000000000..f452808e95 --- /dev/null +++ b/src/OpenTelemetry.Sampler.AWS/SamplingStatisticsDocument.cs @@ -0,0 +1,50 @@ +// +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Text.Json.Serialization; + +namespace OpenTelemetry.Sampler.AWS; + +internal class SamplingStatisticsDocument +{ + public SamplingStatisticsDocument(string clientID, string ruleName, long requestCount, long sampledCount, long borrowCount, double timestamp) + { + this.ClientID = clientID; + this.RuleName = ruleName; + this.RequestCount = requestCount; + this.SampledCount = sampledCount; + this.BorrowCount = borrowCount; + this.Timestamp = timestamp; + } + + [JsonPropertyName("ClientID")] + public string ClientID { get; set; } + + [JsonPropertyName("RuleName")] + public string RuleName { get; set; } + + [JsonPropertyName("RequestCount")] + public long RequestCount { get; set; } + + [JsonPropertyName("SampledCount")] + public long SampledCount { get; set; } + + [JsonPropertyName("BorrowCount")] + public long BorrowCount { get; set; } + + [JsonPropertyName("Timestamp")] + public double Timestamp { get; set; } +} diff --git a/src/OpenTelemetry.Sampler.AWS/SamplingTargetDocument.cs b/src/OpenTelemetry.Sampler.AWS/SamplingTargetDocument.cs new file mode 100644 index 0000000000..3fa7091b73 --- /dev/null +++ b/src/OpenTelemetry.Sampler.AWS/SamplingTargetDocument.cs @@ -0,0 +1,37 @@ +// +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Text.Json.Serialization; + +namespace OpenTelemetry.Sampler.AWS; + +internal class SamplingTargetDocument +{ + [JsonPropertyName("FixedRate")] + public double? FixedRate { get; set; } + + [JsonPropertyName("Interval")] + public long? Interval { get; set; } + + [JsonPropertyName("ReservoirQuota")] + public long? ReservoirQuota { get; set; } + + [JsonPropertyName("ReservoirQuotaTTL")] + public double? ReservoirQuotaTTL { get; set; } + + [JsonPropertyName("RuleName")] + public string? RuleName { get; set; } +} diff --git a/src/OpenTelemetry.Sampler.AWS/Statistics.cs b/src/OpenTelemetry.Sampler.AWS/Statistics.cs index 9b790eb0e8..e278aab772 100644 --- a/src/OpenTelemetry.Sampler.AWS/Statistics.cs +++ b/src/OpenTelemetry.Sampler.AWS/Statistics.cs @@ -18,9 +18,7 @@ namespace OpenTelemetry.Sampler.AWS; internal class Statistics { - public int RequestCount { get; internal set; } - - public int BorrowCount { get; internal set; } - - public int SampleCount { get; internal set; } + public long RequestCount; + public long BorrowCount; + public long SampleCount; } diff --git a/src/OpenTelemetry.Sampler.AWS/SystemClock.cs b/src/OpenTelemetry.Sampler.AWS/SystemClock.cs index d2ec6eb090..4f23ae6704 100644 --- a/src/OpenTelemetry.Sampler.AWS/SystemClock.cs +++ b/src/OpenTelemetry.Sampler.AWS/SystemClock.cs @@ -15,7 +15,6 @@ // using System; -using System.Diagnostics; namespace OpenTelemetry.Sampler.AWS; @@ -40,12 +39,9 @@ public override DateTime Now() return DateTime.UtcNow; } - public override long NowInSeconds() + public override long NowInMilliSeconds() { - double ts = Stopwatch.GetTimestamp(); - double s = ts / Stopwatch.Frequency; - - return (long)s; + return (long)this.Now().ToUniversalTime().Subtract(EpochStart).TotalMilliseconds; } public override DateTime ToDateTime(double seconds) diff --git a/src/OpenTelemetry.Sampler.AWS/UnprocessedStatistic.cs b/src/OpenTelemetry.Sampler.AWS/UnprocessedStatistic.cs new file mode 100644 index 0000000000..9b03449b2f --- /dev/null +++ b/src/OpenTelemetry.Sampler.AWS/UnprocessedStatistic.cs @@ -0,0 +1,38 @@ +// +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Text.Json.Serialization; + +namespace OpenTelemetry.Sampler.AWS; + +internal class UnprocessedStatistic +{ + public UnprocessedStatistic(string? errorCode, string? message, string? ruleName) + { + this.ErrorCode = errorCode; + this.Message = message; + this.RuleName = ruleName; + } + + [JsonPropertyName("ErrorCode")] + public string? ErrorCode { get; set; } + + [JsonPropertyName("Message")] + public string? Message { get; set; } + + [JsonPropertyName("RuleName")] + public string? RuleName { get; set; } +} diff --git a/test/OpenTelemetry.Sampler.AWS.Tests/Data/GetSamplingRulesResponseOptionalFields.json b/test/OpenTelemetry.Sampler.AWS.Tests/Data/GetSamplingRulesResponseOptionalFields.json new file mode 100644 index 0000000000..c0b9813c11 --- /dev/null +++ b/test/OpenTelemetry.Sampler.AWS.Tests/Data/GetSamplingRulesResponseOptionalFields.json @@ -0,0 +1,42 @@ +{ + "SamplingRuleRecords": [ + { + "SamplingRule": { + "RuleName": "Test", + "RuleARN": "arn:aws:xray:us-east-1:123456789000:sampling-rule/Test", + "ResourceARN": "*", + "Priority": 1, + "FixedRate": 0.0, + "ReservoirSize": 0, + "ServiceName": "*", + "ServiceType": "*", + "Host": "*", + "HTTPMethod": "*", + "URLPath": "*", + "Version": 1, + "Attributes": { "test": "cat-service" } + }, + "CreatedAt": 1.67799933E9, + "ModifiedAt": 1.67799933E9 + }, + { + "SamplingRule": { + "RuleName": "Default", + "RuleARN": "arn:aws:xray:us-east-1:123456789000:sampling-rule/Default", + "ResourceARN": "*", + "Priority": 10000, + "FixedRate": 0.0, + "ReservoirSize": 1, + "ServiceName": "*", + "ServiceType": "*", + "Host": "*", + "HTTPMethod": "*", + "URLPath": "*", + "Version": 1, + "Attributes": {} + }, + "CreatedAt": 0.0, + "ModifiedAt": 1.681856516E9 + } + ] +} diff --git a/test/OpenTelemetry.Sampler.AWS.Tests/Data/GetSamplingTargetsResponse.json b/test/OpenTelemetry.Sampler.AWS.Tests/Data/GetSamplingTargetsResponse.json new file mode 100644 index 0000000000..0293d57330 --- /dev/null +++ b/test/OpenTelemetry.Sampler.AWS.Tests/Data/GetSamplingTargetsResponse.json @@ -0,0 +1,23 @@ +{ + "SamplingTargetDocuments": [ + { + "RuleName": "rule1", + "FixedRate": 0.1, + "ReservoirQuota": 2, + "ReservoirQuotaTTL": 1530923107.0, + "Interval": 10 + }, + { + "RuleName": "rule3", + "FixedRate": 0.003 + } + ], + "LastRuleModification": 1530920505.0, + "UnprocessedStatistics": [ + { + "RuleName": "rule2", + "ErrorCode": "400", + "Message": "Unknown rule" + } + ] +} diff --git a/test/OpenTelemetry.Sampler.AWS.Tests/Data/GetSamplingTargetsResponseOptionalFields.json b/test/OpenTelemetry.Sampler.AWS.Tests/Data/GetSamplingTargetsResponseOptionalFields.json new file mode 100644 index 0000000000..b7ca420ecd --- /dev/null +++ b/test/OpenTelemetry.Sampler.AWS.Tests/Data/GetSamplingTargetsResponseOptionalFields.json @@ -0,0 +1,16 @@ +{ + "SamplingTargetDocuments": [ + { + "RuleName": "Test", + "FixedRate": 1.0 + } + ], + "LastRuleModification": 1530920505.0, + "UnprocessedStatistics": [ + { + "RuleName": "Default", + "ErrorCode": "400", + "Message": "Unknown rule" + } + ] +} diff --git a/test/OpenTelemetry.Sampler.AWS.Tests/OpenTelemetry.Sampler.AWS.Tests.csproj b/test/OpenTelemetry.Sampler.AWS.Tests/OpenTelemetry.Sampler.AWS.Tests.csproj index bb8099097e..e0fb3a51f5 100644 --- a/test/OpenTelemetry.Sampler.AWS.Tests/OpenTelemetry.Sampler.AWS.Tests.csproj +++ b/test/OpenTelemetry.Sampler.AWS.Tests/OpenTelemetry.Sampler.AWS.Tests.csproj @@ -21,9 +21,10 @@ - - PreserveNewest - + + + + diff --git a/test/OpenTelemetry.Sampler.AWS.Tests/TestAWSXRayRemoteSampler.cs b/test/OpenTelemetry.Sampler.AWS.Tests/TestAWSXRayRemoteSampler.cs index 5cff06fa5f..ce59cad264 100644 --- a/test/OpenTelemetry.Sampler.AWS.Tests/TestAWSXRayRemoteSampler.cs +++ b/test/OpenTelemetry.Sampler.AWS.Tests/TestAWSXRayRemoteSampler.cs @@ -16,8 +16,14 @@ using System; using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading; using OpenTelemetry.Resources; using OpenTelemetry.Trace; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; using Xunit; namespace OpenTelemetry.Sampler.AWS.Tests; @@ -53,13 +59,71 @@ public void TestSamplerWithDefaults() } [Fact] - public void TestSamplerShouldSample() + public void TestSamplerUpdateAndSample() { - Trace.Sampler sampler = AWSXRayRemoteSampler.Builder(ResourceBuilder.CreateEmpty().Build()).Build(); + // setup mock server + TestClock clock = new TestClock(); + WireMockServer mockServer = WireMockServer.Start(); - // for now the fallback sampler should be making the sampling decision - Assert.Equal( - SamplingDecision.RecordAndSample, - sampler.ShouldSample(Utils.CreateSamplingParametersWithTags(new Dictionary())).Decision); + // create sampler + AWSXRayRemoteSampler sampler = AWSXRayRemoteSampler.Builder(ResourceBuilder.CreateEmpty().Build()) + .SetPollingInterval(TimeSpan.FromMilliseconds(10)) + .SetEndpoint(mockServer.Url) + .SetClock(clock) + .Build(); + + // the sampler will use fallback sampler until rules are fetched. + Assert.Equal(SamplingDecision.RecordAndSample, this.DoSample(sampler, "cat-service")); + Assert.Equal(SamplingDecision.Drop, this.DoSample(sampler, "cat-service")); + + // GetSamplingRules mock response + mockServer + .Given(Request.Create().WithPath("/GetSamplingRules").UsingPost()) + .RespondWith( + Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(File.ReadAllText("Data/GetSamplingRulesResponseOptionalFields.json"))); + + // rules will be polled in 10 milliseconds + Thread.Sleep(2000); + + // sampler will drop because rule has 0 reservoir and 0 fixed rate + Assert.Equal(SamplingDecision.Drop, this.DoSample(sampler, "cat-service")); + + // GetSamplingTargets mock response + mockServer + .Given(Request.Create().WithPath("/SamplingTargets").UsingPost()) + .RespondWith( + Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(File.ReadAllText("Data/GetSamplingTargetsResponseOptionalFields.json"))); + + // targets will be polled in 10 seconds + Thread.Sleep(13000); + + // sampler will always sampler since target has 100% fixed rate + Assert.Equal(SamplingDecision.RecordAndSample, this.DoSample(sampler, "cat-service")); + Assert.Equal(SamplingDecision.RecordAndSample, this.DoSample(sampler, "cat-service")); + Assert.Equal(SamplingDecision.RecordAndSample, this.DoSample(sampler, "cat-service")); + + mockServer.Stop(); + } + + private SamplingDecision DoSample(Trace.Sampler sampler, string serviceName) + { + var samplingParams = new SamplingParameters( + default, + ActivityTraceId.CreateRandom(), + "myActivityName", + ActivityKind.Server, + new List>() + { + new KeyValuePair("test", serviceName), + }, + null); + + return sampler.ShouldSample(samplingParams).Decision; } } diff --git a/test/OpenTelemetry.Sampler.AWS.Tests/TestAWSXRaySamplerClient.cs b/test/OpenTelemetry.Sampler.AWS.Tests/TestAWSXRaySamplerClient.cs index 2b9863c1f8..fa78d9a108 100644 --- a/test/OpenTelemetry.Sampler.AWS.Tests/TestAWSXRaySamplerClient.cs +++ b/test/OpenTelemetry.Sampler.AWS.Tests/TestAWSXRaySamplerClient.cs @@ -109,6 +109,91 @@ public void TestGetSamplingRulesMalformed() Assert.Empty(rules); } + [Fact] + public void TestGetSamplingTargets() + { + TestClock clock = new TestClock(); + + this.CreateResponse("/SamplingTargets", "Data/GetSamplingTargetsResponse.json"); + + var request = new GetSamplingTargetsRequest(new List() + { + new SamplingStatisticsDocument( + "clientId", + "rule1", + 100, + 50, + 10, + clock.ToDouble(clock.Now())), + new SamplingStatisticsDocument( + "clientId", + "rule2", + 200, + 100, + 20, + clock.ToDouble(clock.Now())), + new SamplingStatisticsDocument( + "clientId", + "rule3", + 20, + 10, + 2, + clock.ToDouble(clock.Now())), + }); + + var responseTask = this.client.GetSamplingTargets(request); + responseTask.Wait(); + + GetSamplingTargetsResponse targetsResponse = responseTask.Result; + + Assert.Equal(2, targetsResponse.SamplingTargetDocuments.Count); + Assert.Single(targetsResponse.UnprocessedStatistics); + + Assert.Equal("rule1", targetsResponse.SamplingTargetDocuments[0].RuleName); + Assert.Equal(0.1, targetsResponse.SamplingTargetDocuments[0].FixedRate); + Assert.Equal(2, targetsResponse.SamplingTargetDocuments[0].ReservoirQuota); + Assert.Equal(1530923107.0, targetsResponse.SamplingTargetDocuments[0].ReservoirQuotaTTL); + Assert.Equal(10, targetsResponse.SamplingTargetDocuments[0].Interval); + + Assert.Equal("rule3", targetsResponse.SamplingTargetDocuments[1].RuleName); + Assert.Equal(0.003, targetsResponse.SamplingTargetDocuments[1].FixedRate); + Assert.Null(targetsResponse.SamplingTargetDocuments[1].ReservoirQuota); + Assert.Null(targetsResponse.SamplingTargetDocuments[1].ReservoirQuotaTTL); + Assert.Null(targetsResponse.SamplingTargetDocuments[1].Interval); + + Assert.Equal("rule2", targetsResponse.UnprocessedStatistics[0].RuleName); + Assert.Equal("400", targetsResponse.UnprocessedStatistics[0].ErrorCode); + Assert.Equal("Unknown rule", targetsResponse.UnprocessedStatistics[0].Message); + } + + [Fact] + public void TestGetSamplingTargetsWithMalformed() + { + TestClock clock = new TestClock(); + this.mockServer + .Given(Request.Create().WithPath("/SamplingTargets").UsingPost()) + .RespondWith( + Response.Create().WithStatusCode(200).WithHeader("Content-Type", "application/json").WithBody("notJson")); + + var request = new GetSamplingTargetsRequest(new List() + { + new SamplingStatisticsDocument( + "clientId", + "rule1", + 100, + 50, + 10, + clock.ToDouble(clock.Now())), + }); + + var responseTask = this.client.GetSamplingTargets(request); + responseTask.Wait(); + + GetSamplingTargetsResponse targetsResponse = responseTask.Result; + + Assert.Null(targetsResponse); + } + private void CreateResponse(string endpoint, string filePath) { string mockResponse = File.ReadAllText(filePath); diff --git a/test/OpenTelemetry.Sampler.AWS.Tests/TestClock.cs b/test/OpenTelemetry.Sampler.AWS.Tests/TestClock.cs index a5be1d33df..2de7500de5 100644 --- a/test/OpenTelemetry.Sampler.AWS.Tests/TestClock.cs +++ b/test/OpenTelemetry.Sampler.AWS.Tests/TestClock.cs @@ -16,6 +16,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Text; using System.Threading.Tasks; @@ -24,6 +25,7 @@ namespace OpenTelemetry.Sampler.AWS.Tests; internal class TestClock : Clock { + private static readonly DateTime EpochStart = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); private DateTime nowTime; public TestClock() @@ -41,19 +43,21 @@ public override DateTime Now() return this.nowTime; } - public override long NowInSeconds() + public override long NowInMilliSeconds() { - throw new NotImplementedException(); + return (long)this.nowTime.ToUniversalTime().Subtract(EpochStart).TotalMilliseconds; } public override DateTime ToDateTime(double seconds) { - throw new NotImplementedException(); + return EpochStart.AddSeconds(seconds); } public override double ToDouble(DateTime dateTime) { - throw new NotImplementedException(); + TimeSpan current = new TimeSpan(dateTime.ToUniversalTime().Ticks - EpochStart.Ticks); + double timestamp = Math.Round(current.TotalMilliseconds, 0) / 1000.0; + return timestamp; } // Advnaces the clock by a given time span. diff --git a/test/OpenTelemetry.Sampler.AWS.Tests/TestRateLimiter.cs b/test/OpenTelemetry.Sampler.AWS.Tests/TestRateLimiter.cs new file mode 100644 index 0000000000..4ac5fb4aa9 --- /dev/null +++ b/test/OpenTelemetry.Sampler.AWS.Tests/TestRateLimiter.cs @@ -0,0 +1,163 @@ +// +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace OpenTelemetry.Sampler.AWS.Tests; + +/// +/// This class is a .Net port of the original Java implementation. +/// This class was taken from Jaeger java client. +/// https://github.com/jaegertracing/jaeger-client-java/blob/master/jaeger-core/src/test/java/io/jaegertracing/internal/utils/RateLimiterTest.java +/// +public class TestRateLimiter +{ + [Fact] + public void TestRateLimiterWholeNumber() + { + var testClock = new TestClock(); + RateLimiter limiter = new RateLimiter(2.0, 2.0, testClock); + + Assert.True(limiter.TrySpend(1.0)); + Assert.True(limiter.TrySpend(1.0)); + Assert.False(limiter.TrySpend(1.0)); + + // move time 250ms forward, not enough credits to pay for 1.0 item + testClock.Advance(TimeSpan.FromMilliseconds(250)); + Assert.False(limiter.TrySpend(1.0)); + + // move time 500ms forward, now enough credits to pay for 1.0 item + testClock.Advance(TimeSpan.FromMilliseconds(500)); + Assert.True(limiter.TrySpend(1.0)); + Assert.False(limiter.TrySpend(1.0)); + + // move time 5s forward, enough to accumulate credits for 10 messages, but it should still be + // capped at 2 + testClock.Advance(TimeSpan.FromSeconds(5)); + Assert.True(limiter.TrySpend(1.0)); + Assert.True(limiter.TrySpend(1.0)); + Assert.False(limiter.TrySpend(1.0)); + Assert.False(limiter.TrySpend(1.0)); + Assert.False(limiter.TrySpend(1.0)); + } + + [Fact] + public void TestRateLimiterLessThanOne() + { + TestClock clock = new TestClock(); + RateLimiter limiter = new RateLimiter(0.5, 0.5, clock); + + Assert.True(limiter.TrySpend(0.25)); + Assert.True(limiter.TrySpend(0.25)); + Assert.False(limiter.TrySpend(0.25)); + + // move time 250ms forward, not enough credits to pay for 0.25 item + clock.Advance(TimeSpan.FromMilliseconds(250)); + Assert.False(limiter.TrySpend(0.25)); + + // move clock 500ms forward, enough credits for 0.25 item + clock.Advance(TimeSpan.FromMilliseconds(500)); + Assert.True(limiter.TrySpend(0.25)); + + // move time 5s forward, enough to accumulate credits for 2.5 messages, but it should still be + // capped at 0.5 + clock.Advance(TimeSpan.FromSeconds(5)); + Assert.True(limiter.TrySpend(0.25)); + Assert.True(limiter.TrySpend(0.25)); + Assert.False(limiter.TrySpend(0.25)); + Assert.False(limiter.TrySpend(0.25)); + Assert.False(limiter.TrySpend(0.25)); + } + + [Fact] + public void TestRateLimiterMaxBalance() + { + TestClock clock = new TestClock(); + RateLimiter limiter = new RateLimiter(0.1, 1.0, clock); + + clock.Advance(TimeSpan.FromMilliseconds(0.1)); + Assert.True(limiter.TrySpend(1.0)); + Assert.False(limiter.TrySpend(1.0)); + + // move time 20s forward, enough to accumulate credits for 2 messages, but it should still be + // capped at 1 + clock.Advance(TimeSpan.FromSeconds(20)); + + Assert.True(limiter.TrySpend(1.0)); + Assert.False(limiter.TrySpend(1.0)); + } + + [Fact] + public void TestRateLimiterInitial() + { + TestClock clock = new TestClock(); + RateLimiter limiter = new RateLimiter(1000, 100, clock); + + Assert.True(limiter.TrySpend(100)); // consume initial (max) balance + Assert.False(limiter.TrySpend(1)); + + clock.Advance(TimeSpan.FromMilliseconds(49)); // add 49 credits + Assert.False(limiter.TrySpend(50)); + + clock.Advance(TimeSpan.FromMilliseconds(1)); // add 1 credit + Assert.True(limiter.TrySpend(50)); // consume accrued balance + Assert.False(limiter.TrySpend(1)); + + clock.Advance(TimeSpan.FromSeconds(1000)); // add a lot of credits (max out balance) + Assert.True(limiter.TrySpend(1)); // take 1 credit + + clock.Advance(TimeSpan.FromSeconds(1000)); // add a lot of credits (max out balance) + Assert.False(limiter.TrySpend(101)); // can't consume more than max balance + Assert.True(limiter.TrySpend(100)); // consume max balance + Assert.False(limiter.TrySpend(1)); + } + + [Fact] + public async Task TestRateLimiterConcurrencyAsync() + { + int numWorkers = 8; + int creditsPerWorker = 1000; + TestClock clock = new TestClock(); + RateLimiter limiter = new RateLimiter(1, numWorkers * creditsPerWorker, clock); + int count = 0; + List tasks = new List(numWorkers); + + for (int w = 0; w < numWorkers; ++w) + { + Task task = Task.Run(() => + { + for (int i = 0; i < creditsPerWorker * 2; ++i) + { + if (limiter.TrySpend(1)) + { + Interlocked.Increment(ref count); // count allowed operations + } + } + }); + + tasks.Add(task); + } + + await Task.WhenAll(tasks); + + Assert.Equal(numWorkers * creditsPerWorker, count); + Assert.False(limiter.TrySpend(1)); + } +} diff --git a/test/OpenTelemetry.Sampler.AWS.Tests/TestRateLimitingSampler.cs b/test/OpenTelemetry.Sampler.AWS.Tests/TestRateLimitingSampler.cs new file mode 100644 index 0000000000..9335667956 --- /dev/null +++ b/test/OpenTelemetry.Sampler.AWS.Tests/TestRateLimitingSampler.cs @@ -0,0 +1,44 @@ +// +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using OpenTelemetry.Trace; +using Xunit; + +namespace OpenTelemetry.Sampler.AWS.Tests; + +public class TestRateLimitingSampler +{ + [Fact] + public void TestLimitsRate() + { + TestClock clock = new TestClock(); + Trace.Sampler sampler = new RateLimitingSampler(1, clock); + + Assert.Equal(SamplingDecision.RecordAndSample, sampler.ShouldSample(Utils.CreateSamplingParameters()).Decision); + + // balance used up + Assert.Equal(SamplingDecision.Drop, sampler.ShouldSample(Utils.CreateSamplingParameters()).Decision); + + // balance restore after 1 second, not yet + clock.Advance(TimeSpan.FromMilliseconds(100)); + Assert.Equal(SamplingDecision.Drop, sampler.ShouldSample(Utils.CreateSamplingParameters()).Decision); + + // balance restored + clock.Advance(TimeSpan.FromMilliseconds(900)); + Assert.Equal(SamplingDecision.RecordAndSample, sampler.ShouldSample(Utils.CreateSamplingParameters()).Decision); + } +} diff --git a/test/OpenTelemetry.Sampler.AWS.Tests/TestRulesCache.cs b/test/OpenTelemetry.Sampler.AWS.Tests/TestRulesCache.cs index 57391ce932..eafc974c00 100644 --- a/test/OpenTelemetry.Sampler.AWS.Tests/TestRulesCache.cs +++ b/test/OpenTelemetry.Sampler.AWS.Tests/TestRulesCache.cs @@ -97,7 +97,137 @@ public void TestUpdateRulesRemovesOlderRule() Assert.Equal("Default", rulesCache.RuleAppliers[0].RuleName); } - // TODO: Add tests for matching sampling rules once the reservoir and fixed rate samplers are added. + [Fact] + public void TestShouldSampleMatchesExactRule() + { + var clock = new TestClock(); + var rulesCache = new RulesCache(clock, "clientId", ResourceBuilder.CreateEmpty().Build(), new AlwaysOffSampler()) + { + RuleAppliers = new List() + { + { new SamplingRuleApplier("clientId", clock, this.CreateRule("ruleWillMatch", 1, 0.0, 1), new Statistics()) }, // higher priority rule will sample + { new SamplingRuleApplier("clientId", clock, this.CreateRule("ruleWillNotMatch", 0, 0.0, 2), new Statistics()) }, // this rule will not sample + }, + }; + + // the rule will sample by borrowing from reservoir + Assert.Equal(SamplingDecision.RecordAndSample, rulesCache.ShouldSample(default).Decision); + + var statistics = rulesCache.Snapshot(clock.Now()); + Assert.Equal(2, statistics.Count); + Assert.Equal("ruleWillMatch", statistics[0].RuleName); + Assert.Equal(1, statistics[0].RequestCount); + Assert.Equal(1, statistics[0].SampledCount); + Assert.Equal(1, statistics[0].BorrowCount); + Assert.Equal("ruleWillNotMatch", statistics[1].RuleName); + Assert.Equal(0, statistics[1].RequestCount); + Assert.Equal(0, statistics[1].SampledCount); + Assert.Equal(0, statistics[1].BorrowCount); + } + + [Fact] + public void TestFallbackSamplerMatchesWhenNoRules() + { + var clock = new TestClock(); + var rulesCache = new RulesCache(clock, "clientId", ResourceBuilder.CreateEmpty().Build(), new AlwaysOffSampler()) + { + RuleAppliers = new List(), + }; + + // the fallback sampler will not sample + Assert.Equal(SamplingDecision.Drop, rulesCache.ShouldSample(default).Decision); + } + + [Fact] + public void TestUpdateTargets() + { + var clock = new TestClock(); + var rulesCache = new RulesCache(clock, "clientId", ResourceBuilder.CreateEmpty().Build(), new AlwaysOffSampler()) + { + RuleAppliers = new List() + { + { new SamplingRuleApplier("clientId", clock, this.CreateRule("rule1", 1, 0.0, 1), new Statistics()) }, // this rule will sample 1 req/sec + { new SamplingRuleApplier("clientId", clock, this.CreateRule("rule2", 0, 0.0, 2), new Statistics()) }, + }, + }; + + Assert.Equal(SamplingDecision.RecordAndSample, rulesCache.ShouldSample(default).Decision); + Assert.Equal(SamplingDecision.Drop, rulesCache.ShouldSample(default).Decision); + + // update targets + var targetForRule1 = new SamplingTargetDocument() + { + FixedRate = 0.0, + Interval = 0, + ReservoirQuota = 2, + ReservoirQuotaTTL = clock.ToDouble(clock.Now().AddMinutes(5)), + RuleName = "rule1", + }; + var targetForRule2 = new SamplingTargetDocument() + { + FixedRate = 0.0, + Interval = 0, + ReservoirQuota = 0, + ReservoirQuotaTTL = clock.ToDouble(clock.Now().AddMinutes(5)), + RuleName = "rule2", + }; + + var targets = new Dictionary() + { + { "rule1", targetForRule1 }, + { "rule2", targetForRule2 }, + }; + + rulesCache.UpdateTargets(targets); + + // now rule1 will sample 2 req/sec + Assert.Equal(SamplingDecision.RecordAndSample, rulesCache.ShouldSample(default).Decision); + Assert.Equal(SamplingDecision.RecordAndSample, rulesCache.ShouldSample(default).Decision); + Assert.Equal(SamplingDecision.Drop, rulesCache.ShouldSample(default).Decision); + } + + [Fact] + public void TestNextTargetFetchTime() + { + var clock = new TestClock(); + var rulesCache = new RulesCache(clock, "clientId", ResourceBuilder.CreateEmpty().Build(), new AlwaysOffSampler()) + { + RuleAppliers = new List() + { + { new SamplingRuleApplier("clientId", clock, this.CreateRule("rule1", 1, 0.0, 1), new Statistics()) }, + { new SamplingRuleApplier("clientId", clock, this.CreateRule("rule2", 0, 0.0, 2), new Statistics()) }, + }, + }; + + // update targets + var targetForRule1 = new SamplingTargetDocument() + { + FixedRate = 0.0, + Interval = 10, // next target poll after 10s + ReservoirQuota = 2, + ReservoirQuotaTTL = clock.ToDouble(clock.Now().Add(TimeSpan.FromMinutes(5))), + RuleName = "rule1", + }; + var targetForRule2 = new SamplingTargetDocument() + { + FixedRate = 0.0, + Interval = 5, // next target poll after 5s + ReservoirQuota = 0, + ReservoirQuotaTTL = clock.ToDouble(clock.Now().Add(TimeSpan.FromMinutes(5))), + RuleName = "rule2", + }; + + var targets = new Dictionary() + { + { "rule1", targetForRule1 }, + { "rule2", targetForRule2 }, + }; + + rulesCache.UpdateTargets(targets); + + // next target will be fetched after 5s + Assert.Equal(clock.Now().AddSeconds(5), rulesCache.NextTargetFetchTime()); + } private SamplingRule CreateDefaultRule(int reservoirSize, double fixedRate) { diff --git a/test/OpenTelemetry.Sampler.AWS.Tests/TestSamplingRuleApplier.cs b/test/OpenTelemetry.Sampler.AWS.Tests/TestSamplingRuleApplier.cs index b62fc93c41..34ae66df43 100644 --- a/test/OpenTelemetry.Sampler.AWS.Tests/TestSamplingRuleApplier.cs +++ b/test/OpenTelemetry.Sampler.AWS.Tests/TestSamplingRuleApplier.cs @@ -14,7 +14,9 @@ // limitations under the License. // +using System; using System.Collections.Generic; +using OpenTelemetry.Trace; using Xunit; namespace OpenTelemetry.Sampler.AWS.Tests; @@ -217,5 +219,207 @@ public void TestAttributeMatchingWithLessActivityTags() Assert.False(applier.Matches(Utils.CreateSamplingParametersWithTags(activityTags), Utils.CreateResource("myServiceName", "aws_ecs"))); } - // TODO: Add more test cases for ShouldSample once the sampling logic is added. + // fixed rate is 1.0 and reservoir is 0 + [Fact] + public void TestFixedRateAlwaysSample() + { + TestClock clock = new TestClock(); + SamplingRule rule = new SamplingRule( + "rule1", + 1, + 1.0, // fixed rate + 0, // reservoir + "*", + "*", + "*", + "*", + "*", + "*", + 1, + new Dictionary()); + + var applier = new SamplingRuleApplier("clientId", clock, rule, new Statistics()); + + Assert.Equal(SamplingDecision.RecordAndSample, applier.ShouldSample(default).Decision); + + // test if the snapshot was correctly captured + var statistics = applier.Snapshot(clock.Now()); + Assert.Equal("clientId", statistics.ClientID); + Assert.Equal("rule1", statistics.RuleName); + Assert.Equal(clock.ToDouble(clock.Now()), statistics.Timestamp); + Assert.Equal(1, statistics.RequestCount); + Assert.Equal(1, statistics.SampledCount); + Assert.Equal(0, statistics.BorrowCount); + + // reset statistics + statistics = applier.Snapshot(clock.Now()); + Assert.Equal(0, statistics.RequestCount); + Assert.Equal(0, statistics.SampledCount); + Assert.Equal(0, statistics.BorrowCount); + } + + // fixed rate is 0.0 and reservoir is 0 + [Fact] + public void TestFixedRateNeverSample() + { + TestClock clock = new TestClock(); + SamplingRule rule = new SamplingRule( + "rule1", + 1, + 0.0, // fixed rate + 0, // reservoir + "*", + "*", + "*", + "*", + "*", + "*", + 1, + new Dictionary()); + + var applier = new SamplingRuleApplier("clientId", clock, rule, new Statistics()); + + Assert.Equal(SamplingDecision.Drop, applier.ShouldSample(default).Decision); + + // test if the snapshot was correctly captured + var statistics = applier.Snapshot(clock.Now()); + Assert.Equal("clientId", statistics.ClientID); + Assert.Equal("rule1", statistics.RuleName); + Assert.Equal(clock.ToDouble(clock.Now()), statistics.Timestamp); + Assert.Equal(1, statistics.RequestCount); + Assert.Equal(0, statistics.SampledCount); + Assert.Equal(0, statistics.BorrowCount); + } + + [Fact] + public void TestBorrowFromReservoir() + { + TestClock clock = new TestClock(); + SamplingRule rule = new SamplingRule( + "rule1", + 1, + 0.0, // fixed rate + 100, // reservoir + "*", + "*", + "*", + "*", + "*", + "*", + 1, + new Dictionary()); + + var applier = new SamplingRuleApplier("clientId", clock, rule, new Statistics()); + + // sampled by borrowing + Assert.Equal(SamplingDecision.RecordAndSample, applier.ShouldSample(default).Decision); + + // can only borrow 1 req/sec + Assert.Equal(SamplingDecision.Drop, applier.ShouldSample(default).Decision); + + // test if the snapshot was correctly captured + var statistics = applier.Snapshot(clock.Now()); + Assert.Equal("clientId", statistics.ClientID); + Assert.Equal("rule1", statistics.RuleName); + Assert.Equal(clock.ToDouble(clock.Now()), statistics.Timestamp); + Assert.Equal(2, statistics.RequestCount); + Assert.Equal(1, statistics.SampledCount); + Assert.Equal(1, statistics.BorrowCount); + } + + [Fact] + public void TestWithTarget() + { + TestClock clock = new TestClock(); + SamplingRule rule = new SamplingRule( + "rule1", + 1, + 0.0, // fixed rate + 100, // reservoir + "*", + "*", + "*", + "*", + "*", + "*", + 1, + new Dictionary()); + + var applier = new SamplingRuleApplier("clientId", clock, rule, new Statistics()); + + // no target assigned yet. so borrow 1 from reseroir every second + Assert.Equal(SamplingDecision.RecordAndSample, applier.ShouldSample(default).Decision); + Assert.Equal(SamplingDecision.Drop, applier.ShouldSample(default).Decision); + clock.Advance(TimeSpan.FromSeconds(1)); + Assert.Equal(SamplingDecision.RecordAndSample, applier.ShouldSample(default).Decision); + Assert.Equal(SamplingDecision.Drop, applier.ShouldSample(default).Decision); + + // get the target + SamplingTargetDocument target = new SamplingTargetDocument() + { + FixedRate = 0.0, + Interval = 10, + ReservoirQuota = 2, + ReservoirQuotaTTL = clock.ToDouble(clock.Now().Add(TimeSpan.FromSeconds(10))), + RuleName = "rule1", + }; + + applier = applier.WithTarget(target, clock.Now()); + + // 2 req/sec quota + Assert.Equal(SamplingDecision.RecordAndSample, applier.ShouldSample(default).Decision); + Assert.Equal(SamplingDecision.RecordAndSample, applier.ShouldSample(default).Decision); + Assert.Equal(SamplingDecision.Drop, applier.ShouldSample(default).Decision); + } + + [Fact] + public void TestWithTargetWithoutQuota() + { + TestClock clock = new TestClock(); + SamplingRule rule = new SamplingRule( + "rule1", + 1, + 0.0, // fixed rate + 100, // reservoir + "*", + "*", + "*", + "*", + "*", + "*", + 1, + new Dictionary()); + + var applier = new SamplingRuleApplier("clientId", clock, rule, new Statistics()); + + // no target assigned yet. so borrow 1 from reseroir every second + Assert.Equal(SamplingDecision.RecordAndSample, applier.ShouldSample(default).Decision); + Assert.Equal(SamplingDecision.Drop, applier.ShouldSample(default).Decision); + clock.Advance(TimeSpan.FromSeconds(1)); + Assert.Equal(SamplingDecision.RecordAndSample, applier.ShouldSample(default).Decision); + Assert.Equal(SamplingDecision.Drop, applier.ShouldSample(default).Decision); + + var statistics = applier.Snapshot(clock.Now()); + Assert.Equal(4, statistics.RequestCount); + Assert.Equal(2, statistics.SampledCount); + Assert.Equal(2, statistics.BorrowCount); + + // get the target + SamplingTargetDocument target = new SamplingTargetDocument() + { + FixedRate = 1.0, + Interval = 10, + ReservoirQuota = null, + ReservoirQuotaTTL = null, + RuleName = "rule1", + }; + applier = applier.WithTarget(target, clock.Now()); + + // no reservoir, sample using fixed rate (100% sample) + Assert.Equal(SamplingDecision.RecordAndSample, applier.ShouldSample(default).Decision); + statistics = applier.Snapshot(clock.Now()); + Assert.Equal(1, statistics.RequestCount); + Assert.Equal(1, statistics.SampledCount); + Assert.Equal(0, statistics.BorrowCount); + } } diff --git a/test/OpenTelemetry.Sampler.AWS.Tests/Utils.cs b/test/OpenTelemetry.Sampler.AWS.Tests/Utils.cs index d5a1babd46..b415200872 100644 --- a/test/OpenTelemetry.Sampler.AWS.Tests/Utils.cs +++ b/test/OpenTelemetry.Sampler.AWS.Tests/Utils.cs @@ -23,6 +23,22 @@ namespace OpenTelemetry.Sampler.AWS.Tests; internal static class Utils { + internal static SamplingParameters CreateSamplingParameters() + { + return CreateSamplingParametersWithTags(new Dictionary()); + } + + internal static SamplingParameters CreateSamplingParametersWithRootContext() + { + return new SamplingParameters( + default, + ActivityTraceId.CreateRandom(), + "myActivityName", + ActivityKind.Server, + null, + null); + } + internal static SamplingParameters CreateSamplingParametersWithTags(Dictionary tags) { ActivityTraceId traceId = ActivityTraceId.CreateRandom(); From 9fab3ed0b212b092c3b4dfafb748a35516dcbc57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Kie=C5=82kowicz?= Date: Wed, 31 May 2023 00:14:54 +0200 Subject: [PATCH 2/2] [ResourceDetectors.AWS] Release as .NET 6 instead of NET Standard 2.0 (#1177) --- .../PublicAPI.Shipped.txt | 0 .../PublicAPI.Unshipped.txt | 0 .../AWSECSResourceDetector.cs | 6 +- .../AWSEKSResourceDetector.cs | 9 ++- .../Http/Handler.cs | 14 ++-- .../ServerCertificateValidationProvider.cs | 55 +++++++------- ...OpenTelemetry.ResourceDetectors.AWS.csproj | 12 +-- ...erverCertificateValidationProviderTests.cs | 76 +++++++++++-------- ...lemetry.ResourceDetectors.AWS.Tests.csproj | 1 + 9 files changed, 92 insertions(+), 81 deletions(-) rename src/OpenTelemetry.ResourceDetectors.AWS/.publicApi/{netstandard2.0 => net6.0}/PublicAPI.Shipped.txt (100%) rename src/OpenTelemetry.ResourceDetectors.AWS/.publicApi/{netstandard2.0 => net6.0}/PublicAPI.Unshipped.txt (100%) diff --git a/src/OpenTelemetry.ResourceDetectors.AWS/.publicApi/netstandard2.0/PublicAPI.Shipped.txt b/src/OpenTelemetry.ResourceDetectors.AWS/.publicApi/net6.0/PublicAPI.Shipped.txt similarity index 100% rename from src/OpenTelemetry.ResourceDetectors.AWS/.publicApi/netstandard2.0/PublicAPI.Shipped.txt rename to src/OpenTelemetry.ResourceDetectors.AWS/.publicApi/net6.0/PublicAPI.Shipped.txt diff --git a/src/OpenTelemetry.ResourceDetectors.AWS/.publicApi/netstandard2.0/PublicAPI.Unshipped.txt b/src/OpenTelemetry.ResourceDetectors.AWS/.publicApi/net6.0/PublicAPI.Unshipped.txt similarity index 100% rename from src/OpenTelemetry.ResourceDetectors.AWS/.publicApi/netstandard2.0/PublicAPI.Unshipped.txt rename to src/OpenTelemetry.ResourceDetectors.AWS/.publicApi/net6.0/PublicAPI.Unshipped.txt diff --git a/src/OpenTelemetry.ResourceDetectors.AWS/AWSECSResourceDetector.cs b/src/OpenTelemetry.ResourceDetectors.AWS/AWSECSResourceDetector.cs index b7de0f6e82..5e59597d3e 100644 --- a/src/OpenTelemetry.ResourceDetectors.AWS/AWSECSResourceDetector.cs +++ b/src/OpenTelemetry.ResourceDetectors.AWS/AWSECSResourceDetector.cs @@ -14,6 +14,7 @@ // limitations under the License. // +#if !NETFRAMEWORK using System; using System.Collections.Generic; using System.Net.Http; @@ -195,8 +196,8 @@ internal static List> ExtractMetadataV4ResourceAttr { while (!streamReader.EndOfStream) { - var trimmedLine = streamReader.ReadLine().Trim(); - if (trimmedLine.Length > 64) + var trimmedLine = streamReader.ReadLine()?.Trim(); + if (trimmedLine?.Length > 64) { containerId = trimmedLine.Substring(trimmedLine.Length - 64); return containerId; @@ -212,3 +213,4 @@ internal static bool IsECSProcess() return Environment.GetEnvironmentVariable(AWSECSMetadataURLKey) != null || Environment.GetEnvironmentVariable(AWSECSMetadataURLV4Key) != null; } } +#endif diff --git a/src/OpenTelemetry.ResourceDetectors.AWS/AWSEKSResourceDetector.cs b/src/OpenTelemetry.ResourceDetectors.AWS/AWSEKSResourceDetector.cs index 4f4faf4e77..81e3d6ba4c 100644 --- a/src/OpenTelemetry.ResourceDetectors.AWS/AWSEKSResourceDetector.cs +++ b/src/OpenTelemetry.ResourceDetectors.AWS/AWSEKSResourceDetector.cs @@ -14,6 +14,8 @@ // limitations under the License. // +#if !NETFRAMEWORK + using System; using System.Collections.Generic; using System.Net.Http; @@ -85,7 +87,7 @@ internal static List> ExtractResourceAttributes(str { while (!streamReader.EndOfStream) { - stringBuilder.Append(streamReader.ReadLine().Trim()); + stringBuilder.Append(streamReader.ReadLine()?.Trim()); } } @@ -109,8 +111,8 @@ internal static List> ExtractResourceAttributes(str { while (!streamReader.EndOfStream) { - var trimmedLine = streamReader.ReadLine().Trim(); - if (trimmedLine.Length > 64) + var trimmedLine = streamReader.ReadLine()?.Trim(); + if (trimmedLine?.Length > 64) { return trimmedLine.Substring(trimmedLine.Length - 64); } @@ -165,3 +167,4 @@ private static string GetEKSClusterInfo(string credentials, HttpClientHandler? h return ResourceDetectorUtils.SendOutRequest(AWSClusterInfoUrl, "GET", new KeyValuePair("Authorization", credentials), httpClientHandler).Result; } } +#endif diff --git a/src/OpenTelemetry.ResourceDetectors.AWS/Http/Handler.cs b/src/OpenTelemetry.ResourceDetectors.AWS/Http/Handler.cs index 27b89b2e2b..0b2b1cf262 100644 --- a/src/OpenTelemetry.ResourceDetectors.AWS/Http/Handler.cs +++ b/src/OpenTelemetry.ResourceDetectors.AWS/Http/Handler.cs @@ -14,6 +14,8 @@ // limitations under the License. // +#if !NETFRAMEWORK + using System; using System.Net.Http; @@ -25,24 +27,19 @@ internal class Handler { try { - ServerCertificateValidationProvider serverCertificateValidationProvider = + ServerCertificateValidationProvider? serverCertificateValidationProvider = ServerCertificateValidationProvider.FromCertificateFile(certificateFile); - if (!serverCertificateValidationProvider.IsCertificateLoaded ?? false) + if (serverCertificateValidationProvider == null) { AWSResourcesEventSource.Log.FailedToValidateCertificate(nameof(Handler), "Failed to Load the certificate file into trusted collection"); return null; } - if (serverCertificateValidationProvider.ValidationCallback == null) - { - return null; - } - var clientHandler = new HttpClientHandler(); clientHandler.ServerCertificateCustomValidationCallback = (sender, x509Certificate2, x509Chain, sslPolicyErrors) => - serverCertificateValidationProvider.ValidationCallback(null, x509Certificate2, x509Chain, sslPolicyErrors); + serverCertificateValidationProvider.ValidationCallback(sender, x509Certificate2, x509Chain, sslPolicyErrors); return clientHandler; } catch (Exception ex) @@ -53,3 +50,4 @@ internal class Handler return null; } } +#endif diff --git a/src/OpenTelemetry.ResourceDetectors.AWS/Http/ServerCertificateValidationProvider.cs b/src/OpenTelemetry.ResourceDetectors.AWS/Http/ServerCertificateValidationProvider.cs index 5f26f4f064..535e67efda 100644 --- a/src/OpenTelemetry.ResourceDetectors.AWS/Http/ServerCertificateValidationProvider.cs +++ b/src/OpenTelemetry.ResourceDetectors.AWS/Http/ServerCertificateValidationProvider.cs @@ -14,6 +14,8 @@ // limitations under the License. // +#if !NETFRAMEWORK + using System; using System.IO; using System.Linq; @@ -24,43 +26,30 @@ namespace OpenTelemetry.ResourceDetectors.AWS.Http; internal class ServerCertificateValidationProvider { - private static readonly ServerCertificateValidationProvider InvalidProvider = new(null); - - private readonly X509Certificate2Collection? trustedCertificates; + private readonly X509Certificate2Collection trustedCertificates; - private ServerCertificateValidationProvider(X509Certificate2Collection? trustedCertificates) + private ServerCertificateValidationProvider(X509Certificate2Collection trustedCertificates) { - if (trustedCertificates == null) - { - this.trustedCertificates = null; - this.ValidationCallback = null; - this.IsCertificateLoaded = false; - return; - } - this.trustedCertificates = trustedCertificates; - this.ValidationCallback = (sender, cert, chain, errors) => - this.ValidateCertificate(new X509Certificate2(cert), chain, errors); - this.IsCertificateLoaded = true; + this.ValidationCallback = (_, cert, chain, errors) => + this.ValidateCertificate(cert != null ? new X509Certificate2(cert) : null, chain, errors); } - public bool? IsCertificateLoaded { get; } - - public RemoteCertificateValidationCallback? ValidationCallback { get; } + public RemoteCertificateValidationCallback ValidationCallback { get; } - public static ServerCertificateValidationProvider FromCertificateFile(string certificateFile) + public static ServerCertificateValidationProvider? FromCertificateFile(string certificateFile) { if (!File.Exists(certificateFile)) { AWSResourcesEventSource.Log.FailedToValidateCertificate(nameof(ServerCertificateValidationProvider), "Certificate File does not exist"); - return InvalidProvider; + return null; } var trustedCertificates = new X509Certificate2Collection(); if (!LoadCertificateToTrustedCollection(trustedCertificates, certificateFile)) { AWSResourcesEventSource.Log.FailedToValidateCertificate(nameof(ServerCertificateValidationProvider), "Failed to load certificate in trusted collection"); - return InvalidProvider; + return null; } return new ServerCertificateValidationProvider(trustedCertificates); @@ -90,7 +79,7 @@ private static bool HasCommonCertificate(X509Chain chain, X509Certificate2Collec { foreach (var certificate in collection) { - if (Enumerable.SequenceEqual(chainElement.Certificate.GetPublicKey(), certificate.GetPublicKey())) + if (chainElement.Certificate.GetPublicKey().SequenceEqual(certificate.GetPublicKey())) { return true; } @@ -100,7 +89,7 @@ private static bool HasCommonCertificate(X509Chain chain, X509Certificate2Collec return false; } - private bool ValidateCertificate(X509Certificate2 cert, X509Chain chain, SslPolicyErrors errors) + private bool ValidateCertificate(X509Certificate2? cert, X509Chain? chain, SslPolicyErrors errors) { var isSslPolicyPassed = errors == SslPolicyErrors.None || errors == SslPolicyErrors.RemoteCertificateChainErrors; @@ -117,6 +106,18 @@ private bool ValidateCertificate(X509Certificate2 cert, X509Chain chain, SslPoli } } + if (chain == null) + { + AWSResourcesEventSource.Log.FailedToValidateCertificate(nameof(ServerCertificateValidationProvider), "Failed to validate certificate. Certificate chain is null."); + return false; + } + + if (cert == null) + { + AWSResourcesEventSource.Log.FailedToValidateCertificate(nameof(ServerCertificateValidationProvider), "Failed to validate certificate. Certificate is null."); + return false; + } + chain.ChainPolicy.ExtraStore.AddRange(this.trustedCertificates); chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority; @@ -149,12 +150,9 @@ private bool ValidateCertificate(X509Certificate2 cert, X509Chain chain, SslPoli } var trustCertificates = string.Empty; - if (this.trustedCertificates != null) + foreach (var trustCertificate in this.trustedCertificates) { - foreach (var trustCertificate in this.trustedCertificates) - { - trustCertificates += " " + trustCertificate.Subject; - } + trustCertificates += " " + trustCertificate.Subject; } AWSResourcesEventSource.Log.FailedToValidateCertificate( @@ -165,3 +163,4 @@ private bool ValidateCertificate(X509Certificate2 cert, X509Chain chain, SslPoli return isSslPolicyPassed && isValidChain && isTrusted; } } +#endif diff --git a/src/OpenTelemetry.ResourceDetectors.AWS/OpenTelemetry.ResourceDetectors.AWS.csproj b/src/OpenTelemetry.ResourceDetectors.AWS/OpenTelemetry.ResourceDetectors.AWS.csproj index a4ec9726b9..c2278435cf 100644 --- a/src/OpenTelemetry.ResourceDetectors.AWS/OpenTelemetry.ResourceDetectors.AWS.csproj +++ b/src/OpenTelemetry.ResourceDetectors.AWS/OpenTelemetry.ResourceDetectors.AWS.csproj @@ -2,7 +2,7 @@ - netstandard2.0 + net6.0 $(TargetFrameworks);$(NetFrameworkMinimumSupportedVersion) OpenTelemetry Extensions - AWS Resource Detectors for ElasticBeanstalk, EC2, ECS, EKS. ResourceDetectors.AWS- @@ -12,17 +12,9 @@ - - - - - - - - - + diff --git a/test/OpenTelemetry.ResourceDetectors.AWS.Tests/Http/ServerCertificateValidationProviderTests.cs b/test/OpenTelemetry.ResourceDetectors.AWS.Tests/Http/ServerCertificateValidationProviderTests.cs index 7f69902b6c..04da229c89 100644 --- a/test/OpenTelemetry.ResourceDetectors.AWS.Tests/Http/ServerCertificateValidationProviderTests.cs +++ b/test/OpenTelemetry.ResourceDetectors.AWS.Tests/Http/ServerCertificateValidationProviderTests.cs @@ -16,8 +16,8 @@ #if !NETFRAMEWORK -using System.Runtime.InteropServices; using System.Security.Cryptography.X509Certificates; +using Moq; using OpenTelemetry.ResourceDetectors.AWS.Http; using Xunit; @@ -25,46 +25,62 @@ namespace OpenTelemetry.ResourceDetectors.AWS.Tests.Http; public class ServerCertificateValidationProviderTests { - private const string INVALIDCRTNAME = "invalidcert"; + private const string InvalidCertificateName = "invalidcert"; [Fact] public void TestValidCertificate() { - // This test fails on Linux in netcoreapp3.1, but passes in net6.0 and net7.0. - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - using (CertificateUploader certificateUploader = new CertificateUploader()) - { - certificateUploader.Create(); - - // Loads the certificate to the trusted collection from the file - ServerCertificateValidationProvider serverCertificateValidationProvider = - ServerCertificateValidationProvider.FromCertificateFile(certificateUploader.FilePath); - - // Validates if the certificate loaded into the trusted collection. - Assert.True(serverCertificateValidationProvider.IsCertificateLoaded); - - var certificate = new X509Certificate2(certificateUploader.FilePath); - X509Chain chain = new X509Chain(); - chain.Build(certificate); - - // validates if certificate is valid - Assert.NotNull(serverCertificateValidationProvider); - Assert.NotNull(serverCertificateValidationProvider.ValidationCallback); - Assert.True(serverCertificateValidationProvider.ValidationCallback(null, certificate, chain, System.Net.Security.SslPolicyErrors.None)); - } - } + using CertificateUploader certificateUploader = new CertificateUploader(); + certificateUploader.Create(); + + ServerCertificateValidationProvider serverCertificateValidationProvider = + ServerCertificateValidationProvider.FromCertificateFile(certificateUploader.FilePath); + + Assert.NotNull(serverCertificateValidationProvider); + + var certificate = new X509Certificate2(certificateUploader.FilePath); + X509Chain chain = new X509Chain(); + chain.Build(certificate); + + // validates if certificate is valid + Assert.NotNull(serverCertificateValidationProvider); + Assert.NotNull(serverCertificateValidationProvider.ValidationCallback); + Assert.True(serverCertificateValidationProvider.ValidationCallback(this, certificate, chain, System.Net.Security.SslPolicyErrors.None)); } [Fact] public void TestInValidCertificate() { - // Loads the certificate to the trusted collection from the file ServerCertificateValidationProvider serverCertificateValidationProvider = - ServerCertificateValidationProvider.FromCertificateFile(INVALIDCRTNAME); + ServerCertificateValidationProvider.FromCertificateFile(InvalidCertificateName); + + Assert.Null(serverCertificateValidationProvider); + } + + [Fact] + public void TestTestCallbackWithNullCertificate() + { + using var certificateUploader = new CertificateUploader(); + certificateUploader.Create(); + + ServerCertificateValidationProvider serverCertificateValidationProvider = + ServerCertificateValidationProvider.FromCertificateFile(certificateUploader.FilePath); + + Assert.NotNull(serverCertificateValidationProvider); + Assert.False(serverCertificateValidationProvider.ValidationCallback(this, null, Mock.Of(), default)); + } + + [Fact] + public void TestCallbackWithNullChain() + { + using var certificateUploader = new CertificateUploader(); + certificateUploader.Create(); + + ServerCertificateValidationProvider serverCertificateValidationProvider = + ServerCertificateValidationProvider.FromCertificateFile(certificateUploader.FilePath); - // Validates if the certificate file loaded. - Assert.False(serverCertificateValidationProvider.IsCertificateLoaded); + Assert.NotNull(serverCertificateValidationProvider); + Assert.False(serverCertificateValidationProvider.ValidationCallback(this, Mock.Of(), null, default)); } } diff --git a/test/OpenTelemetry.ResourceDetectors.AWS.Tests/OpenTelemetry.ResourceDetectors.AWS.Tests.csproj b/test/OpenTelemetry.ResourceDetectors.AWS.Tests/OpenTelemetry.ResourceDetectors.AWS.Tests.csproj index 061f4214ac..12b3750bb0 100644 --- a/test/OpenTelemetry.ResourceDetectors.AWS.Tests/OpenTelemetry.ResourceDetectors.AWS.Tests.csproj +++ b/test/OpenTelemetry.ResourceDetectors.AWS.Tests/OpenTelemetry.ResourceDetectors.AWS.Tests.csproj @@ -10,6 +10,7 @@ + all