Skip to content

Improve performance for UriHelpers.CleanUri#8199

Merged
andrewlock merged 5 commits intomasterfrom
andrew/uri-helpers
Feb 18, 2026
Merged

Improve performance for UriHelpers.CleanUri#8199
andrewlock merged 5 commits intomasterfrom
andrew/uri-helpers

Conversation

@andrewlock
Copy link
Member

@andrewlock andrewlock commented Feb 12, 2026

Summary of changes

Reduce allocations for UriHelpers.CleanUri() that's called as part of HTTP requests (among others)

Reason for change

The allocations showed up in some profiling so decided to dig in. And Oh Boy, this stuff is terrible on < .NET 6 😅 And it's still terrible after this PR, just less terrible 😅

Implementation details

Two main improvements:

  • If we need to use a StringBuilder to format the Uri anyway (because we're stripping identifiers), then use one for the whole string, instead of partially building the string, and then doing another allocation.
  • Use GetComponents(), specifying all the components we need, instead of accessing each of the Uri properties ad-hoc. It turns out these properties are actually very expensive, because they trigger a bunch of analysis. Doing it one-shot doesn't avoid that, but it still gives some improvements.

Test coverage

I wrote a "characterisation" unit test, seeing as we didn't have any, to describe the current behaviour, to ensure I didn't change anything. Benchmarking was done (as shown below), comparing the original implementation with the new implementation, for three different scenarios:

  • CleanUri:
_uri = new Uri("http://datadoghq.com/some-path");
UriHelpers.CleanUri(_uri, removeScheme: true, tryRemoveIds: true);
  • CleanUri_WithIds:
_uriWithIds= new Uri("http://datadoghq.com/some-path/123");
UriHelpers.CleanUri(_uriWithIds, removeScheme: true, tryRemoveIds: true);
  • CleanUri_IgnoreIds:
_uriWithIds= new Uri("http://datadoghq.com/some-path/123");
UriHelpers.CleanUri(_uriWithIds, removeScheme: true, tryRemoveIds: false);

The benchmark results heavily depend on the Uri provided, so it's only the relative changes that are interesting here, comparing original to updated.

Also note that these took a long time to run, I was on a call for some of it, so the execution times may be a bit wonky, but allocations was what I was focusing on 😅

Benchmarking code

[MemoryDiagnoser, GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory), CategoriesColumn]
public class UriHelperBenchmarks
{
    private Uri _uri;
    private Uri _uriWithIds;
    private string _result;

    [GlobalSetup]
    public void GlobalSetup()
    {
        _uri = new Uri("http://datadoghq.com/some-path");
        _uriWithIds = new Uri("http://datadoghq.com/some-path/123");
    }

    [GlobalCleanup]
    public void GlobalCleanup()
    {
    }

    [Benchmark(Baseline = true)]
    [BenchmarkCategory("CleanUri")]
    public int CleanUri_Original()
    {
        _result = OriginalUriHelpers.CleanUri(_uri, removeScheme: true, tryRemoveIds: true);
        return _result.Length;
    }

    [Benchmark(Baseline = true)]
    [BenchmarkCategory("CleanUri_WithIds")]
    public int CleanUri_WithIds_Original()
    {
        _result = OriginalUriHelpers.CleanUri(_uriWithIds, removeScheme: true, tryRemoveIds: true);
        return _result.Length;
    }

    [Benchmark(Baseline = true)]
    [BenchmarkCategory("CleanUri_IgnoreIds")]
    public int CleanUri_IgnoreIds_Original()
    {
        _result = OriginalUriHelpers.CleanUri(_uriWithIds, removeScheme: true, tryRemoveIds: false);
        return _result.Length;
    }

    [Benchmark]
    [BenchmarkCategory("CleanUri")]
    public int CleanUri_Updated()
    {
        _result = UriHelpers.CleanUri(_uri, removeScheme: true, tryRemoveIds: true);
        return _result.Length;
    }

    [Benchmark]
    [BenchmarkCategory("CleanUri_WithIds")]
    public int CleanUri_WithIds_Updated()
    {
        _result = UriHelpers.CleanUri(_uriWithIds, removeScheme: true, tryRemoveIds: true);
        return _result.Length;
    }

    [Benchmark]
    [BenchmarkCategory("CleanUri_IgnoreIds")]
    public int CleanUri_IgnoreIds_Updated()
    {
        _result = UriHelpers.CleanUri(_uriWithIds, removeScheme: true, tryRemoveIds: false);
        return _result.Length;
    }
}

Original benchmarks

Method Runtime Mean Allocated
CleanUri_Original .NET 10.0 122.02 ns 168 B
CleanUri_Updated .NET 10.0 92.97 ns 120 B
CleanUri_Original .NET 6.0 162.29 ns 168 B
CleanUri_Updated .NET 6.0 159.69 ns 120 B
CleanUri_Original .NET Core 2.1 263.90 ns 800 B
CleanUri_Updated .NET Core 2.1 237.06 ns 752 B
CleanUri_Original .NET Core 3.1 281.37 ns 792 B
CleanUri_Updated .NET Core 3.1 263.45 ns 744 B
CleanUri_Original .NET Framework 4.8 263.97 ns 802 B
CleanUri_Updated .NET Framework 4.8 216.58 ns 754 B
CleanUri_IgnoreIds_Original .NET 10.0 53.41 ns 128 B
CleanUri_IgnoreIds_Updated .NET 10.0 46.89 ns 80 B
CleanUri_IgnoreIds_Original .NET 6.0 70.11 ns 128 B
CleanUri_IgnoreIds_Updated .NET 6.0 61.18 ns 80 B
CleanUri_IgnoreIds_Original .NET Core 2.1 148.38 ns 856 B
CleanUri_IgnoreIds_Updated .NET Core 2.1 151.01 ns 800 B
CleanUri_IgnoreIds_Original .NET Core 3.1 170.16 ns 848 B
CleanUri_IgnoreIds_Updated .NET Core 3.1 173.91 ns 800 B
CleanUri_IgnoreIds_Original .NET Framework 4.8 159.43 ns 859 B
CleanUri_IgnoreIds_Updated .NET Framework 4.8 145.18 ns 802 B
CleanUri_WithIds_Original .NET 10.0 112.47 ns 168 B
CleanUri_WithIds_Updated .NET 10.0 89.27 ns 120 B
CleanUri_WithIds_Original .NET 6.0 147.53 ns 168 B
CleanUri_WithIds_Updated .NET 6.0 138.39 ns 120 B
CleanUri_WithIds_Original .NET Core 2.1 232.67 ns 912 B
CleanUri_WithIds_Updated .NET Core 2.1 249.10 ns 856 B
CleanUri_WithIds_Original .NET Core 3.1 273.64 ns 888 B
CleanUri_WithIds_Updated .NET Core 3.1 252.48 ns 840 B
CleanUri_WithIds_Original .NET Framework 4.8 246.37 ns 915 B
CleanUri_WithIds_Updated .NET Framework 4.8 248.15 ns 859 B
Method Runtime Mean Allocated
CleanUri_Original .NET 10.0 240.0 ns 472 B
CleanUri_Updated .NET 10.0 223.9 ns 424 B
CleanUri_Original .NET 6.0 354.1 ns 456 B
CleanUri_Updated .NET 6.0 351.7 ns 408 B
CleanUri_Original .NET Core 2.1 515.3 ns 1112 B
CleanUri_Updated .NET Core 2.1 498.0 ns 1064 B
CleanUri_Original .NET Core 3.1 547.7 ns 1096 B
CleanUri_Updated .NET Core 3.1 536.6 ns 1048 B
CleanUri_Original .NET Framework 4.8 647.9 ns 1244 B
CleanUri_Updated .NET Framework 4.8 628.0 ns 1196 B
CleanUri_IgnoreIds_Original .NET 10.0 210.6 ns 440 B
CleanUri_IgnoreIds_Updated .NET 10.0 179.5 ns 272 B
CleanUri_IgnoreIds_Original .NET 6.0 317.4 ns 424 B
CleanUri_IgnoreIds_Updated .NET 6.0 293.5 ns 264 B
CleanUri_IgnoreIds_Original .NET Core 2.1 493.0 ns 1176 B
CleanUri_IgnoreIds_Updated .NET Core 2.1 439.8 ns 1000 B
CleanUri_IgnoreIds_Original .NET Core 3.1 505.4 ns 1160 B
CleanUri_IgnoreIds_Updated .NET Core 3.1 463.7 ns 992 B
CleanUri_IgnoreIds_Original .NET Framework 4.8 602.3 ns 1316 B
CleanUri_IgnoreIds_Updated .NET Framework 4.8 559.4 ns 1139 B
CleanUri_WithIds_Original .NET 10.0 247.0 ns 480 B
CleanUri_WithIds_Updated .NET 10.0 252.5 ns 432 B
CleanUri_WithIds_Original .NET 6.0 392.9 ns 464 B
CleanUri_WithIds_Updated .NET 6.0 393.4 ns 416 B
CleanUri_WithIds_Original .NET Core 2.1 598.1 ns 1232 B
CleanUri_WithIds_Updated .NET Core 2.1 579.0 ns 1176 B
CleanUri_WithIds_Original .NET Core 3.1 610.8 ns 1200 B
CleanUri_WithIds_Updated .NET Core 3.1 593.2 ns 1152 B
CleanUri_WithIds_Original .NET Framework 4.8 684.0 ns 1372 B
CleanUri_WithIds_Updated .NET Framework 4.8 688.1 ns 1316 B

EDIT: I realised that the allocations here aren't quite right, because this is calling the method on the same Uri instance in each iteration. As Uri uses heavy caching, that distorts the allocations a lot. A more accurate test is to use a new Uri for each iteration, as the following benchmarks do. The allocation is more because we're both allocating the Uri and doing the stripping, and the benchmark doesn't make use of caching, but thankfully we're still better in all cases.

EDIT 2: Shortly after submitting this PR, I discovered DangerousDisablePathAndQueryCanonicalization and tl;dr; that's a nightmare 😅 It's only in .NET 6+, but they don't expose whether it was set publicly, so had to do some gnarly stuff with duck types. I'm not proud, but the benchmarks still seem worth pursuing it to me. This is the same issue that plagues #8203

Method Runtime Mean Error Allocated
CleanUri_Original .NET 10.0 241.7598 ns 3.7925 ns 472 B
CleanUri_Updated .NET 10.0 232.1299 ns 4.5930 ns 424 B
CleanUri_Original .NET 6.0 360.8269 ns 6.3565 ns 456 B
CleanUri_Updated .NET 6.0 346.9848 ns 4.7634 ns 408 B
CleanUri_Original .NET Core 2.1 519.9048 ns 6.2900 ns 1112 B
CleanUri_Updated .NET Core 2.1 520.4468 ns 7.2347 ns 1064 B
CleanUri_Original .NET Core 3.1 556.0087 ns 5.3537 ns 1096 B
CleanUri_Updated .NET Core 3.1 543.6773 ns 3.7395 ns 1048 B
CleanUri_Original .NET Framework 4.8 632.6677 ns 5.9946 ns 1244 B
CleanUri_Updated .NET Framework 4.8 632.7827 ns 12.1315 ns 1196 B
CleanUri_IgnoreIds_Original .NET 10.0 200.0625 ns 2.1611 ns 440 B
CleanUri_IgnoreIds_Updated .NET 10.0 172.3196 ns 3.3675 ns 272 B
CleanUri_IgnoreIds_Original .NET 6.0 337.9443 ns 6.7787 ns 424 B
CleanUri_IgnoreIds_Updated .NET 6.0 313.6942 ns 6.1152 ns 264 B
CleanUri_IgnoreIds_Original .NET Core 2.1 493.7106 ns 9.8265 ns 1176 B
CleanUri_IgnoreIds_Updated .NET Core 2.1 461.0721 ns 9.0235 ns 1000 B
CleanUri_IgnoreIds_Original .NET Core 3.1 523.9340 ns 7.3235 ns 1160 B
CleanUri_IgnoreIds_Updated .NET Core 3.1 473.7414 ns 6.9061 ns 992 B
CleanUri_IgnoreIds_Original .NET Framework 4.8 631.2814 ns 9.9056 ns 1316 B
CleanUri_IgnoreIds_Updated .NET Framework 4.8 605.1636 ns 10.9916 ns 1139 B
CleanUri_WithIds_Original .NET 10.0 268.1831 ns 4.7108 ns 480 B
CleanUri_WithIds_Updated .NET 10.0 268.6544 ns 8.1581 ns 432 B
CleanUri_WithIds_Original .NET 6.0 408.5128 ns 8.4589 ns 464 B
CleanUri_WithIds_Updated .NET 6.0 381.8052 ns 4.4549 ns 416 B
CleanUri_WithIds_Original .NET Core 2.1 572.6368 ns 5.4855 ns 1232 B
CleanUri_WithIds_Updated .NET Core 2.1 567.9524 ns 8.8147 ns 1176 B
CleanUri_WithIds_Original .NET Core 3.1 612.2201 ns 7.8213 ns 1200 B
CleanUri_WithIds_Updated .NET Core 3.1 594.9918 ns 6.8069 ns 1152 B
CleanUri_WithIds_Original .NET Framework 4.8 700.4916 ns 13.6586 ns 1372 B
CleanUri_WithIds_Updated .NET Framework 4.8 699.0997 ns 10.7405 ns 1316 B
CleanUri_Dangerous_Original .NET 10.0 225.5236 ns 4.3965 ns 472 B
CleanUri_Dangerous_Updated .NET 10.0 208.1699 ns 3.5823 ns 424 B
CleanUri_Dangerous_Original .NET 6.0 336.6692 ns 3.6419 ns 456 B
CleanUri_Dangerous_Updated .NET 6.0 315.4834 ns 4.6207 ns 408 B
CleanUri_Dangerous_IgnoreIds_Original .NET 10.0 185.8492 ns 3.7319 ns 440 B
CleanUri_Dangerous_IgnoreIds_Updated .NET 10.0 190.3548 ns 3.4563 ns 440 B
CleanUri_Dangerous_IgnoreIds_Original .NET 6.0 277.9524 ns 4.8131 ns 424 B
CleanUri_Dangerous_IgnoreIds_Updated .NET 6.0 330.9686 ns 6.3933 ns 424 B
CleanUri_Dangerous_WithIds_Original .NET 10.0 233.4651 ns 4.6167 ns 480 B
CleanUri_Dangerous_WithIds_Updated .NET 10.0 225.7008 ns 4.5418 ns 432 B
CleanUri_Dangerous_WithIds_Original .NET 6.0 355.9743 ns 6.1388 ns 464 B
CleanUri_Dangerous_WithIds_Updated .NET 6.0 370.6714 ns 7.1053 ns 416 B

Other details

Spotted while working on https://datadoghq.atlassian.net/browse/LANGPLAT-842

@andrewlock andrewlock requested a review from a team as a code owner February 12, 2026 17:39
@andrewlock andrewlock added area:tracer The core tracer library (Datadog.Trace, does not include OpenTracing, native code, or integrations) type:performance Performance, speed, latency, resource usage (CPU, memory) labels Feb 12, 2026
@dd-trace-dotnet-ci-bot
Copy link

dd-trace-dotnet-ci-bot bot commented Feb 12, 2026

Execution-Time Benchmarks Report ⏱️

Execution-time results for samples comparing This PR (8199) and master.

✅ No regressions detected - check the details below

Full Metrics Comparison

FakeDbCommand

Metric Master (Mean ± 95% CI) Current (Mean ± 95% CI) Change Status
.NET Framework 4.8 - Baseline
duration69.17 ± (69.15 - 69.45) ms68.96 ± (69.04 - 69.29) ms-0.3%
.NET Framework 4.8 - Bailout
duration73.17 ± (73.09 - 73.41) ms72.90 ± (72.88 - 73.14) ms-0.4%
.NET Framework 4.8 - CallTarget+Inlining+NGEN
duration1039.74 ± (1040.02 - 1046.06) ms1038.37 ± (1047.12 - 1057.54) ms-0.1%
.NET Core 3.1 - Baseline
process.internal_duration_ms22.31 ± (22.28 - 22.34) ms22.43 ± (22.39 - 22.47) ms+0.6%✅⬆️
process.time_to_main_ms87.40 ± (87.24 - 87.56) ms86.94 ± (86.75 - 87.13) ms-0.5%
runtime.dotnet.exceptions.count0 ± (0 - 0)0 ± (0 - 0)+0.0%
runtime.dotnet.mem.committed15.49 ± (15.48 - 15.49) MB15.48 ± (15.48 - 15.48) MB-0.0%
runtime.dotnet.threads.count12 ± (12 - 12)12 ± (12 - 12)+0.0%
.NET Core 3.1 - Bailout
process.internal_duration_ms22.26 ± (22.23 - 22.28) ms22.33 ± (22.29 - 22.36) ms+0.3%✅⬆️
process.time_to_main_ms88.47 ± (88.32 - 88.63) ms88.26 ± (88.06 - 88.46) ms-0.2%
runtime.dotnet.exceptions.count0 ± (0 - 0)0 ± (0 - 0)+0.0%
runtime.dotnet.mem.committed15.51 ± (15.50 - 15.51) MB15.52 ± (15.51 - 15.52) MB+0.1%✅⬆️
runtime.dotnet.threads.count13 ± (13 - 13)13 ± (13 - 13)+0.0%
.NET Core 3.1 - CallTarget+Inlining+NGEN
process.internal_duration_ms255.07 ± (251.56 - 258.57) ms255.99 ± (252.91 - 259.07) ms+0.4%✅⬆️
process.time_to_main_ms493.87 ± (493.34 - 494.40) ms493.05 ± (492.50 - 493.59) ms-0.2%
runtime.dotnet.exceptions.count0 ± (0 - 0)0 ± (0 - 0)+0.0%
runtime.dotnet.mem.committed52.15 ± (52.13 - 52.17) MB52.11 ± (52.09 - 52.13) MB-0.1%
runtime.dotnet.threads.count28 ± (28 - 28)28 ± (28 - 28)+0.0%
.NET 6 - Baseline
process.internal_duration_ms21.06 ± (21.03 - 21.08) ms21.02 ± (20.99 - 21.05) ms-0.2%
process.time_to_main_ms75.31 ± (75.17 - 75.44) ms75.22 ± (75.08 - 75.36) ms-0.1%
runtime.dotnet.exceptions.count0 ± (0 - 0)0 ± (0 - 0)+0.0%
runtime.dotnet.mem.committed15.18 ± (15.17 - 15.18) MB15.20 ± (15.20 - 15.20) MB+0.2%✅⬆️
runtime.dotnet.threads.count10 ± (10 - 10)10 ± (10 - 10)+0.0%
.NET 6 - Bailout
process.internal_duration_ms20.98 ± (20.95 - 21.00) ms20.97 ± (20.94 - 20.99) ms-0.0%
process.time_to_main_ms76.27 ± (76.18 - 76.36) ms76.44 ± (76.32 - 76.56) ms+0.2%✅⬆️
runtime.dotnet.exceptions.count0 ± (0 - 0)0 ± (0 - 0)+0.0%
runtime.dotnet.mem.committed15.30 ± (15.30 - 15.31) MB15.31 ± (15.30 - 15.31) MB+0.0%✅⬆️
runtime.dotnet.threads.count11 ± (11 - 11)11 ± (11 - 11)+0.0%
.NET 6 - CallTarget+Inlining+NGEN
process.internal_duration_ms258.59 ± (257.66 - 259.51) ms257.04 ± (256.17 - 257.90) ms-0.6%
process.time_to_main_ms475.08 ± (474.28 - 475.88) ms472.06 ± (471.57 - 472.55) ms-0.6%
runtime.dotnet.exceptions.count0 ± (0 - 0)0 ± (0 - 0)+0.0%
runtime.dotnet.mem.committed52.92 ± (52.89 - 52.94) MB53.06 ± (53.03 - 53.09) MB+0.3%✅⬆️
runtime.dotnet.threads.count28 ± (28 - 28)28 ± (28 - 28)-0.1%
.NET 8 - Baseline
process.internal_duration_ms18.84 ± (18.82 - 18.86) ms18.97 ± (18.94 - 19.00) ms+0.7%✅⬆️
process.time_to_main_ms68.28 ± (68.15 - 68.41) ms68.37 ± (68.25 - 68.48) ms+0.1%✅⬆️
runtime.dotnet.exceptions.count0 ± (0 - 0)0 ± (0 - 0)+0.0%
runtime.dotnet.mem.committed7.68 ± (7.67 - 7.69) MB7.69 ± (7.69 - 7.70) MB+0.1%✅⬆️
runtime.dotnet.threads.count10 ± (10 - 10)10 ± (10 - 10)+0.0%
.NET 8 - Bailout
process.internal_duration_ms19.00 ± (18.96 - 19.03) ms18.99 ± (18.96 - 19.02) ms-0.1%
process.time_to_main_ms69.49 ± (69.37 - 69.62) ms69.63 ± (69.52 - 69.74) ms+0.2%✅⬆️
runtime.dotnet.exceptions.count0 ± (0 - 0)0 ± (0 - 0)+0.0%
runtime.dotnet.mem.committed7.74 ± (7.73 - 7.75) MB7.75 ± (7.75 - 7.76) MB+0.2%✅⬆️
runtime.dotnet.threads.count11 ± (11 - 11)11 ± (11 - 11)+0.0%
.NET 8 - CallTarget+Inlining+NGEN
process.internal_duration_ms179.35 ± (178.44 - 180.25) ms180.48 ± (179.44 - 181.53) ms+0.6%✅⬆️
process.time_to_main_ms427.95 ± (427.30 - 428.59) ms430.51 ± (429.93 - 431.10) ms+0.6%✅⬆️
runtime.dotnet.exceptions.count0 ± (0 - 0)0 ± (0 - 0)+0.0%
runtime.dotnet.mem.committed35.89 ± (35.86 - 35.92) MB35.94 ± (35.91 - 35.98) MB+0.2%✅⬆️
runtime.dotnet.threads.count27 ± (27 - 27)27 ± (27 - 27)+0.2%✅⬆️

HttpMessageHandler

Metric Master (Mean ± 95% CI) Current (Mean ± 95% CI) Change Status
.NET Framework 4.8 - Baseline
duration212.75 ± (212.46 - 213.54) ms212.67 ± (212.78 - 213.89) ms-0.0%
.NET Framework 4.8 - Bailout
duration219.13 ± (218.71 - 219.72) ms217.39 ± (217.31 - 218.24) ms-0.8%
.NET Framework 4.8 - CallTarget+Inlining+NGEN
duration1221.60 ± (1219.66 - 1226.50) ms1221.94 ± (1221.55 - 1229.30) ms+0.0%✅⬆️
.NET Core 3.1 - Baseline
process.internal_duration_ms215.35 ± (214.82 - 215.87) ms211.77 ± (211.25 - 212.30) ms-1.7%
process.time_to_main_ms100.46 ± (100.17 - 100.74) ms98.65 ± (98.38 - 98.93) ms-1.8%
runtime.dotnet.exceptions.count3 ± (3 - 3)3 ± (3 - 3)+0.0%
runtime.dotnet.mem.committed20.47 ± (20.46 - 20.49) MB20.46 ± (20.44 - 20.47) MB-0.1%
runtime.dotnet.threads.count20 ± (20 - 20)20 ± (20 - 20)-0.3%
.NET Core 3.1 - Bailout
process.internal_duration_ms213.97 ± (213.47 - 214.47) ms212.32 ± (211.80 - 212.83) ms-0.8%
process.time_to_main_ms101.40 ± (101.12 - 101.68) ms100.59 ± (100.28 - 100.91) ms-0.8%
runtime.dotnet.exceptions.count3 ± (3 - 3)3 ± (3 - 3)+0.0%
runtime.dotnet.mem.committed20.47 ± (20.46 - 20.49) MB20.52 ± (20.50 - 20.53) MB+0.2%✅⬆️
runtime.dotnet.threads.count21 ± (21 - 21)21 ± (21 - 21)+0.1%✅⬆️
.NET Core 3.1 - CallTarget+Inlining+NGEN
process.internal_duration_ms482.92 ± (480.71 - 485.12) ms474.47 ± (472.26 - 476.68) ms-1.7%
process.time_to_main_ms551.52 ± (550.59 - 552.45) ms544.74 ± (543.95 - 545.54) ms-1.2%
runtime.dotnet.exceptions.count3 ± (3 - 3)3 ± (3 - 3)+0.0%
runtime.dotnet.mem.committed60.61 ± (60.48 - 60.74) MB61.05 ± (60.91 - 61.19) MB+0.7%✅⬆️
runtime.dotnet.threads.count30 ± (30 - 30)30 ± (30 - 30)-0.2%
.NET 6 - Baseline
process.internal_duration_ms212.45 ± (211.98 - 212.93) ms211.01 ± (210.48 - 211.54) ms-0.7%
process.time_to_main_ms79.06 ± (78.82 - 79.29) ms78.26 ± (78.02 - 78.50) ms-1.0%
runtime.dotnet.exceptions.count4 ± (4 - 4)4 ± (4 - 4)+0.0%
runtime.dotnet.mem.committed16.19 ± (16.17 - 16.20) MB16.19 ± (16.17 - 16.20) MB-0.0%
runtime.dotnet.threads.count20 ± (19 - 20)20 ± (19 - 20)+0.0%✅⬆️
.NET 6 - Bailout
process.internal_duration_ms213.33 ± (212.77 - 213.89) ms212.88 ± (212.09 - 213.67) ms-0.2%
process.time_to_main_ms80.86 ± (80.65 - 81.08) ms80.31 ± (79.99 - 80.63) ms-0.7%
runtime.dotnet.exceptions.count4 ± (4 - 4)4 ± (4 - 4)+0.0%
runtime.dotnet.mem.committed16.17 ± (16.15 - 16.19) MB16.22 ± (16.20 - 16.24) MB+0.3%✅⬆️
runtime.dotnet.threads.count21 ± (20 - 21)21 ± (20 - 21)-0.1%
.NET 6 - CallTarget+Inlining+NGEN
process.internal_duration_ms476.27 ± (472.33 - 480.21) ms469.49 ± (465.70 - 473.29) ms-1.4%
process.time_to_main_ms493.12 ± (492.23 - 494.01) ms487.35 ± (486.45 - 488.26) ms-1.2%
runtime.dotnet.exceptions.count4 ± (4 - 4)4 ± (4 - 4)+0.0%
runtime.dotnet.mem.committed57.29 ± (57.13 - 57.46) MB57.55 ± (57.37 - 57.74) MB+0.5%✅⬆️
runtime.dotnet.threads.count30 ± (30 - 30)30 ± (30 - 30)-0.1%
.NET 8 - Baseline
process.internal_duration_ms219.00 ± (218.41 - 219.60) ms214.71 ± (214.14 - 215.28) ms-2.0%
process.time_to_main_ms85.86 ± (85.58 - 86.14) ms84.14 ± (83.89 - 84.40) ms-2.0%
runtime.dotnet.exceptions.count4 ± (4 - 4)4 ± (4 - 4)+0.0%
runtime.dotnet.mem.committed16.07 ± (16.06 - 16.09) MB16.04 ± (16.02 - 16.05) MB-0.2%
runtime.dotnet.threads.count19 ± (19 - 19)19 ± (19 - 19)+0.2%✅⬆️
.NET 8 - Bailout
process.internal_duration_ms218.40 ± (217.80 - 218.99) ms213.46 ± (212.95 - 213.97) ms-2.3%
process.time_to_main_ms87.36 ± (87.12 - 87.61) ms85.68 ± (85.42 - 85.93) ms-1.9%
runtime.dotnet.exceptions.count4 ± (4 - 4)4 ± (4 - 4)+0.0%
runtime.dotnet.mem.committed16.15 ± (16.14 - 16.17) MB16.20 ± (16.18 - 16.21) MB+0.3%✅⬆️
runtime.dotnet.threads.count20 ± (20 - 20)20 ± (20 - 20)-0.4%
.NET 8 - CallTarget+Inlining+NGEN
process.internal_duration_ms485.17 ± (477.85 - 492.49) ms474.77 ± (468.09 - 481.44) ms-2.1%
process.time_to_main_ms508.16 ± (507.33 - 508.99) ms501.92 ± (501.06 - 502.78) ms-1.2%
runtime.dotnet.exceptions.count4 ± (4 - 4)4 ± (4 - 4)+0.0%
runtime.dotnet.mem.committed54.29 ± (54.26 - 54.33) MB54.37 ± (54.33 - 54.40) MB+0.1%✅⬆️
runtime.dotnet.threads.count29 ± (29 - 29)29 ± (29 - 29)+0.6%✅⬆️
Comparison explanation

Execution-time benchmarks measure the whole time it takes to execute a program, and are intended to measure the one-off costs. Cases where the execution time results for the PR are worse than latest master results are highlighted in **red**. The following thresholds were used for comparing the execution times:

  • Welch test with statistical test for significance of 5%
  • Only results indicating a difference greater than 5% and 5 ms are considered.

Note that these results are based on a single point-in-time result for each branch. For full results, see the dashboard.

Graphs show the p99 interval based on the mean and StdDev of the test run, as well as the mean value of the run (shown as a diamond below the graph).

Duration charts
FakeDbCommand (.NET Framework 4.8)
gantt
    title Execution time (ms) FakeDbCommand (.NET Framework 4.8)
    dateFormat  x
    axisFormat %Q
    todayMarker off
    section Baseline
    This PR (8199) - mean (69ms)  : 67, 71
    master - mean (69ms)  : 67, 71

    section Bailout
    This PR (8199) - mean (73ms)  : 72, 74
    master - mean (73ms)  : 72, 75

    section CallTarget+Inlining+NGEN
    This PR (8199) - mean (1,052ms)  : 973, 1131
    master - mean (1,043ms)  : 1000, 1086

Loading
FakeDbCommand (.NET Core 3.1)
gantt
    title Execution time (ms) FakeDbCommand (.NET Core 3.1)
    dateFormat  x
    axisFormat %Q
    todayMarker off
    section Baseline
    This PR (8199) - mean (116ms)  : 113, 119
    master - mean (116ms)  : 113, 120

    section Bailout
    This PR (8199) - mean (117ms)  : 114, 119
    master - mean (117ms)  : 115, 119

    section CallTarget+Inlining+NGEN
    This PR (8199) - mean (773ms)  : 712, 834
    master - mean (777ms)  : 725, 828

Loading
FakeDbCommand (.NET 6)
gantt
    title Execution time (ms) FakeDbCommand (.NET 6)
    dateFormat  x
    axisFormat %Q
    todayMarker off
    section Baseline
    This PR (8199) - mean (102ms)  : 98, 105
    master - mean (102ms)  : 99, 105

    section Bailout
    This PR (8199) - mean (103ms)  : 101, 104
    master - mean (103ms)  : 101, 104

    section CallTarget+Inlining+NGEN
    This PR (8199) - mean (761ms)  : 739, 783
    master - mean (763ms)  : 737, 790

Loading
FakeDbCommand (.NET 8)
gantt
    title Execution time (ms) FakeDbCommand (.NET 8)
    dateFormat  x
    axisFormat %Q
    todayMarker off
    section Baseline
    This PR (8199) - mean (94ms)  : 91, 96
    master - mean (94ms)  : 91, 97

    section Bailout
    This PR (8199) - mean (95ms)  : 93, 97
    master - mean (95ms)  : 93, 98

    section CallTarget+Inlining+NGEN
    This PR (8199) - mean (640ms)  : 626, 654
    master - mean (635ms)  : 622, 648

Loading
HttpMessageHandler (.NET Framework 4.8)
gantt
    title Execution time (ms) HttpMessageHandler (.NET Framework 4.8)
    dateFormat  x
    axisFormat %Q
    todayMarker off
    section Baseline
    This PR (8199) - mean (213ms)  : 205, 222
    master - mean (213ms)  : 205, 221

    section Bailout
    This PR (8199) - mean (218ms)  : 211, 224
    master - mean (219ms)  : 214, 225

    section CallTarget+Inlining+NGEN
    This PR (8199) - mean (1,225ms)  : 1170, 1281
    master - mean (1,223ms)  : 1172, 1274

Loading
HttpMessageHandler (.NET Core 3.1)
gantt
    title Execution time (ms) HttpMessageHandler (.NET Core 3.1)
    dateFormat  x
    axisFormat %Q
    todayMarker off
    section Baseline
    This PR (8199) - mean (321ms)  : 307, 335
    master - mean (326ms)  : 315, 337

    section Bailout
    This PR (8199) - mean (323ms)  : 311, 335
    master - mean (326ms)  : 316, 336

    section CallTarget+Inlining+NGEN
    This PR (8199) - mean (1,051ms)  : 1000, 1103
    master - mean (1,069ms)  : 1016, 1121

Loading
HttpMessageHandler (.NET 6)
gantt
    title Execution time (ms) HttpMessageHandler (.NET 6)
    dateFormat  x
    axisFormat %Q
    todayMarker off
    section Baseline
    This PR (8199) - mean (299ms)  : 287, 310
    master - mean (301ms)  : 292, 310

    section Bailout
    This PR (8199) - mean (303ms)  : 288, 318
    master - mean (304ms)  : 291, 316

    section CallTarget+Inlining+NGEN
    This PR (8199) - mean (992ms)  : 936, 1047
    master - mean (1,001ms)  : 930, 1071

Loading
HttpMessageHandler (.NET 8)
gantt
    title Execution time (ms) HttpMessageHandler (.NET 8)
    dateFormat  x
    axisFormat %Q
    todayMarker off
    section Baseline
    This PR (8199) - mean (311ms)  : 299, 322
    master - mean (316ms)  : 303, 328

    section Bailout
    This PR (8199) - mean (310ms)  : 301, 320
    master - mean (317ms)  : 305, 329

    section CallTarget+Inlining+NGEN
    This PR (8199) - mean (1,007ms)  : 902, 1113
    master - mean (1,030ms)  : 939, 1120

Loading


private static string CleanUriAndRemoveIds(Uri uri, bool removeScheme)
{
var sb = StringBuilderCache.Acquire();
Copy link
Collaborator

Choose a reason for hiding this comment

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

NIT: should we create this with a size estimation line in line 66? Something like var sb = StringBuilderCache.Acquire(uri.AbsolutePath.Length + 64);

Copy link
Member Author

Choose a reason for hiding this comment

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

tbh, I don't know. We had started to not bother in various place, and just to always allocate the Max size, so we basically always end up creating one of the max size anyway, and creating a smaller one isn't actually a benefit if we're just going to allocate a bigger one later 😄

Copy link
Collaborator

@NachoEchevarria NachoEchevarria left a comment

Choose a reason for hiding this comment

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

Very nice! Thanks!

…uired

| Method                      | Job                |      Mean | Allocated | Alloc Ratio |
| --------------------------- | ------------------ | --------: | --------: | ----------: |
| CleanUri_Original           | .NET 10.0          |  98.36 ns |     168 B |        0.21 |
| CleanUri_Updated            | .NET 10.0          |  75.24 ns |     120 B |        0.15 |
| CleanUri_Original           | .NET 6.0           | 126.13 ns |     168 B |        0.21 |
| CleanUri_Updated            | .NET 6.0           | 120.56 ns |     120 B |        0.15 |
| CleanUri_Original           | .NET Core 2.1      | 217.51 ns |     800 B |        1.00 |
| CleanUri_Updated            | .NET Core 2.1      | 195.59 ns |     752 B |        0.94 |
| CleanUri_Original           | .NET Core 3.1      | 247.55 ns |     792 B |        0.99 |
| CleanUri_Updated            | .NET Core 3.1      | 229.05 ns |     744 B |        0.93 |
| CleanUri_Original           | .NET Framework 4.8 | 186.70 ns |     802 B |        1.00 |
| CleanUri_Updated            | .NET Framework 4.8 | 205.19 ns |     754 B |        0.94 |
|                             |                    |           |           |             |
| CleanUri_IgnoreIds_Original | .NET 10.0          |  55.58 ns |     128 B |        0.15 |
| CleanUri_IgnoreIds_Updated  | .NET 10.0          |  53.36 ns |     128 B |        0.15 |
| CleanUri_IgnoreIds_Original | .NET 6.0           |  68.13 ns |     128 B |        0.15 |
| CleanUri_IgnoreIds_Updated  | .NET 6.0           |  73.44 ns |     128 B |        0.15 |
| CleanUri_IgnoreIds_Original | .NET Core 2.1      | 150.71 ns |     856 B |        1.00 |
| CleanUri_IgnoreIds_Updated  | .NET Core 2.1      | 150.08 ns |     856 B |        1.00 |
| CleanUri_IgnoreIds_Original | .NET Core 3.1      | 163.51 ns |     848 B |        0.99 |
| CleanUri_IgnoreIds_Updated  | .NET Core 3.1      | 172.51 ns |     848 B |        0.99 |
| CleanUri_IgnoreIds_Original | .NET Framework 4.8 | 106.02 ns |     859 B |        1.00 |
| CleanUri_IgnoreIds_Updated  | .NET Framework 4.8 | 106.11 ns |     859 B |        1.00 |
|                             |                    |           |           |             |
| CleanUri_WithIds_Original   | .NET 10.0          | 104.45 ns |     168 B |        0.18 |
| CleanUri_WithIds_Updated    | .NET 10.0          |  80.02 ns |     115 B |        0.13 |
| CleanUri_WithIds_Original   | .NET 6.0           | 142.08 ns |     168 B |        0.18 |
| CleanUri_WithIds_Updated    | .NET 6.0           | 132.24 ns |     120 B |        0.13 |
| CleanUri_WithIds_Original   | .NET Core 2.1      | 234.61 ns |     912 B |        1.00 |
| CleanUri_WithIds_Updated    | .NET Core 2.1      | 219.09 ns |     856 B |        0.94 |
| CleanUri_WithIds_Original   | .NET Core 3.1      | 266.88 ns |     888 B |        0.97 |
| CleanUri_WithIds_Updated    | .NET Core 3.1      | 246.59 ns |     840 B |        0.92 |
| CleanUri_WithIds_Original   | .NET Framework 4.8 | 194.05 ns |     915 B |        1.00 |
| CleanUri_WithIds_Updated    | .NET Framework 4.8 | 202.65 ns |     859 B |        0.94 |
@pr-commenter
Copy link

pr-commenter bot commented Feb 16, 2026

Benchmarks

Benchmark execution time: 2026-02-16 18:18:17

Comparing candidate commit 0dc0106 in PR branch andrew/uri-helpers with baseline commit 9281c0d in branch master.

Found 11 performance improvements and 6 performance regressions! Performance is the same for 154 metrics, 21 unstable metrics.

scenario:Benchmarks.Trace.Asm.AppSecBodyBenchmark.AllCycleSimpleBody net6.0

  • 🟩 execution_time [-34.496ms; -28.325ms] or [-15.222%; -12.498%]

scenario:Benchmarks.Trace.Asm.AppSecBodyBenchmark.ObjectExtractorSimpleBody net6.0

  • 🟩 execution_time [-19.185ms; -13.351ms] or [-8.848%; -6.158%]

scenario:Benchmarks.Trace.Asm.AppSecBodyBenchmark.ObjectExtractorSimpleBody netcoreapp3.1

  • 🟥 execution_time [+14.158ms; +19.927ms] or [+7.186%; +10.114%]

scenario:Benchmarks.Trace.Asm.AppSecEncoderBenchmark.EncodeLegacyArgs net6.0

  • 🟥 execution_time [+11.107ms; +11.328ms] or [+5.867%; +5.983%]
  • 🟩 throughput [+509.795op/s; +517.479op/s] or [+7.834%; +7.952%]

scenario:Benchmarks.Trace.CIVisibilityProtocolWriterBenchmark.WriteAndFlushEnrichedTraces net472

  • 🟩 execution_time [-18.771ms; -14.043ms] or [-8.563%; -6.406%]
  • 🟩 throughput [+70.922op/s; +93.972op/s] or [+6.938%; +9.193%]

scenario:Benchmarks.Trace.CharSliceBenchmark.OptimizedCharSlice netcoreapp3.1

  • 🟩 execution_time [-926.693µs; -677.653µs] or [-33.273%; -24.331%]

scenario:Benchmarks.Trace.CharSliceBenchmark.OptimizedCharSliceWithPool net6.0

  • 🟩 execution_time [-129.953µs; -120.140µs] or [-11.322%; -10.467%]
  • 🟩 throughput [+102.077op/s; +111.107op/s] or [+11.716%; +12.753%]

scenario:Benchmarks.Trace.ElasticsearchBenchmark.CallElasticsearch netcoreapp3.1

  • 🟥 execution_time [+11.180ms; +15.080ms] or [+5.696%; +7.683%]

scenario:Benchmarks.Trace.GraphQLBenchmark.ExecuteAsync netcoreapp3.1

  • 🟩 throughput [+32001.371op/s; +41466.487op/s] or [+7.994%; +10.359%]

scenario:Benchmarks.Trace.ILoggerBenchmark.EnrichedLog net6.0

  • 🟥 execution_time [+11.806ms; +15.618ms] or [+5.898%; +7.803%]

scenario:Benchmarks.Trace.Log4netBenchmark.EnrichedLog netcoreapp3.1

  • 🟩 execution_time [-30.605ms; -29.164ms] or [-15.098%; -14.386%]

scenario:Benchmarks.Trace.RedisBenchmark.SendReceive net6.0

  • 🟥 execution_time [+14.498ms; +21.844ms] or [+7.278%; +10.965%]

scenario:Benchmarks.Trace.SpanBenchmark.StartFinishSpan net6.0

  • 🟩 execution_time [-19.186ms; -15.574ms] or [-8.873%; -7.202%]

scenario:Benchmarks.Trace.TraceAnnotationsBenchmark.RunOnMethodBegin net6.0

  • 🟥 execution_time [+15.583ms; +19.825ms] or [+7.812%; +9.938%]

Comment on lines +289 to +293
// the flag changed in .NET 10
private static readonly ulong DisablePathAndQueryCanonicalizationFlag
= FrameworkDescription.Instance.RuntimeVersion.Major >= 10
? 1UL << 55
: 0x200000000000;
Copy link
Member

Choose a reason for hiding this comment

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

Is there anything we can do to preemptively workaround this if it changes again in future version of .NET?

Like assuming it was created with DangerousDisablePathAndQueryCanonicalizationFlag and take the safe route if RuntimeVersion.Major > 10 (until we add support for whatever the new value is)

Copy link
Member Author

Choose a reason for hiding this comment

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

Is there anything we can do to preemptively workaround this if it changes again in future version of .NET?

Well, the good news is that our tests would crash if they change it in future versions (both the unit tests here and the integration tests) 😄

Like assuming it was created with DangerousDisablePathAndQueryCanonicalizationFlag and take the safe route if RuntimeVersion.Major > 10 (until we add support for whatever the new value is)

I'd generally rather we didn't do that, as I would be concerned about the performance regression going unnoticed...


#if NET6_0_OR_GREATER
[DuckCopy]
internal struct UriStruct
Copy link
Member

Choose a reason for hiding this comment

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

You're adding the same duck type in #8203 at tracer/src/Datadog.Trace/Util/Http/HttpRequestUtils.cs.

Is that intentional?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah, I just didn't want to stack the PRs, I wasn't sure anyway would notice, and was going to clean it up after 😉

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR improves the performance of UriHelpers.CleanUri() by reducing memory allocations. The primary optimization is to use a single GetComponents() call to retrieve URI components at once instead of accessing individual properties, which trigger separate internal parsing operations. Additionally, when IDs need to be removed, the code now uses a single StringBuilder instance throughout the entire operation instead of partially building strings and then allocating another StringBuilder.

The PR handles a complex edge case in .NET 6+ where URIs created with DangerousDisablePathAndQueryCanonicalization flag cannot use GetComponents() with path components without throwing exceptions. This is detected using duck typing to inspect internal URI flags.

Changes:

  • Refactored CleanUri() to delegate to specialized methods for performance
  • Added extensive characterization tests for existing behavior
  • Implemented duck typing detection for .NET 6+ dangerous URI flag

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
tracer/src/Datadog.Trace/Util/UriHelpers.cs Refactored main implementation to use GetComponents(), added specialized methods, and added .NET 6+ dangerous flag detection
tracer/test/Datadog.Trace.Tests/Util/UriHelpersTests.cs Added comprehensive test coverage for IsIdentifierSegment and CleanUri methods with 180+ test cases

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

andrewlock added a commit that referenced this pull request Feb 18, 2026
## Summary of changes

A few minor improvements to the standard `AspNetCoreDiagnosticObserver`

## Reason for change

Looking into obvious perf improvements for ASP.NET Core, but only a few
minor things stood out (apart from related PRs like #8199 and #8203).

## Implementation details

- Reduce size of MVC tags object by not deriving from `WebTags` (we
never set those tags anyway)
- Delay creating spanlinks collection if we don't need it
- HttpRoute always matches AspNetCoreRoute, so can make it readonly

## Test coverage

Covered by existing tests, benchmarks show (tiny) allocation gains

## Other details


Relates to https://datadoghq.atlassian.net/browse/LANGPLAT-842

Related PRs:
- #8167
- #8170
- #8180
- #8196
- #8199
- #8203
@andrewlock andrewlock merged commit 0b48278 into master Feb 18, 2026
148 checks passed
@andrewlock andrewlock deleted the andrew/uri-helpers branch February 18, 2026 09:27
@github-actions github-actions bot added this to the vNext-v3 milestone Feb 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:tracer The core tracer library (Datadog.Trace, does not include OpenTracing, native code, or integrations) type:performance Performance, speed, latency, resource usage (CPU, memory)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

Comments