From 98818c706dce832a656a7181eb9e32a757a176b5 Mon Sep 17 00:00:00 2001 From: Luc Varoqui Date: Wed, 14 Oct 2026 12:09:26 +0200 Subject: [PATCH 01/10] feat(divan): export benchmark tracing spans over OTLP behind an otel feature Add an opt-in `otel` feature to `codspeed` and `codspeed-divan-compat`. When `CODSPEED_OTEL_ENDPOINT` is set, `divan::main()` installs a global `tracing` subscriber that exports the spans and events emitted by benchmarks over OTLP/HTTP, filtered by `CODSPEED_OTEL_FILTER` (defaults to `info`), and flushes them when the run ends. Spans from the exporter's own HTTP stack are always filtered out to avoid an export loop. Benchmarks that already set a global subscriber are left untouched; they can compose `divan::otel::layer()` into their own subscriber instead. Closes COD-3862 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 969 +++++++++++++++++--- crates/codspeed/Cargo.toml | 25 + crates/codspeed/src/lib.rs | 2 + crates/codspeed/src/otel.rs | 136 +++ crates/divan_compat/Cargo.toml | 12 + crates/divan_compat/README.md | 36 + crates/divan_compat/benches/otel_example.rs | 32 + crates/divan_compat/src/lib.rs | 12 + 8 files changed, 1119 insertions(+), 105 deletions(-) create mode 100644 crates/codspeed/src/otel.rs create mode 100644 crates/divan_compat/benches/otel_example.rs diff --git a/Cargo.lock b/Cargo.lock index 51960382..34c1ae2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,21 +2,6 @@ # It is not intended for manual editing. version = 3 -[[package]] -name = "addr2line" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" - [[package]] name = "aho-corasick" version = "1.1.3" @@ -201,7 +186,7 @@ dependencies = [ "polling 2.8.0", "rustix 0.37.27", "slab", - "socket2", + "socket2 0.4.10", "waker-fn", ] @@ -353,6 +338,17 @@ version = "4.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -366,19 +362,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" [[package]] -name = "backtrace" -version = "0.3.73" +name = "base64" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc23269a4f8976d0a4d2e7109211a419fe30e8d88d677cd60b6bc79c5732e0a" -dependencies = [ - "addr2line", - "cc", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", -] +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" [[package]] name = "bencher" @@ -394,9 +381,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.6.0" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "blocking" @@ -434,6 +421,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "camino" version = "1.2.1" @@ -572,7 +565,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.96", ] [[package]] @@ -592,10 +585,16 @@ dependencies = [ "glob", "libc", "nix", + "opentelemetry", + "opentelemetry-otlp", + "opentelemetry_sdk", "serde", "serde_json", "statrs", "tempfile", + "tracing", + "tracing-opentelemetry", + "tracing-subscriber", ] [[package]] @@ -642,7 +641,7 @@ dependencies = [ "oorandom", "plotters", "quickcheck", - "rand", + "rand 0.8.5", "rayon", "regex", "serde", @@ -664,6 +663,7 @@ dependencies = [ "codspeed-divan-compat-macros", "codspeed-divan-compat-walltime", "regex", + "tracing", ] [[package]] @@ -685,7 +685,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.96", ] [[package]] @@ -714,7 +714,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -800,6 +800,17 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "divan-macros" version = "0.1.17" @@ -808,7 +819,7 @@ checksum = "8dc51d98e636f5e3b0759a39257458b22619cac7e96d932da6eeb052891bb67c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.96", ] [[package]] @@ -831,12 +842,12 @@ checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "errno" -version = "0.3.9" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -907,6 +918,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -989,6 +1009,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.96", +] + [[package]] name = "futures-sink" version = "0.3.31" @@ -1010,6 +1041,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -1031,20 +1063,26 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", + "wasip2", ] [[package]] -name = "gimli" -version = "0.29.0" +name = "getrandom" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] [[package]] name = "glob" @@ -1098,6 +1136,198 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.5", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -1128,6 +1358,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + [[package]] name = "is-terminal" version = "0.4.12" @@ -1171,10 +1407,12 @@ checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" [[package]] name = "js-sys" -version = "0.3.69" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ + "cfg-if", + "futures-util", "wasm-bindgen", ] @@ -1187,6 +1425,12 @@ dependencies = [ "log", ] +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + [[package]] name = "libc" version = "0.2.182" @@ -1215,15 +1459,30 @@ version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + [[package]] name = "log" -version = "0.4.22" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" dependencies = [ "value-bag", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "memchr" version = "2.7.4" @@ -1240,12 +1499,14 @@ dependencies = [ ] [[package]] -name = "miniz_oxide" -version = "0.7.4" +name = "mio" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ - "adler", + "libc", + "wasi", + "windows-sys 0.61.2", ] [[package]] @@ -1254,7 +1515,7 @@ version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", @@ -1294,15 +1555,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "object" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f203fa8daa7bb185f760ae12bd8e097f63d17041dcdcaf675ac54cdf863170e" -dependencies = [ - "memchr", -] - [[package]] name = "once_cell" version = "1.19.0" @@ -1315,12 +1567,88 @@ version = "11.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b410bbe7e14ab526a0e86877eb47c6996a2bd7746f027ba551028c925390e4e9" +[[package]] +name = "opentelemetry" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c1f1a6ac9c875a4941e4c7c085b61e8f4daa326de03b4701768c3491a6ad9ee" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror", +] + +[[package]] +name = "opentelemetry-http" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "302249bd8ee804789faaa7b5b2cbf79bc61cb864714079c644e3828af5b7e811" +dependencies = [ + "async-trait", + "bytes", + "http", + "opentelemetry", + "reqwest", +] + +[[package]] +name = "opentelemetry-otlp" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0756af24841e367128ef31c831999937b8b5c5197d47914590d8f4e2e88c2e1a" +dependencies = [ + "http", + "httpdate", + "opentelemetry", + "opentelemetry-http", + "opentelemetry-proto", + "opentelemetry_sdk", + "prost", + "reqwest", + "thiserror", +] + +[[package]] +name = "opentelemetry-proto" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d576f5dddc3299341bcd85a04be17b2564844c0c9563214f02d85f2524d1afae" +dependencies = [ + "opentelemetry", + "opentelemetry_sdk", + "prost", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "372a912db2974d1990207006fd0e3aa0ef3fd4ad3be43c65cfaadf2206866061" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "portable-atomic", + "rand 0.9.5", + "thiserror", +] + [[package]] name = "parking" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb813b8af86854136c6922af0598d719255ecb2179515e6e7730d468f05c9cae" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.14" @@ -1403,6 +1731,21 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + [[package]] name = "ppv-lite86" version = "0.2.20" @@ -1460,24 +1803,53 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.96", +] + [[package]] name = "quickcheck" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "588f6378e4dd99458b60ec275b4477add41ce4fa9f64dcba6f15adccb19b50d6" dependencies = [ - "rand", + "rand 0.8.5", ] [[package]] name = "quote" -version = "1.0.36" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -1491,8 +1863,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -1502,7 +1884,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -1514,6 +1906,15 @@ dependencies = [ "getrandom 0.2.15", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rayon" version = "1.10.0" @@ -1579,10 +1980,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" [[package]] -name = "rustc-demangle" -version = "0.1.24" +name = "reqwest" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] [[package]] name = "rustix" @@ -1604,13 +2030,19 @@ version = "0.38.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.4.14", "windows-sys 0.52.0", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "ryu" version = "1.0.18" @@ -1663,7 +2095,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.96", ] [[package]] @@ -1688,6 +2120,15 @@ dependencies = [ "serde", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "2.0.1" @@ -1712,6 +2153,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + [[package]] name = "smol" version = "1.3.0" @@ -1756,6 +2203,22 @@ dependencies = [ "winapi", ] +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "statrs" version = "0.18.0" @@ -1783,6 +2246,37 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "tempfile" version = "3.7.1" @@ -1838,7 +2332,26 @@ checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.96", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", ] [[package]] @@ -1853,12 +2366,15 @@ dependencies = [ [[package]] name = "tokio" -version = "1.44.1" +version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f382da615b842244d4b8738c82ed1275e6c5dd90c459a30941cd07080b06c91a" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ - "backtrace", + "libc", + "mio", "pin-project-lite", + "socket2 0.6.5", + "windows-sys 0.61.2", ] [[package]] @@ -1925,21 +2441,117 @@ dependencies = [ "winnow 1.0.3", ] +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" -version = "0.1.40" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.96", +] + [[package]] name = "tracing-core" -version = "0.1.32" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-opentelemetry" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a904802a1b902f43638b677ff2a650847e3b4404101b6c586d648e8c1e3e8fe" +dependencies = [ + "js-sys", + "opentelemetry", + "tracing", + "tracing-core", + "tracing-subscriber", + "web-time", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "once_cell", + "regex-automata", + "sharded-slab", + "thread_local", + "tracing", + "tracing-core", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "unicode-ident" @@ -1947,17 +2559,41 @@ version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "value-bag" -version = "1.9.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a84c137d37ab0142f0f2ddfe332651fdbf252e7b7dbb4e67b6c1f1b2e925101" +checksum = "2799ffb329a792ecfd902b71306c8a815a6ef1c0470fa9953a6aa4d4cecbe511" [[package]] name = "wait-timeout" @@ -1984,6 +2620,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -1991,27 +2636,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] -name = "wasm-bindgen" -version = "0.2.92" +name = "wasip2" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "cfg-if", - "wasm-bindgen-macro", + "wit-bindgen", ] [[package]] -name = "wasm-bindgen-backend" -version = "0.2.92" +name = "wasm-bindgen" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ - "bumpalo", - "log", + "cfg-if", "once_cell", - "proc-macro2", - "quote", - "syn", + "rustversion", + "wasm-bindgen-macro", "wasm-bindgen-shared", ] @@ -2029,9 +2671,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.92" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2039,22 +2681,25 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.92" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ + "bumpalo", "proc-macro2", "quote", - "syn", - "wasm-bindgen-backend", + "syn 3.0.9", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.92" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] [[package]] name = "web-sys" @@ -2066,6 +2711,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "winapi" version = "0.3.9" @@ -2097,6 +2752,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-sys" version = "0.48.0" @@ -2115,6 +2776,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -2254,6 +2924,41 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.7.35" @@ -2272,5 +2977,59 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.96", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", ] diff --git a/crates/codspeed/Cargo.toml b/crates/codspeed/Cargo.toml index ffec9b8e..fad13b03 100644 --- a/crates/codspeed/Cargo.toml +++ b/crates/codspeed/Cargo.toml @@ -28,6 +28,31 @@ serde = { workspace = true } serde_json = { workspace = true } statrs = { version = "0.18.0", default-features = false } +opentelemetry = { version = "0.33", default-features = false, features = ["trace"], optional = true } +opentelemetry_sdk = { version = "0.33", default-features = false, features = ["trace"], optional = true } +opentelemetry-otlp = { version = "0.33", default-features = false, features = [ + "trace", + "http-proto", + "reqwest-blocking-client", +], optional = true } +tracing = { version = "0.1", default-features = false, features = ["std"], optional = true } +tracing-opentelemetry = { version = "0.34", default-features = false, optional = true } +tracing-subscriber = { version = "0.3", default-features = false, features = [ + "std", + "registry", + "env-filter", +], optional = true } + +[features] +otel = [ + "dep:opentelemetry", + "dep:opentelemetry_sdk", + "dep:opentelemetry-otlp", + "dep:tracing", + "dep:tracing-opentelemetry", + "dep:tracing-subscriber", +] + [[bench]] name = "native" harness = false diff --git a/crates/codspeed/src/lib.rs b/crates/codspeed/src/lib.rs index 929c3662..cf7aa7fa 100644 --- a/crates/codspeed/src/lib.rs +++ b/crates/codspeed/src/lib.rs @@ -4,6 +4,8 @@ pub mod instrument_hooks; mod macros; mod measurement; +#[cfg(feature = "otel")] +pub mod otel; mod request; pub mod utils; pub mod walltime_results; diff --git a/crates/codspeed/src/otel.rs b/crates/codspeed/src/otel.rs new file mode 100644 index 00000000..f2c7f306 --- /dev/null +++ b/crates/codspeed/src/otel.rs @@ -0,0 +1,136 @@ +//! Export of the `tracing` spans and events emitted by benchmarks over OTLP/HTTP. +//! +//! Everything is a no-op unless [`ENDPOINT_ENV`] is set. + +use opentelemetry::trace::TracerProvider as _; +use opentelemetry_otlp::{SpanExporter, WithExportConfig}; +use opentelemetry_sdk::{trace::SdkTracerProvider, Resource}; +use tracing::Subscriber; +use tracing_subscriber::{layer::SubscriberExt, registry::LookupSpan, EnvFilter, Layer}; + +/// Base URL of the OTLP/HTTP receiver, e.g. `http://localhost:4318`. +pub const ENDPOINT_ENV: &str = "CODSPEED_OTEL_ENDPOINT"; + +/// `EnvFilter` directives selecting the exported spans and events. Defaults to `info`. +pub const FILTER_ENV: &str = "CODSPEED_OTEL_FILTER"; + +const DEFAULT_FILTER: &str = "info"; + +/// Spans emitted by the exporter itself would be exported in turn, in an endless loop. +const EXPORTER_FILTER_DIRECTIVES: &[&str] = &[ + "h2=off", + "hyper=off", + "hyper_util=off", + "opentelemetry=off", + "opentelemetry_otlp=off", + "opentelemetry_sdk=off", + "opentelemetry_http=off", + "reqwest=off", + "tower=off", +]; + +/// Flushes and shuts down the exporter when dropped. +#[must_use = "dropping the guard shuts the exporter down"] +pub struct OtelGuard { + provider: SdkTracerProvider, +} + +impl Drop for OtelGuard { + fn drop(&mut self) { + if let Err(error) = self.provider.shutdown() { + eprintln!("Warning: failed to flush OpenTelemetry spans: {error}"); + } + } +} + +/// Builds a layer exporting spans and events to the receiver at +/// [`ENDPOINT_ENV`], to add to a subscriber of your own. Returns `None` when +/// the variable is unset. +/// +/// Keep the returned guard alive until the benchmarks are done. +pub fn layer() -> Option<(impl Layer, OtelGuard)> +where + S: Subscriber + for<'span> LookupSpan<'span>, +{ + let provider = build_provider()?; + let layer = tracing_opentelemetry::layer() + .with_tracer(provider.tracer("codspeed")) + .with_filter(build_filter()); + Some((layer, OtelGuard { provider })) +} + +/// Installs a global `tracing` subscriber exporting spans and events to the receiver at +/// [`ENDPOINT_ENV`], and registers its provider as the global OpenTelemetry tracer provider. +/// +/// Returns `None` when the variable is unset or a global subscriber is already set. +pub fn init() -> Option { + if tracing::dispatcher::has_been_set() { + return None; + } + + let (layer, guard) = layer()?; + let subscriber = tracing_subscriber::registry().with(layer); + if tracing::subscriber::set_global_default(subscriber).is_err() { + return None; + } + opentelemetry::global::set_tracer_provider(guard.provider.clone()); + Some(guard) +} + +fn build_provider() -> Option { + let endpoint = std::env::var(ENDPOINT_ENV) + .ok() + .filter(|endpoint| !endpoint.is_empty())?; + let traces_endpoint = format!("{}/v1/traces", endpoint.trim_end_matches('/')); + + let exporter = match SpanExporter::builder() + .with_http() + .with_endpoint(traces_endpoint) + .build() + { + Ok(exporter) => exporter, + Err(error) => { + eprintln!("Warning: failed to build the OpenTelemetry exporter: {error}"); + return None; + } + }; + + Some( + SdkTracerProvider::builder() + .with_batch_exporter(exporter) + .with_resource( + Resource::builder() + .with_service_name(service_name()) + .build(), + ) + .build(), + ) +} + +fn build_filter() -> EnvFilter { + let user_directives = std::env::var(FILTER_ENV).unwrap_or_else(|_| DEFAULT_FILTER.to_owned()); + let directives = std::iter::once(user_directives.as_str()) + .chain(EXPORTER_FILTER_DIRECTIVES.iter().copied()) + .collect::>() + .join(","); + EnvFilter::builder().parse_lossy(directives) +} + +/// The benchmark executable name, without the hash cargo appends to it. +fn service_name() -> String { + let file_stem = std::env::current_exe().ok().and_then(|exe| { + exe.file_stem() + .map(|stem| stem.to_string_lossy().into_owned()) + }); + match file_stem { + Some(stem) => match stem.rsplit_once('-') { + Some((name, hash)) if is_cargo_hash(hash) => name.to_owned(), + _ => stem, + }, + None => "codspeed-benchmark".to_owned(), + } +} + +fn is_cargo_hash(s: &str) -> bool { + s.len() == 16 && s.chars().all(|c| c.is_ascii_hexdigit()) +} diff --git a/crates/divan_compat/Cargo.toml b/crates/divan_compat/Cargo.toml index 8b015d31..2cbab7ff 100644 --- a/crates/divan_compat/Cargo.toml +++ b/crates/divan_compat/Cargo.toml @@ -24,6 +24,13 @@ codspeed-divan-compat-macros = { version = "=5.0.2", path = './macros' } regex = "1.11.3" clap = { version = "4", default-features = false, features = ["std", "env"] } +[features] +# Export the `tracing` spans and events of benchmarks over OTLP to `CODSPEED_OTEL_ENDPOINT` +otel = ["codspeed/otel"] + +[dev-dependencies] +tracing = "0.1" + [[bench]] name = "basic_example" harness = false @@ -43,3 +50,8 @@ harness = false [[bench]] name = "fib_example" harness = false + +[[bench]] +name = "otel_example" +harness = false +required-features = ["otel"] diff --git a/crates/divan_compat/README.md b/crates/divan_compat/README.md index dc448a89..cb2e4404 100644 --- a/crates/divan_compat/README.md +++ b/crates/divan_compat/README.md @@ -75,6 +75,42 @@ Done running my_benchmark Finished running 1 benchmark suite(s) ``` +### Exporting `tracing` spans with OpenTelemetry + +With the opt-in `otel` feature, `divan::main()` installs a global `tracing` subscriber that exports +the spans emitted by your benchmarks, and the events recorded inside them, over OTLP/HTTP: + +```toml title="Cargo.toml" +[dev-dependencies] +divan = { package = "codspeed-divan-compat", version = "*", features = ["otel"] } +``` + +- `CODSPEED_OTEL_ENDPOINT`: base URL of the OTLP/HTTP receiver, e.g. `http://localhost:4318`. + Nothing is exported when it is unset. +- `CODSPEED_OTEL_FILTER`: [`EnvFilter`](https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html) + directives selecting what is exported. Defaults to `info`. + +The provider is also registered as the global OpenTelemetry tracer provider, and spans are flushed +when `divan::main()` returns. + +If your benchmarks already set a global subscriber before calling `divan::main()`, nothing is +installed. Add the CodSpeed layer to your subscriber instead, and keep its guard alive until the +benchmarks are done: + +```rust +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +fn main() { + let (otel_layer, _otel_guard) = divan::otel::layer().unzip(); + tracing_subscriber::registry() + .with(otel_layer) + .with(tracing_subscriber::fmt::layer()) + .init(); + + divan::main(); +} +``` + ### Not supported: - [`divan::bench(crate = xxx)`](https://docs.rs/divan/latest/divan/attr.bench.html#crate): due to how the compatibility layer works internally, we do not plan to support this feature. diff --git a/crates/divan_compat/benches/otel_example.rs b/crates/divan_compat/benches/otel_example.rs new file mode 100644 index 00000000..57375d9a --- /dev/null +++ b/crates/divan_compat/benches/otel_example.rs @@ -0,0 +1,32 @@ +use tracing::{debug, debug_span, info, info_span, warn}; + +fn parse(input: &str) -> Vec { + let _span = info_span!("parse", input_len = input.len()).entered(); + let values: Vec = input + .split(',') + .filter_map(|field| field.parse().ok()) + .collect(); + info!(count = values.len(), "parsed values"); + values +} + +fn sum(values: &[u32]) -> u32 { + let _span = info_span!("sum").entered(); + let _filtered_out = debug_span!("sum_inner").entered(); + debug!("filtered out by the default filter"); + values.iter().sum() +} + +#[codspeed_divan_compat::bench] +fn parse_and_sum() -> u32 { + let _span = info_span!("parse_and_sum").entered(); + let values = parse("1,2,x,3"); + if values.len() < 4 { + warn!(skipped = 4 - values.len(), "skipped invalid fields"); + } + sum(&values) +} + +fn main() { + codspeed_divan_compat::main(); +} diff --git a/crates/divan_compat/src/lib.rs b/crates/divan_compat/src/lib.rs index b977abfa..ec2a56d6 100644 --- a/crates/divan_compat/src/lib.rs +++ b/crates/divan_compat/src/lib.rs @@ -1,4 +1,6 @@ pub use codspeed::codspeed_uri; +#[cfg(feature = "otel")] +pub use codspeed::otel; #[cfg(not(codspeed))] mod compat_divan { @@ -19,3 +21,13 @@ mod compat_divan { } pub use compat_divan::*; + +/// Runs all registered benchmarks. +/// +/// With the `otel` feature, spans and events are exported for the duration of the run. +pub fn main() { + #[cfg(feature = "otel")] + let _otel_guard = otel::init(); + + compat_divan::main(); +} From 6355f27f48a60f6557baf867ca771288b9c56e1a Mon Sep 17 00:00:00 2001 From: Luc Varoqui Date: Wed, 14 Oct 2026 12:32:15 +0200 Subject: [PATCH 02/10] fix(codspeed): keep the otel dependencies within MSRV and minimal versions `reqwest` pulls `idna_adapter` 1.2.2 and the `icu_*` 2.x crates, which require rustc 1.88. Resolve them with the MSRV-aware fallback resolver so the lockfile builds on rustc 1.85. `sharded-slab` declares `lazy_static = "1"` but fails to build with 1.0.x, which breaks the minimal-versions check. Raise the floor with an optional direct `lazy_static = "1.1"` dependency enabled by the `otel` feature. Refs COD-3862 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 162 +++++++++++++++++++++++-------------- crates/codspeed/Cargo.toml | 3 + 2 files changed, 104 insertions(+), 61 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 34c1ae2e..38f56e02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -583,6 +583,7 @@ dependencies = [ "colored", "getrandom 0.4.3", "glob", + "lazy_static", "libc", "nix", "opentelemetry", @@ -1226,23 +1227,21 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.3.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" dependencies = [ "displaydoc", - "potential_utf", - "utf8_iter", "yoke", "zerofrom", "zerovec", ] [[package]] -name = "icu_locale_core" -version = "2.3.0" +name = "icu_locid" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" dependencies = [ "displaydoc", "litemap", @@ -1251,62 +1250,99 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7515e6d781098bf9f7205ab3fc7e9709d34554ae0b21ddbcb5febfa4bc7df11d" + [[package]] name = "icu_normalizer" -version = "2.3.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" dependencies = [ + "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", "icu_provider", "smallvec", + "utf16_iter", + "utf8_iter", + "write16", "zerovec", ] [[package]] name = "icu_normalizer_data" -version = "2.3.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +checksum = "c5e8338228bdc8ab83303f16b797e177953730f601a96c25d10cb3ab0daa0cb7" [[package]] name = "icu_properties" -version = "2.3.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" dependencies = [ "displaydoc", "icu_collections", - "icu_locale_core", + "icu_locid_transform", "icu_properties_data", "icu_provider", - "zerotrie", + "tinystr", "zerovec", ] [[package]] name = "icu_properties_data" -version = "2.3.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" +checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2" [[package]] name = "icu_provider" -version = "2.3.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" dependencies = [ "displaydoc", - "icu_locale_core", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", "writeable", "yoke", "zerofrom", - "zerotrie", "zerovec", ] +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.96", +] + [[package]] name = "idna" version = "1.1.0" @@ -1320,9 +1356,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.2" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" dependencies = [ "icu_normalizer", "icu_properties", @@ -1461,9 +1497,9 @@ checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" [[package]] name = "litemap" -version = "0.8.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" [[package]] name = "log" @@ -1737,15 +1773,6 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" -[[package]] -name = "potential_utf" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" -dependencies = [ - "zerovec", -] - [[package]] name = "ppv-lite86" version = "0.2.20" @@ -2266,6 +2293,17 @@ dependencies = [ "futures-core", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.96", +] + [[package]] name = "synstructure" version = "0.14.0" @@ -2346,9 +2384,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.4" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" dependencies = [ "displaydoc", "zerovec", @@ -2571,6 +2609,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -2930,18 +2974,25 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + [[package]] name = "writeable" -version = "0.6.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" [[package]] name = "yoke" -version = "0.8.3" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" dependencies = [ + "serde", "stable_deref_trait", "yoke-derive", "zerofrom", @@ -2949,14 +3000,14 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.4" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", - "synstructure", + "syn 2.0.96", + "synstructure 0.13.2", ] [[package]] @@ -2998,25 +3049,14 @@ dependencies = [ "proc-macro2", "quote", "syn 3.0.9", - "synstructure", -] - -[[package]] -name = "zerotrie" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", + "synstructure 0.14.0", ] [[package]] name = "zerovec" -version = "0.11.8" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" dependencies = [ "yoke", "zerofrom", @@ -3025,11 +3065,11 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.6" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +checksum = "3e3c6377872d72510393f688a555d7097b0f741995c7a00f0407f786dd486b2d" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn 2.0.96", ] diff --git a/crates/codspeed/Cargo.toml b/crates/codspeed/Cargo.toml index fad13b03..460b87e0 100644 --- a/crates/codspeed/Cargo.toml +++ b/crates/codspeed/Cargo.toml @@ -42,6 +42,8 @@ tracing-subscriber = { version = "0.3", default-features = false, features = [ "registry", "env-filter", ], optional = true } +# Unused directly: raises the floor of `sharded-slab`'s `lazy_static = "1"`, which fails to build below 1.1 +lazy_static = { version = "1.1", optional = true } [features] otel = [ @@ -51,6 +53,7 @@ otel = [ "dep:tracing", "dep:tracing-opentelemetry", "dep:tracing-subscriber", + "dep:lazy_static", ] [[bench]] From e359614f80dea813c09236d4ef906cdb40757e40 Mon Sep 17 00:00:00 2001 From: Luc Varoqui Date: Wed, 14 Oct 2026 18:19:23 +0200 Subject: [PATCH 03/10] feat(codspeed): label exported spans with the running benchmark URI Spans exported with the otel feature carried nothing tying them to a benchmark, so a suite with several benchmarks could not be told apart. Add `benchmark_scope::enter`/`exit`, which the integrations call around each benchmark, outside the measured section. A span processor sets the `codspeed.benchmark.uri` attribute on every span started inside the scope. The scope is process-wide, so spans of threaded benchmarks are labelled too. Without the otel feature, both calls do nothing. The divan walltime harness now computes the benchmark URI before running it instead of after. Refs COD-3862 Co-Authored-By: Claude Opus 5.5 --- crates/codspeed/src/benchmark_scope.rs | 21 ++++ crates/codspeed/src/codspeed.rs | 11 +- crates/codspeed/src/lib.rs | 1 + crates/codspeed/src/otel.rs | 110 ++++++++++++++++++- crates/divan_compat/divan_fork/src/divan.rs | 114 ++++++++++---------- 5 files changed, 198 insertions(+), 59 deletions(-) create mode 100644 crates/codspeed/src/benchmark_scope.rs diff --git a/crates/codspeed/src/benchmark_scope.rs b/crates/codspeed/src/benchmark_scope.rs new file mode 100644 index 00000000..93fdd15f --- /dev/null +++ b/crates/codspeed/src/benchmark_scope.rs @@ -0,0 +1,21 @@ +//! The benchmark being run, which the exported telemetry is attributed to. +//! +//! Without the `otel` feature, entering and exiting a scope does nothing. + +/// Marks the start of the benchmark `uri`. Spans started until [`exit`] carry its URI. +/// +/// Call it outside the measured section. +pub fn enter(uri: &str) { + #[cfg(feature = "otel")] + crate::otel::enter_benchmark(uri); + #[cfg(not(feature = "otel"))] + let _ = uri; +} + +/// Marks the end of the current benchmark. +/// +/// Call it outside the measured section. +pub fn exit() { + #[cfg(feature = "otel")] + crate::otel::exit_benchmark(); +} diff --git a/crates/codspeed/src/codspeed.rs b/crates/codspeed/src/codspeed.rs index 1a4644d2..02d91a04 100644 --- a/crates/codspeed/src/codspeed.rs +++ b/crates/codspeed/src/codspeed.rs @@ -81,6 +81,7 @@ impl CodSpeed { #[inline(always)] pub fn start_benchmark(&mut self, name: &str) { self.current_benchmark = CString::new(name).expect("CString::new failed"); + crate::benchmark_scope::enter(name); if let InstrumentationStatus::InstrumentHooks(hooks_instance) = &self.instrumentation_status { @@ -99,9 +100,15 @@ impl CodSpeed { // The overhead in case we are not running valgrind is extremely low, and we do not want to // add a conditionnal branch to valgrind measurements. measurement::stop(&self.current_benchmark); - if let InstrumentationStatus::InstrumentHooks(hooks_instance) = &self.instrumentation_status - { + let hooks_instance = match &self.instrumentation_status { + InstrumentationStatus::InstrumentHooks(hooks_instance) => Some(*hooks_instance), + _ => None, + }; + if let Some(hooks_instance) = hooks_instance { let _ = hooks_instance.stop_benchmark(); + } + crate::benchmark_scope::exit(); + if let Some(hooks_instance) = hooks_instance { let _ = hooks_instance.set_executed_benchmark(&self.current_benchmark.to_string_lossy()); } diff --git a/crates/codspeed/src/lib.rs b/crates/codspeed/src/lib.rs index cf7aa7fa..d0025727 100644 --- a/crates/codspeed/src/lib.rs +++ b/crates/codspeed/src/lib.rs @@ -1,3 +1,4 @@ +pub mod benchmark_scope; pub mod codspeed; pub mod instrument_hooks; diff --git a/crates/codspeed/src/otel.rs b/crates/codspeed/src/otel.rs index f2c7f306..52ad9270 100644 --- a/crates/codspeed/src/otel.rs +++ b/crates/codspeed/src/otel.rs @@ -2,9 +2,15 @@ //! //! Everything is a no-op unless [`ENDPOINT_ENV`] is set. -use opentelemetry::trace::TracerProvider as _; +use std::sync::{Arc, PoisonError, RwLock}; +use std::time::Duration; + +use opentelemetry::trace::{Span as _, TracerProvider as _}; +use opentelemetry::{Context, KeyValue}; use opentelemetry_otlp::{SpanExporter, WithExportConfig}; -use opentelemetry_sdk::{trace::SdkTracerProvider, Resource}; +use opentelemetry_sdk::error::OTelSdkResult; +use opentelemetry_sdk::trace::{SdkTracerProvider, Span, SpanData, SpanProcessor}; +use opentelemetry_sdk::Resource; use tracing::Subscriber; use tracing_subscriber::{layer::SubscriberExt, registry::LookupSpan, EnvFilter, Layer}; @@ -16,6 +22,11 @@ pub const FILTER_ENV: &str = "CODSPEED_OTEL_FILTER"; const DEFAULT_FILTER: &str = "info"; +/// Span attribute holding the URI of the benchmark running when the span started. +pub const BENCHMARK_URI_ATTRIBUTE: &str = "codspeed.benchmark.uri"; + +static CURRENT_BENCHMARK: RwLock>> = RwLock::new(None); + /// Spans emitted by the exporter itself would be exported in turn, in an endless loop. const EXPORTER_FILTER_DIRECTIVES: &[&str] = &[ "h2=off", @@ -97,6 +108,7 @@ fn build_provider() -> Option { Some( SdkTracerProvider::builder() + .with_span_processor(BenchmarkLabelProcessor) .with_batch_exporter(exporter) .with_resource( Resource::builder() @@ -107,6 +119,44 @@ fn build_provider() -> Option { ) } +pub(crate) fn enter_benchmark(uri: &str) { + *CURRENT_BENCHMARK + .write() + .unwrap_or_else(PoisonError::into_inner) = Some(Arc::from(uri)); +} + +pub(crate) fn exit_benchmark() { + *CURRENT_BENCHMARK + .write() + .unwrap_or_else(PoisonError::into_inner) = None; +} + +/// Labels each span with the benchmark running when it starts. +#[derive(Debug)] +struct BenchmarkLabelProcessor; + +impl SpanProcessor for BenchmarkLabelProcessor { + fn on_start(&self, span: &mut Span, _cx: &Context) { + let current_benchmark = CURRENT_BENCHMARK + .read() + .unwrap_or_else(PoisonError::into_inner) + .clone(); + if let Some(uri) = current_benchmark { + span.set_attribute(KeyValue::new(BENCHMARK_URI_ATTRIBUTE, uri)); + } + } + + fn on_end(&self, _span: SpanData) {} + + fn force_flush(&self) -> OTelSdkResult { + Ok(()) + } + + fn shutdown_with_timeout(&self, _timeout: Duration) -> OTelSdkResult { + Ok(()) + } +} + fn build_filter() -> EnvFilter { let user_directives = std::env::var(FILTER_ENV).unwrap_or_else(|_| DEFAULT_FILTER.to_owned()); let directives = std::iter::once(user_directives.as_str()) @@ -134,3 +184,59 @@ fn service_name() -> String { fn is_cargo_hash(s: &str) -> bool { s.len() == 16 && s.chars().all(|c| c.is_ascii_hexdigit()) } + +#[cfg(test)] +mod tests { + use super::*; + use opentelemetry::trace::Tracer as _; + use std::sync::Mutex; + + #[derive(Debug, Clone, Default)] + struct RecordingExporter(Arc>>); + + impl opentelemetry_sdk::trace::SpanExporter for RecordingExporter { + async fn export(&self, batch: Vec) -> OTelSdkResult { + self.0.lock().unwrap().extend(batch); + Ok(()) + } + } + + fn benchmark_uri(span: &SpanData) -> Option { + span.attributes + .iter() + .find(|attribute| attribute.key.as_str() == BENCHMARK_URI_ATTRIBUTE) + .map(|attribute| attribute.value.to_string()) + } + + #[test] + fn labels_the_spans_started_in_a_benchmark() { + let exporter = RecordingExporter::default(); + let provider = SdkTracerProvider::builder() + .with_span_processor(BenchmarkLabelProcessor) + .with_simple_exporter(exporter.clone()) + .build(); + let tracer = provider.tracer("test"); + + tracer.start("before").end(); + enter_benchmark("bench.rs::a"); + tracer.start("inside").end(); + exit_benchmark(); + tracer.start("after").end(); + + let labels = exporter + .0 + .lock() + .unwrap() + .iter() + .map(|span| (span.name.to_string(), benchmark_uri(span))) + .collect::>(); + assert_eq!( + labels, + [ + ("before".into(), None), + ("inside".into(), Some("bench.rs::a".into())), + ("after".into(), None), + ] + ); + } +} diff --git a/crates/divan_compat/divan_fork/src/divan.rs b/crates/divan_compat/divan_fork/src/divan.rs index 3a78d6c1..bbb347f2 100644 --- a/crates/divan_compat/divan_fork/src/divan.rs +++ b/crates/divan_compat/divan_fork/src/divan.rs @@ -296,8 +296,13 @@ impl Divan { .start_leaf(&format!("t={thread_count}"), is_last_thread_count); } + let (bench_name, uri) = + codspeed::bench_name_and_uri(&bench_entry, bench_display_name); + let mut bench_context = BenchContext::new(shared_context, options, thread_count); + ::codspeed::benchmark_scope::enter(&uri); with_bencher(Bencher::new(&mut bench_context)); + ::codspeed::benchmark_scope::exit(); if !bench_context.did_run { eprintln!( @@ -310,11 +315,7 @@ impl Divan { if should_compute_stats { let stats = bench_context.compute_stats(); - codspeed::collect_walltime_results( - &bench_context, - &bench_entry, - bench_display_name, - ); + codspeed::collect_walltime_results(&bench_context, bench_name, uri); tree_painter.borrow_mut().finish_leaf( is_last_thread_count, &stats, @@ -359,70 +360,73 @@ mod codspeed { use crate::bench::BenchContext; use crate::entry::AnyBenchEntry; - pub(crate) fn collect_walltime_results( - bench_context: &BenchContext, + pub(crate) fn bench_name_and_uri( bench_entry: &AnyBenchEntry, closure_bench_display_name: &str, - ) { + ) -> (String, String) { // WARNING: Keep URI generation in sync with `codspeed-divan-compat::uri::generate` // Not worth doing the work of actually using the same code since this fork is temporary - let (bench_name, uri) = { - let bench_function_name = bench_entry.meta().display_name; + let bench_function_name = bench_entry.meta().display_name; - let (bench_type_name, bench_arg_name) = { - let bench_function_or_type_name = bench_entry.display_name().to_string(); + let (bench_type_name, bench_arg_name) = { + let bench_function_or_type_name = bench_entry.display_name().to_string(); - let type_name = if bench_function_or_type_name == bench_function_name { - None - } else { - Some(bench_function_or_type_name) - }; - - let arg_name = match type_name.as_ref() { - None => { - if closure_bench_display_name == bench_function_name { - None - } else { - Some(closure_bench_display_name) - } + let type_name = if bench_function_or_type_name == bench_function_name { + None + } else { + Some(bench_function_or_type_name) + }; + + let arg_name = match type_name.as_ref() { + None => { + if closure_bench_display_name == bench_function_name { + None + } else { + Some(closure_bench_display_name) } - Some(type_name) => { - if closure_bench_display_name == type_name { - None - } else { - Some(closure_bench_display_name) - } + } + Some(type_name) => { + if closure_bench_display_name == type_name { + None + } else { + Some(closure_bench_display_name) } - }; - - (type_name, arg_name) + } }; - let mut bench_name = bench_function_name.to_string(); + (type_name, arg_name) + }; - match (bench_type_name, bench_arg_name) { - (None, None) => {} - (Some(type_name), None) => { - bench_name.push_str(format!("[{type_name}]").as_str()); - } - (None, Some(arg_name)) => { - bench_name.push_str(format!("[{arg_name}]").as_str()); - } - (Some(type_name), Some(arg_name)) => { - bench_name.push_str(format!("[{type_name}, {arg_name}]").as_str()); - } - } + let mut bench_name = bench_function_name.to_string(); - let file = bench_entry.meta().location.file; - let mut module_path = - bench_entry.meta().module_path_components().skip(1).collect::>().join("::"); - if !module_path.is_empty() { - module_path.push_str("::"); + match (bench_type_name, bench_arg_name) { + (None, None) => {} + (Some(type_name), None) => { + bench_name.push_str(format!("[{type_name}]").as_str()); } - let uri = format!("{file}::{module_path}{bench_name}"); - (bench_name, uri) - }; + (None, Some(arg_name)) => { + bench_name.push_str(format!("[{arg_name}]").as_str()); + } + (Some(type_name), Some(arg_name)) => { + bench_name.push_str(format!("[{type_name}, {arg_name}]").as_str()); + } + } + let file = bench_entry.meta().location.file; + let mut module_path = + bench_entry.meta().module_path_components().skip(1).collect::>().join("::"); + if !module_path.is_empty() { + module_path.push_str("::"); + } + let uri = format!("{file}::{module_path}{bench_name}"); + (bench_name, uri) + } + + pub(crate) fn collect_walltime_results( + bench_context: &BenchContext, + bench_name: String, + uri: String, + ) { let iter_per_round = bench_context.samples.sample_size; let iters_per_round = vec![iter_per_round as u128; bench_context.samples.time_samples.len()]; From 6c3f81a3062b86e0f0476d1b97bf5954a59088cb Mon Sep 17 00:00:00 2001 From: Luc Varoqui Date: Wed, 14 Oct 2026 18:20:08 +0200 Subject: [PATCH 04/10] feat(codspeed): hold exported spans until the benchmark ends The batch span processor exports on a 5s timer and whenever 512 spans are queued, so exports could run inside the measured section, and it dropped every span past a 2048-span queue. Replace it with a processor holding the ended spans in memory, exported when the benchmark scope exits, after the measured section and before the benchmark is reported as executed, and on shutdown. Exports are sent in requests of 4096 spans. Past 1,000,000 held spans, spans are dropped with a warning, to bound the memory of a benchmark emitting spans on every iteration. Refs COD-3862 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/codspeed/Cargo.toml | 2 + crates/codspeed/src/benchmark_scope.rs | 4 +- crates/codspeed/src/otel.rs | 115 ++++++++++++++++++++++++- 4 files changed, 116 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 38f56e02..8a88ff29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -581,6 +581,7 @@ dependencies = [ "anyhow", "cc", "colored", + "futures-executor", "getrandom 0.4.3", "glob", "lazy_static", diff --git a/crates/codspeed/Cargo.toml b/crates/codspeed/Cargo.toml index 460b87e0..c5562137 100644 --- a/crates/codspeed/Cargo.toml +++ b/crates/codspeed/Cargo.toml @@ -30,6 +30,7 @@ statrs = { version = "0.18.0", default-features = false } opentelemetry = { version = "0.33", default-features = false, features = ["trace"], optional = true } opentelemetry_sdk = { version = "0.33", default-features = false, features = ["trace"], optional = true } +futures-executor = { version = "0.3", default-features = false, features = ["std"], optional = true } opentelemetry-otlp = { version = "0.33", default-features = false, features = [ "trace", "http-proto", @@ -47,6 +48,7 @@ lazy_static = { version = "1.1", optional = true } [features] otel = [ + "dep:futures-executor", "dep:opentelemetry", "dep:opentelemetry_sdk", "dep:opentelemetry-otlp", diff --git a/crates/codspeed/src/benchmark_scope.rs b/crates/codspeed/src/benchmark_scope.rs index 93fdd15f..83bf41c9 100644 --- a/crates/codspeed/src/benchmark_scope.rs +++ b/crates/codspeed/src/benchmark_scope.rs @@ -12,9 +12,9 @@ pub fn enter(uri: &str) { let _ = uri; } -/// Marks the end of the current benchmark. +/// Marks the end of the current benchmark and exports the spans held so far. /// -/// Call it outside the measured section. +/// Call it outside the measured section, before the benchmark is reported as executed. pub fn exit() { #[cfg(feature = "otel")] crate::otel::exit_benchmark(); diff --git a/crates/codspeed/src/otel.rs b/crates/codspeed/src/otel.rs index 52ad9270..55498dd3 100644 --- a/crates/codspeed/src/otel.rs +++ b/crates/codspeed/src/otel.rs @@ -1,8 +1,13 @@ //! Export of the `tracing` spans and events emitted by benchmarks over OTLP/HTTP. //! //! Everything is a no-op unless [`ENDPOINT_ENV`] is set. +//! +//! Ended spans are held in memory and only exported when a benchmark scope exits (see +//! [`crate::benchmark_scope`]) or the exporter shuts down, so no export runs while a benchmark is +//! measured. -use std::sync::{Arc, PoisonError, RwLock}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, OnceLock, PoisonError, RwLock}; use std::time::Duration; use opentelemetry::trace::{Span as _, TracerProvider as _}; @@ -25,8 +30,17 @@ const DEFAULT_FILTER: &str = "info"; /// Span attribute holding the URI of the benchmark running when the span started. pub const BENCHMARK_URI_ATTRIBUTE: &str = "codspeed.benchmark.uri"; +/// Ended spans held in memory between two exports, past which spans are dropped. Bounds the +/// memory used by a benchmark emitting spans on every iteration. +const MAX_HELD_SPANS: usize = 1_000_000; + +/// Spans sent per export request. +const EXPORT_BATCH_SIZE: usize = 4_096; + static CURRENT_BENCHMARK: RwLock>> = RwLock::new(None); +static PROVIDER: OnceLock = OnceLock::new(); + /// Spans emitted by the exporter itself would be exported in turn, in an endless loop. const EXPORTER_FILTER_DIRECTIVES: &[&str] = &[ "h2=off", @@ -64,6 +78,7 @@ where S: Subscriber + for<'span> LookupSpan<'span>, { let provider = build_provider()?; + let _ = PROVIDER.set(provider.clone()); let layer = tracing_opentelemetry::layer() .with_tracer(provider.tracer("codspeed")) .with_filter(build_filter()); @@ -109,7 +124,7 @@ fn build_provider() -> Option { Some( SdkTracerProvider::builder() .with_span_processor(BenchmarkLabelProcessor) - .with_batch_exporter(exporter) + .with_span_processor(HoldingSpanProcessor::new(exporter)) .with_resource( Resource::builder() .with_service_name(service_name()) @@ -129,6 +144,12 @@ pub(crate) fn exit_benchmark() { *CURRENT_BENCHMARK .write() .unwrap_or_else(PoisonError::into_inner) = None; + + if let Some(provider) = PROVIDER.get() { + if let Err(error) = provider.force_flush() { + eprintln!("Warning: failed to export OpenTelemetry spans: {error}"); + } + } } /// Labels each span with the benchmark running when it starts. @@ -157,6 +178,73 @@ impl SpanProcessor for BenchmarkLabelProcessor { } } +/// Holds the ended spans until a flush. +#[derive(Debug)] +struct HoldingSpanProcessor { + exporter: E, + spans: Mutex>, + dropped_spans: AtomicUsize, +} + +impl HoldingSpanProcessor { + fn new(exporter: E) -> Self { + Self { + exporter, + spans: Mutex::new(Vec::new()), + dropped_spans: AtomicUsize::new(0), + } + } +} + +impl HoldingSpanProcessor { + /// Exports in batches, carrying on past a failed batch. + fn export(&self, spans: Vec) -> OTelSdkResult { + let mut result = Ok(()); + let mut spans = spans.into_iter().peekable(); + while spans.peek().is_some() { + let batch = spans.by_ref().take(EXPORT_BATCH_SIZE).collect(); + if let Err(error) = futures_executor::block_on(self.exporter.export(batch)) { + result = Err(error); + } + } + result + } +} + +impl SpanProcessor for HoldingSpanProcessor { + fn on_start(&self, _span: &mut Span, _cx: &Context) {} + + fn on_end(&self, span: SpanData) { + let mut spans = self.spans.lock().unwrap_or_else(PoisonError::into_inner); + if spans.len() < MAX_HELD_SPANS { + spans.push(span); + } else { + self.dropped_spans.fetch_add(1, Ordering::Relaxed); + } + } + + fn force_flush(&self) -> OTelSdkResult { + let spans = std::mem::take(&mut *self.spans.lock().unwrap_or_else(PoisonError::into_inner)); + let dropped_spans = self.dropped_spans.swap(0, Ordering::Relaxed); + if dropped_spans > 0 { + eprintln!( + "Warning: dropped {dropped_spans} OpenTelemetry spans, past the {MAX_HELD_SPANS} held \ + per benchmark" + ); + } + self.export(spans) + } + + fn shutdown_with_timeout(&self, timeout: Duration) -> OTelSdkResult { + self.force_flush() + .and(self.exporter.shutdown_with_timeout(timeout)) + } + + fn set_resource(&mut self, resource: &Resource) { + self.exporter.set_resource(resource); + } +} + fn build_filter() -> EnvFilter { let user_directives = std::env::var(FILTER_ENV).unwrap_or_else(|_| DEFAULT_FILTER.to_owned()); let directives = std::iter::once(user_directives.as_str()) @@ -189,13 +277,14 @@ fn is_cargo_hash(s: &str) -> bool { mod tests { use super::*; use opentelemetry::trace::Tracer as _; - use std::sync::Mutex; + /// Records the exported spans, and the size of each batch. #[derive(Debug, Clone, Default)] - struct RecordingExporter(Arc>>); + struct RecordingExporter(Arc>>, Arc>>); impl opentelemetry_sdk::trace::SpanExporter for RecordingExporter { async fn export(&self, batch: Vec) -> OTelSdkResult { + self.1.lock().unwrap().push(batch.len()); self.0.lock().unwrap().extend(batch); Ok(()) } @@ -239,4 +328,22 @@ mod tests { ] ); } + + #[test] + fn holds_spans_until_flushed() { + let exporter = RecordingExporter::default(); + let provider = SdkTracerProvider::builder() + .with_span_processor(HoldingSpanProcessor::new(exporter.clone())) + .build(); + let tracer = provider.tracer("test"); + + for _ in 0..=EXPORT_BATCH_SIZE { + tracer.start("span").end(); + } + assert!(exporter.0.lock().unwrap().is_empty()); + + provider.force_flush().unwrap(); + assert_eq!(exporter.0.lock().unwrap().len(), EXPORT_BATCH_SIZE + 1); + assert_eq!(*exporter.1.lock().unwrap(), [EXPORT_BATCH_SIZE, 1]); + } } From 49b937257231c345c40d8f004d55312a2a115bc6 Mon Sep 17 00:00:00 2001 From: Luc Varoqui Date: Fri, 16 Oct 2026 12:22:33 +0200 Subject: [PATCH 05/10] feat(codspeed): export tracing events as OTLP logs Events were only recorded as span events, so a receiver got no logs signal and events emitted outside any span were lost. Export them as log records to `/v1/logs` instead, held until the benchmark ends like spans, labeled with the running benchmark URI and carrying the trace and span IDs of the span they are emitted in. Spans no longer carry the events, which would otherwise be exported twice. Refs COD-3862 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 13 + crates/codspeed/Cargo.toml | 7 +- crates/codspeed/src/otel.rs | 433 ++++++++++++++++++++++++++-------- crates/divan_compat/README.md | 9 +- 4 files changed, 359 insertions(+), 103 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8a88ff29..f1d78cfb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -588,6 +588,7 @@ dependencies = [ "libc", "nix", "opentelemetry", + "opentelemetry-appender-tracing", "opentelemetry-otlp", "opentelemetry_sdk", "serde", @@ -1617,6 +1618,18 @@ dependencies = [ "thiserror", ] +[[package]] +name = "opentelemetry-appender-tracing" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01f1c53c46ce80e5fd8cb7a646f13766fd8f315a8a5b75b9bad59a08d2c5c7" +dependencies = [ + "opentelemetry", + "tracing", + "tracing-core", + "tracing-subscriber", +] + [[package]] name = "opentelemetry-http" version = "0.33.1" diff --git a/crates/codspeed/Cargo.toml b/crates/codspeed/Cargo.toml index c5562137..ecd290ff 100644 --- a/crates/codspeed/Cargo.toml +++ b/crates/codspeed/Cargo.toml @@ -28,11 +28,13 @@ serde = { workspace = true } serde_json = { workspace = true } statrs = { version = "0.18.0", default-features = false } -opentelemetry = { version = "0.33", default-features = false, features = ["trace"], optional = true } -opentelemetry_sdk = { version = "0.33", default-features = false, features = ["trace"], optional = true } +opentelemetry = { version = "0.33", default-features = false, features = ["trace", "logs"], optional = true } +opentelemetry_sdk = { version = "0.33", default-features = false, features = ["trace", "logs"], optional = true } +opentelemetry-appender-tracing = { version = "0.33", default-features = false, optional = true } futures-executor = { version = "0.3", default-features = false, features = ["std"], optional = true } opentelemetry-otlp = { version = "0.33", default-features = false, features = [ "trace", + "logs", "http-proto", "reqwest-blocking-client", ], optional = true } @@ -51,6 +53,7 @@ otel = [ "dep:futures-executor", "dep:opentelemetry", "dep:opentelemetry_sdk", + "dep:opentelemetry-appender-tracing", "dep:opentelemetry-otlp", "dep:tracing", "dep:tracing-opentelemetry", diff --git a/crates/codspeed/src/otel.rs b/crates/codspeed/src/otel.rs index 55498dd3..7c822bfe 100644 --- a/crates/codspeed/src/otel.rs +++ b/crates/codspeed/src/otel.rs @@ -1,22 +1,27 @@ -//! Export of the `tracing` spans and events emitted by benchmarks over OTLP/HTTP. +//! Export of the `tracing` spans and events emitted by benchmarks over OTLP/HTTP, as traces and +//! logs respectively. //! //! Everything is a no-op unless [`ENDPOINT_ENV`] is set. //! -//! Ended spans are held in memory and only exported when a benchmark scope exits (see -//! [`crate::benchmark_scope`]) or the exporter shuts down, so no export runs while a benchmark is -//! measured. +//! Ended spans and log records are held in memory and only exported when a benchmark scope exits +//! (see [`crate::benchmark_scope`]) or the exporters shut down, so no export runs while a benchmark +//! is measured. use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, OnceLock, PoisonError, RwLock}; use std::time::Duration; +use opentelemetry::logs::{AnyValue, LogRecord as _}; use opentelemetry::trace::{Span as _, TracerProvider as _}; -use opentelemetry::{Context, KeyValue}; -use opentelemetry_otlp::{SpanExporter, WithExportConfig}; +use opentelemetry::{Context, InstrumentationScope, KeyValue}; +use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge; +use opentelemetry_otlp::{LogExporter, SpanExporter, WithExportConfig}; use opentelemetry_sdk::error::OTelSdkResult; +use opentelemetry_sdk::logs::{LogBatch, LogProcessor, SdkLogRecord, SdkLoggerProvider}; use opentelemetry_sdk::trace::{SdkTracerProvider, Span, SpanData, SpanProcessor}; use opentelemetry_sdk::Resource; use tracing::Subscriber; +use tracing_subscriber::filter::{filter_fn, FilterExt}; use tracing_subscriber::{layer::SubscriberExt, registry::LookupSpan, EnvFilter, Layer}; /// Base URL of the OTLP/HTTP receiver, e.g. `http://localhost:4318`. @@ -27,19 +32,20 @@ pub const FILTER_ENV: &str = "CODSPEED_OTEL_FILTER"; const DEFAULT_FILTER: &str = "info"; -/// Span attribute holding the URI of the benchmark running when the span started. +/// Span and log record attribute holding the URI of the benchmark running when the span started +/// or the record was emitted. pub const BENCHMARK_URI_ATTRIBUTE: &str = "codspeed.benchmark.uri"; -/// Ended spans held in memory between two exports, past which spans are dropped. Bounds the -/// memory used by a benchmark emitting spans on every iteration. -const MAX_HELD_SPANS: usize = 1_000_000; +/// Ended spans, and log records, held in memory between two exports, past which they are dropped. +/// Bounds the memory used by a benchmark emitting on every iteration. +const MAX_HELD: usize = 1_000_000; -/// Spans sent per export request. +/// Spans or log records sent per export request. const EXPORT_BATCH_SIZE: usize = 4_096; static CURRENT_BENCHMARK: RwLock>> = RwLock::new(None); -static PROVIDER: OnceLock = OnceLock::new(); +static PROVIDERS: OnceLock = OnceLock::new(); /// Spans emitted by the exporter itself would be exported in turn, in an endless loop. const EXPORTER_FILTER_DIRECTIVES: &[&str] = &[ @@ -54,17 +60,37 @@ const EXPORTER_FILTER_DIRECTIVES: &[&str] = &[ "tower=off", ]; -/// Flushes and shuts down the exporter when dropped. -#[must_use = "dropping the guard shuts the exporter down"] +#[derive(Debug, Clone)] +struct Providers { + tracer: SdkTracerProvider, + logger: SdkLoggerProvider, +} + +impl Providers { + fn flush(&self) { + if let Err(error) = self.tracer.force_flush() { + eprintln!("Warning: failed to export OpenTelemetry spans: {error}"); + } + if let Err(error) = self.logger.force_flush() { + eprintln!("Warning: failed to export OpenTelemetry logs: {error}"); + } + } +} + +/// Flushes and shuts down the exporters when dropped. +#[must_use = "dropping the guard shuts the exporters down"] pub struct OtelGuard { - provider: SdkTracerProvider, + providers: Providers, } impl Drop for OtelGuard { fn drop(&mut self) { - if let Err(error) = self.provider.shutdown() { + if let Err(error) = self.providers.tracer.shutdown() { eprintln!("Warning: failed to flush OpenTelemetry spans: {error}"); } + if let Err(error) = self.providers.logger.shutdown() { + eprintln!("Warning: failed to flush OpenTelemetry logs: {error}"); + } } } @@ -72,17 +98,29 @@ impl Drop for OtelGuard { /// [`ENDPOINT_ENV`], to add to a subscriber of your own. Returns `None` when /// the variable is unset. /// +/// Events are exported as log records carrying the trace and span IDs of the span they are emitted +/// in, rather than as span events. +/// /// Keep the returned guard alive until the benchmarks are done. pub fn layer() -> Option<(impl Layer, OtelGuard)> where S: Subscriber + for<'span> LookupSpan<'span>, { - let provider = build_provider()?; - let _ = PROVIDER.set(provider.clone()); - let layer = tracing_opentelemetry::layer() - .with_tracer(provider.tracer("codspeed")) - .with_filter(build_filter()); - Some((layer, OtelGuard { provider })) + let providers = build_providers()?; + let _ = PROVIDERS.set(providers.clone()); + Some((build_layer(&providers), OtelGuard { providers })) +} + +fn build_layer(providers: &Providers) -> impl Layer +where + S: Subscriber + for<'span> LookupSpan<'span>, +{ + let span_layer = tracing_opentelemetry::layer() + .with_tracer(providers.tracer.tracer("codspeed")) + .with_filter(build_filter().and(filter_fn(|metadata| metadata.is_span()))); + let log_layer = OpenTelemetryTracingBridge::new(&providers.logger) + .with_filter(build_filter().and(filter_fn(|metadata| metadata.is_event()))); + span_layer.and_then(log_layer) } /// Installs a global `tracing` subscriber exporting spans and events to the receiver at @@ -99,39 +137,50 @@ pub fn init() -> Option { if tracing::subscriber::set_global_default(subscriber).is_err() { return None; } - opentelemetry::global::set_tracer_provider(guard.provider.clone()); + opentelemetry::global::set_tracer_provider(guard.providers.tracer.clone()); Some(guard) } -fn build_provider() -> Option { +fn build_providers() -> Option { let endpoint = std::env::var(ENDPOINT_ENV) .ok() .filter(|endpoint| !endpoint.is_empty())?; - let traces_endpoint = format!("{}/v1/traces", endpoint.trim_end_matches('/')); + let endpoint = endpoint.trim_end_matches('/'); - let exporter = match SpanExporter::builder() + let exporters = SpanExporter::builder() .with_http() - .with_endpoint(traces_endpoint) + .with_endpoint(format!("{endpoint}/v1/traces")) .build() - { - Ok(exporter) => exporter, + .and_then(|span_exporter| { + let log_exporter = LogExporter::builder() + .with_http() + .with_endpoint(format!("{endpoint}/v1/logs")) + .build()?; + Ok((span_exporter, log_exporter)) + }); + let (span_exporter, log_exporter) = match exporters { + Ok(exporters) => exporters, Err(error) => { - eprintln!("Warning: failed to build the OpenTelemetry exporter: {error}"); + eprintln!("Warning: failed to build the OpenTelemetry exporters: {error}"); return None; } }; - Some( - SdkTracerProvider::builder() + let resource = Resource::builder() + .with_service_name(service_name()) + .build(); + Some(Providers { + tracer: SdkTracerProvider::builder() .with_span_processor(BenchmarkLabelProcessor) - .with_span_processor(HoldingSpanProcessor::new(exporter)) - .with_resource( - Resource::builder() - .with_service_name(service_name()) - .build(), - ) + .with_span_processor(HoldingSpanProcessor::new(span_exporter)) + .with_resource(resource.clone()) + .build(), + logger: SdkLoggerProvider::builder() + .with_log_processor(BenchmarkLabelProcessor) + .with_log_processor(HoldingLogProcessor::new(log_exporter)) + .with_resource(resource) .build(), - ) + }) } pub(crate) fn enter_benchmark(uri: &str) { @@ -145,24 +194,26 @@ pub(crate) fn exit_benchmark() { .write() .unwrap_or_else(PoisonError::into_inner) = None; - if let Some(provider) = PROVIDER.get() { - if let Err(error) = provider.force_flush() { - eprintln!("Warning: failed to export OpenTelemetry spans: {error}"); - } + if let Some(providers) = PROVIDERS.get() { + providers.flush(); } } -/// Labels each span with the benchmark running when it starts. +fn current_benchmark() -> Option> { + CURRENT_BENCHMARK + .read() + .unwrap_or_else(PoisonError::into_inner) + .clone() +} + +/// Labels each span with the benchmark running when it starts, and each log record with the one +/// running when it is emitted. #[derive(Debug)] struct BenchmarkLabelProcessor; impl SpanProcessor for BenchmarkLabelProcessor { fn on_start(&self, span: &mut Span, _cx: &Context) { - let current_benchmark = CURRENT_BENCHMARK - .read() - .unwrap_or_else(PoisonError::into_inner) - .clone(); - if let Some(uri) = current_benchmark { + if let Some(uri) = current_benchmark() { span.set_attribute(KeyValue::new(BENCHMARK_URI_ATTRIBUTE, uri)); } } @@ -178,61 +229,143 @@ impl SpanProcessor for BenchmarkLabelProcessor { } } +impl LogProcessor for BenchmarkLabelProcessor { + fn emit(&self, record: &mut SdkLogRecord, _instrumentation: &InstrumentationScope) { + if let Some(uri) = current_benchmark() { + record.add_attribute(BENCHMARK_URI_ATTRIBUTE, AnyValue::String(uri.into())); + } + } + + fn force_flush(&self) -> OTelSdkResult { + Ok(()) + } + + fn shutdown_with_timeout(&self, _timeout: Duration) -> OTelSdkResult { + Ok(()) + } +} + +/// Items held until a flush, up to [`MAX_HELD`]. +#[derive(Debug)] +struct Held { + items: Mutex>, + dropped: AtomicUsize, +} + +impl Held { + fn new() -> Self { + Self { + items: Mutex::new(Vec::new()), + dropped: AtomicUsize::new(0), + } + } + + fn push(&self, item: T) { + let mut items = self.items.lock().unwrap_or_else(PoisonError::into_inner); + if items.len() < MAX_HELD { + items.push(item); + } else { + self.dropped.fetch_add(1, Ordering::Relaxed); + } + } + + /// Takes the held items, warning about those dropped since the last take. + fn take(&self, kind: &str) -> Vec { + let items = std::mem::take(&mut *self.items.lock().unwrap_or_else(PoisonError::into_inner)); + let dropped = self.dropped.swap(0, Ordering::Relaxed); + if dropped > 0 { + eprintln!( + "Warning: dropped {dropped} OpenTelemetry {kind}, past the {MAX_HELD} held per \ + benchmark" + ); + } + items + } +} + +/// Exports in batches, carrying on past a failed batch. +fn export_in_batches( + items: Vec, + mut export: impl FnMut(Vec) -> OTelSdkResult, +) -> OTelSdkResult { + let mut result = Ok(()); + let mut items = items.into_iter().peekable(); + while items.peek().is_some() { + if let Err(error) = export(items.by_ref().take(EXPORT_BATCH_SIZE).collect()) { + result = Err(error); + } + } + result +} + /// Holds the ended spans until a flush. #[derive(Debug)] struct HoldingSpanProcessor { exporter: E, - spans: Mutex>, - dropped_spans: AtomicUsize, + spans: Held, } impl HoldingSpanProcessor { fn new(exporter: E) -> Self { Self { exporter, - spans: Mutex::new(Vec::new()), - dropped_spans: AtomicUsize::new(0), + spans: Held::new(), } } } -impl HoldingSpanProcessor { - /// Exports in batches, carrying on past a failed batch. - fn export(&self, spans: Vec) -> OTelSdkResult { - let mut result = Ok(()); - let mut spans = spans.into_iter().peekable(); - while spans.peek().is_some() { - let batch = spans.by_ref().take(EXPORT_BATCH_SIZE).collect(); - if let Err(error) = futures_executor::block_on(self.exporter.export(batch)) { - result = Err(error); - } - } - result - } -} - impl SpanProcessor for HoldingSpanProcessor { fn on_start(&self, _span: &mut Span, _cx: &Context) {} fn on_end(&self, span: SpanData) { - let mut spans = self.spans.lock().unwrap_or_else(PoisonError::into_inner); - if spans.len() < MAX_HELD_SPANS { - spans.push(span); - } else { - self.dropped_spans.fetch_add(1, Ordering::Relaxed); - } + self.spans.push(span); } fn force_flush(&self) -> OTelSdkResult { - let spans = std::mem::take(&mut *self.spans.lock().unwrap_or_else(PoisonError::into_inner)); - let dropped_spans = self.dropped_spans.swap(0, Ordering::Relaxed); - if dropped_spans > 0 { - eprintln!( - "Warning: dropped {dropped_spans} OpenTelemetry spans, past the {MAX_HELD_SPANS} held \ - per benchmark" - ); + export_in_batches(self.spans.take("spans"), |batch| { + futures_executor::block_on(self.exporter.export(batch)) + }) + } + + fn shutdown_with_timeout(&self, timeout: Duration) -> OTelSdkResult { + self.force_flush() + .and(self.exporter.shutdown_with_timeout(timeout)) + } + + fn set_resource(&mut self, resource: &Resource) { + self.exporter.set_resource(resource); + } +} + +/// Holds the emitted log records until a flush. +#[derive(Debug)] +struct HoldingLogProcessor { + exporter: E, + records: Held<(SdkLogRecord, InstrumentationScope)>, +} + +impl HoldingLogProcessor { + fn new(exporter: E) -> Self { + Self { + exporter, + records: Held::new(), } - self.export(spans) + } +} + +impl LogProcessor for HoldingLogProcessor { + fn emit(&self, record: &mut SdkLogRecord, instrumentation: &InstrumentationScope) { + self.records.push((record.clone(), instrumentation.clone())); + } + + fn force_flush(&self) -> OTelSdkResult { + export_in_batches(self.records.take("log records"), |batch| { + let batch = batch + .iter() + .map(|(record, instrumentation)| (record, instrumentation)) + .collect::>(); + futures_executor::block_on(self.exporter.export(LogBatch::new(&batch))) + }) } fn shutdown_with_timeout(&self, timeout: Duration) -> OTelSdkResult { @@ -276,6 +409,7 @@ fn is_cargo_hash(s: &str) -> bool { #[cfg(test)] mod tests { use super::*; + use opentelemetry::logs::{Logger as _, LoggerProvider as _}; use opentelemetry::trace::Tracer as _; /// Records the exported spans, and the size of each batch. @@ -290,6 +424,21 @@ mod tests { } } + /// Records the exported log records, and the size of each batch. + #[derive(Debug, Clone, Default)] + struct RecordingLogExporter(Arc>>, Arc>>); + + impl opentelemetry_sdk::logs::LogExporter for RecordingLogExporter { + async fn export(&self, batch: LogBatch<'_>) -> OTelSdkResult { + let records = batch.iter().map(|(record, _)| record.clone()); + let mut recorded = self.0.lock().unwrap(); + let len_before = recorded.len(); + recorded.extend(records); + self.1.lock().unwrap().push(recorded.len() - len_before); + Ok(()) + } + } + fn benchmark_uri(span: &SpanData) -> Option { span.attributes .iter() @@ -297,36 +446,74 @@ mod tests { .map(|attribute| attribute.value.to_string()) } + fn log_benchmark_uri(record: &SdkLogRecord) -> Option { + record + .attributes_iter() + .find(|(key, _)| key.as_str() == BENCHMARK_URI_ATTRIBUTE) + .map(|(_, value)| match value { + AnyValue::String(uri) => uri.to_string(), + other => panic!("unexpected benchmark URI {other:?}"), + }) + } + + fn log_body(record: &SdkLogRecord) -> String { + match record.body() { + Some(AnyValue::String(body)) => body.to_string(), + other => panic!("unexpected log body {other:?}"), + } + } + + // Spans and log records are checked in one test since the current benchmark is global. #[test] - fn labels_the_spans_started_in_a_benchmark() { - let exporter = RecordingExporter::default(); - let provider = SdkTracerProvider::builder() + fn labels_the_spans_and_log_records_of_a_benchmark() { + let span_exporter = RecordingExporter::default(); + let tracer_provider = SdkTracerProvider::builder() .with_span_processor(BenchmarkLabelProcessor) - .with_simple_exporter(exporter.clone()) + .with_simple_exporter(span_exporter.clone()) .build(); - let tracer = provider.tracer("test"); + let tracer = tracer_provider.tracer("test"); + let log_exporter = RecordingLogExporter::default(); + let logger_provider = SdkLoggerProvider::builder() + .with_log_processor(BenchmarkLabelProcessor) + .with_simple_exporter(log_exporter.clone()) + .build(); + let logger = logger_provider.logger("test"); + let log = |body: &'static str| { + let mut record = logger.create_log_record(); + record.set_body(body.into()); + logger.emit(record); + }; tracer.start("before").end(); + log("before"); enter_benchmark("bench.rs::a"); tracer.start("inside").end(); + log("inside"); exit_benchmark(); tracer.start("after").end(); - - let labels = exporter + log("after"); + + let expected: [(String, Option); 3] = [ + ("before".into(), None), + ("inside".into(), Some("bench.rs::a".into())), + ("after".into(), None), + ]; + let span_labels = span_exporter .0 .lock() .unwrap() .iter() .map(|span| (span.name.to_string(), benchmark_uri(span))) .collect::>(); - assert_eq!( - labels, - [ - ("before".into(), None), - ("inside".into(), Some("bench.rs::a".into())), - ("after".into(), None), - ] - ); + assert_eq!(span_labels, expected); + let log_labels = log_exporter + .0 + .lock() + .unwrap() + .iter() + .map(|record| (log_body(record), log_benchmark_uri(record))) + .collect::>(); + assert_eq!(log_labels, expected); } #[test] @@ -346,4 +533,56 @@ mod tests { assert_eq!(exporter.0.lock().unwrap().len(), EXPORT_BATCH_SIZE + 1); assert_eq!(*exporter.1.lock().unwrap(), [EXPORT_BATCH_SIZE, 1]); } + + #[test] + fn holds_log_records_until_flushed() { + let exporter = RecordingLogExporter::default(); + let provider = SdkLoggerProvider::builder() + .with_log_processor(HoldingLogProcessor::new(exporter.clone())) + .build(); + let logger = provider.logger("test"); + + for _ in 0..=EXPORT_BATCH_SIZE { + logger.emit(logger.create_log_record()); + } + assert!(exporter.0.lock().unwrap().is_empty()); + + provider.force_flush().unwrap(); + assert_eq!(exporter.0.lock().unwrap().len(), EXPORT_BATCH_SIZE + 1); + assert_eq!(*exporter.1.lock().unwrap(), [EXPORT_BATCH_SIZE, 1]); + } + + #[test] + fn exports_events_as_log_records_of_their_span() { + let span_exporter = RecordingExporter::default(); + let log_exporter = RecordingLogExporter::default(); + let providers = Providers { + tracer: SdkTracerProvider::builder() + .with_simple_exporter(span_exporter.clone()) + .build(), + logger: SdkLoggerProvider::builder() + .with_simple_exporter(log_exporter.clone()) + .build(), + }; + let subscriber = tracing_subscriber::registry().with(build_layer(&providers)); + + tracing::subscriber::with_default(subscriber, || { + let _span = tracing::info_span!("parent").entered(); + tracing::info!("inside"); + }); + + let spans = span_exporter.0.lock().unwrap(); + let [span] = spans.as_slice() else { + panic!("expected one span, got {spans:?}"); + }; + assert!(span.events.is_empty()); + let records = log_exporter.0.lock().unwrap(); + let [record] = records.as_slice() else { + panic!("expected one log record, got {records:?}"); + }; + assert_eq!(log_body(record), "inside"); + let trace_context = record.trace_context().unwrap(); + assert_eq!(trace_context.trace_id, span.span_context.trace_id()); + assert_eq!(trace_context.span_id, span.span_context.span_id()); + } } diff --git a/crates/divan_compat/README.md b/crates/divan_compat/README.md index cb2e4404..e1c0e163 100644 --- a/crates/divan_compat/README.md +++ b/crates/divan_compat/README.md @@ -75,10 +75,11 @@ Done running my_benchmark Finished running 1 benchmark suite(s) ``` -### Exporting `tracing` spans with OpenTelemetry +### Exporting `tracing` spans and logs with OpenTelemetry With the opt-in `otel` feature, `divan::main()` installs a global `tracing` subscriber that exports -the spans emitted by your benchmarks, and the events recorded inside them, over OTLP/HTTP: +the spans emitted by your benchmarks as traces, and their events as logs, over OTLP/HTTP. Each log +record carries the trace and span IDs of the span it was emitted in: ```toml title="Cargo.toml" [dev-dependencies] @@ -90,8 +91,8 @@ divan = { package = "codspeed-divan-compat", version = "*", features = ["otel"] - `CODSPEED_OTEL_FILTER`: [`EnvFilter`](https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html) directives selecting what is exported. Defaults to `info`. -The provider is also registered as the global OpenTelemetry tracer provider, and spans are flushed -when `divan::main()` returns. +The tracer provider is also registered as the global OpenTelemetry tracer provider, and spans and +logs are flushed when `divan::main()` returns. If your benchmarks already set a global subscriber before calling `divan::main()`, nothing is installed. Add the CodSpeed layer to your subscriber instead, and keep its guard alive until the From ad257ccef3bcb6ee47f736beb9e2fc5875586b99 Mon Sep 17 00:00:00 2001 From: Luc Varoqui Date: Fri, 16 Oct 2026 13:26:41 +0200 Subject: [PATCH 06/10] feat(codspeed): expose the instrument hooks monotonic clock offset Bump instrument-hooks for `instrument_hooks_monotonic_offset`, the offset that shifts the monotonic clock of the benchmark markers to read as wall clock times, cached for the process. Expose it as `InstrumentHooks::monotonic_offset`, 0 without instrument hooks. In analysis mode, the hooks also send it to the runner. Refs COD-3862 Co-Authored-By: Claude Opus 5.5 --- crates/codspeed/instrument-hooks | 2 +- .../codspeed/src/instrument_hooks/bindings.rs | 3 +++ crates/codspeed/src/instrument_hooks/mod.rs | 24 +++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/codspeed/instrument-hooks b/crates/codspeed/instrument-hooks index e998b76e..f1b12073 160000 --- a/crates/codspeed/instrument-hooks +++ b/crates/codspeed/instrument-hooks @@ -1 +1 @@ -Subproject commit e998b76edcc2945d7f98c5f13c38045d44de26ae +Subproject commit f1b12073f4f86cd5cd44aa3dffb04f0e6b1ad3ad diff --git a/crates/codspeed/src/instrument_hooks/bindings.rs b/crates/codspeed/src/instrument_hooks/bindings.rs index 5d4768ae..4a80dfeb 100644 --- a/crates/codspeed/src/instrument_hooks/bindings.rs +++ b/crates/codspeed/src/instrument_hooks/bindings.rs @@ -56,6 +56,9 @@ extern "C" { extern "C" { pub fn instrument_hooks_current_timestamp() -> u64; } +extern "C" { + pub fn instrument_hooks_monotonic_offset() -> i64; +} extern "C" { pub fn instrument_hooks_callgrind_add_obj_skip(path: *const ::std::os::raw::c_char) -> u8; } diff --git a/crates/codspeed/src/instrument_hooks/mod.rs b/crates/codspeed/src/instrument_hooks/mod.rs index e3f93deb..cfb324e3 100644 --- a/crates/codspeed/src/instrument_hooks/mod.rs +++ b/crates/codspeed/src/instrument_hooks/mod.rs @@ -166,6 +166,13 @@ mod linux_impl { } } + /// Offset, in nanoseconds, to add to [`Self::current_timestamp`] to read it as a wall + /// clock time since the Unix epoch, or 0 where the monotonic clock is unavailable. Cached + /// for the process. + pub fn monotonic_offset() -> i64 { + unsafe { ffi::instrument_hooks_monotonic_offset() } + } + pub fn set_environment( &self, section_name: &str, @@ -256,6 +263,10 @@ mod other_impl { 0 } + pub fn monotonic_offset() -> i64 { + 0 + } + pub fn set_environment( &self, _section_name: &str, @@ -312,4 +323,17 @@ mod tests { hooks.add_benchmark_timestamps(start, end); assert!(!hooks.is_instrumented() || hooks.stop_benchmark().is_ok()); } + + #[cfg(all(use_instrument_hooks, any(target_os = "linux", target_os = "macos")))] + #[test] + fn monotonic_offset_shifts_the_monotonic_clock_to_the_wall_clock() { + let offset = InstrumentHooks::monotonic_offset(); + assert_eq!(offset, InstrumentHooks::monotonic_offset()); + let shifted_ns = InstrumentHooks::current_timestamp() as i64 + offset; + let wall_ns = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() as i64; + assert!((wall_ns - shifted_ns).abs() < 1_000_000_000); + } } From 4bad64a3fb50909eb0517c7767f12619925a3add Mon Sep 17 00:00:00 2001 From: Luc Varoqui Date: Fri, 16 Oct 2026 13:26:42 +0200 Subject: [PATCH 07/10] feat(codspeed): timestamp spans and logs with the monotonic clock Spans and log records carried wall clock times, which cannot be lined up exactly with the benchmark markers taken on the monotonic clock. With instrument hooks, timestamp log records with the monotonic clock shifted by `InstrumentHooks::monotonic_offset`, and convert span start, end and event times to it when the span ends. The hooks send the offset to the runner, which subtracts it to get back the clock of the markers, while standard tools still read wall clock times. Without instrument hooks, times stay wall clock times. Refs COD-3862 Co-Authored-By: Claude Opus 5.5 --- crates/codspeed/src/otel.rs | 132 +++++++++++++++++++++++++++++++++--- 1 file changed, 122 insertions(+), 10 deletions(-) diff --git a/crates/codspeed/src/otel.rs b/crates/codspeed/src/otel.rs index 7c822bfe..310fa629 100644 --- a/crates/codspeed/src/otel.rs +++ b/crates/codspeed/src/otel.rs @@ -6,10 +6,14 @@ //! Ended spans and log records are held in memory and only exported when a benchmark scope exits //! (see [`crate::benchmark_scope`]) or the exporters shut down, so no export runs while a benchmark //! is measured. +//! +//! With instrument hooks, spans and log records are timestamped with the monotonic clock of the +//! benchmark markers, shifted by [`InstrumentHooks::monotonic_offset`] to read as wall clock +//! times. The hooks send that offset to the runner. use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, OnceLock, PoisonError, RwLock}; -use std::time::Duration; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use opentelemetry::logs::{AnyValue, LogRecord as _}; use opentelemetry::trace::{Span as _, TracerProvider as _}; @@ -24,6 +28,8 @@ use tracing::Subscriber; use tracing_subscriber::filter::{filter_fn, FilterExt}; use tracing_subscriber::{layer::SubscriberExt, registry::LookupSpan, EnvFilter, Layer}; +use crate::instrument_hooks::InstrumentHooks; + /// Base URL of the OTLP/HTTP receiver, e.g. `http://localhost:4318`. pub const ENDPOINT_ENV: &str = "CODSPEED_OTEL_ENDPOINT"; @@ -166,23 +172,63 @@ fn build_providers() -> Option { } }; + let clock = MonotonicClock::new(); let resource = Resource::builder() .with_service_name(service_name()) .build(); Some(Providers { tracer: SdkTracerProvider::builder() .with_span_processor(BenchmarkLabelProcessor) - .with_span_processor(HoldingSpanProcessor::new(span_exporter)) + .with_span_processor(HoldingSpanProcessor::new(span_exporter, clock)) .with_resource(resource.clone()) .build(), logger: SdkLoggerProvider::builder() .with_log_processor(BenchmarkLabelProcessor) - .with_log_processor(HoldingLogProcessor::new(log_exporter)) + .with_log_processor(HoldingLogProcessor::new(log_exporter, clock)) .with_resource(resource) .build(), }) } +/// The instrument hooks monotonic clock, shifted by a fixed offset to read as wall clock times. +#[derive(Debug, Clone, Copy)] +struct MonotonicClock { + offset_ns: i64, +} + +impl MonotonicClock { + /// Uses the offset instrument hooks shares across the process, so that every integration in + /// it shifts its timestamps alike. Returns `None` where the monotonic clock is unavailable. + fn new() -> Option { + match InstrumentHooks::monotonic_offset() { + 0 => None, + offset_ns => Some(Self { offset_ns }), + } + } + + fn now(&self) -> SystemTime { + let monotonic_ns = InstrumentHooks::current_timestamp() as i64; + UNIX_EPOCH + Duration::from_nanos((monotonic_ns + self.offset_ns) as u64) + } + + /// Converts wall clock times taken shortly before to this clock, by their distance to now. + /// Changes of the wall clock since they were taken would otherwise skew them. + fn convert_recent<'a>(&self, times: impl IntoIterator) { + let (wall_now, now) = (SystemTime::now(), self.now()); + for time in times { + *time = shift(*time, wall_now, now); + } + } +} + +/// Moves `time`, taken on the clock reading `from_now`, to the clock reading `to_now`. +fn shift(time: SystemTime, from_now: SystemTime, to_now: SystemTime) -> SystemTime { + match from_now.duration_since(time) { + Ok(elapsed) => to_now - elapsed, + Err(error) => to_now + error.duration(), + } +} + pub(crate) fn enter_benchmark(uri: &str) { *CURRENT_BENCHMARK .write() @@ -298,17 +344,19 @@ fn export_in_batches( result } -/// Holds the ended spans until a flush. +/// Holds the ended spans until a flush, after moving their times to `clock`. #[derive(Debug)] struct HoldingSpanProcessor { exporter: E, + clock: Option, spans: Held, } impl HoldingSpanProcessor { - fn new(exporter: E) -> Self { + fn new(exporter: E, clock: Option) -> Self { Self { exporter, + clock, spans: Held::new(), } } @@ -317,7 +365,20 @@ impl HoldingSpanProcessor { impl SpanProcessor for HoldingSpanProcessor { fn on_start(&self, _span: &mut Span, _cx: &Context) {} - fn on_end(&self, span: SpanData) { + fn on_end(&self, mut span: SpanData) { + // Spans end as they are closed, so their times are recent. + if let Some(clock) = &self.clock { + let event_times = span + .events + .events + .iter_mut() + .map(|event| &mut event.timestamp); + clock.convert_recent( + [&mut span.start_time, &mut span.end_time] + .into_iter() + .chain(event_times), + ); + } self.spans.push(span); } @@ -337,17 +398,19 @@ impl SpanProcessor for HoldingSpanPro } } -/// Holds the emitted log records until a flush. +/// Holds the emitted log records until a flush, after timestamping them with `clock`. #[derive(Debug)] struct HoldingLogProcessor { exporter: E, + clock: Option, records: Held<(SdkLogRecord, InstrumentationScope)>, } impl HoldingLogProcessor { - fn new(exporter: E) -> Self { + fn new(exporter: E, clock: Option) -> Self { Self { exporter, + clock, records: Held::new(), } } @@ -355,6 +418,12 @@ impl HoldingLogProcessor { impl LogProcessor for HoldingLogProcessor { fn emit(&self, record: &mut SdkLogRecord, instrumentation: &InstrumentationScope) { + // Records are emitted as their event happens. + if let Some(clock) = &self.clock { + let now = clock.now(); + record.set_timestamp(now); + record.set_observed_timestamp(now); + } self.records.push((record.clone(), instrumentation.clone())); } @@ -520,7 +589,7 @@ mod tests { fn holds_spans_until_flushed() { let exporter = RecordingExporter::default(); let provider = SdkTracerProvider::builder() - .with_span_processor(HoldingSpanProcessor::new(exporter.clone())) + .with_span_processor(HoldingSpanProcessor::new(exporter.clone(), None)) .build(); let tracer = provider.tracer("test"); @@ -538,7 +607,7 @@ mod tests { fn holds_log_records_until_flushed() { let exporter = RecordingLogExporter::default(); let provider = SdkLoggerProvider::builder() - .with_log_processor(HoldingLogProcessor::new(exporter.clone())) + .with_log_processor(HoldingLogProcessor::new(exporter.clone(), None)) .build(); let logger = provider.logger("test"); @@ -552,6 +621,49 @@ mod tests { assert_eq!(*exporter.1.lock().unwrap(), [EXPORT_BATCH_SIZE, 1]); } + #[test] + fn shifts_times_by_their_distance_to_now() { + let at = |secs| UNIX_EPOCH + Duration::from_secs(secs); + assert_eq!(shift(at(97), at(100), at(1_000)), at(997)); + assert_eq!(shift(at(103), at(100), at(1_000)), at(1_003)); + } + + #[test] + fn timestamps_spans_and_log_records_with_the_monotonic_clock() { + let clock = MonotonicClock { offset_ns: 1_000 }; + let span_exporter = RecordingExporter::default(); + let tracer_provider = SdkTracerProvider::builder() + .with_span_processor(HoldingSpanProcessor::new( + span_exporter.clone(), + Some(clock), + )) + .build(); + let log_exporter = RecordingLogExporter::default(); + let logger_provider = SdkLoggerProvider::builder() + .with_log_processor(HoldingLogProcessor::new(log_exporter.clone(), Some(clock))) + .build(); + let logger = logger_provider.logger("test"); + + let before = clock.now(); + let mut span = tracer_provider.tracer("test").start("span"); + logger.emit(logger.create_log_record()); + span.end(); + let after = clock.now(); + tracer_provider.force_flush().unwrap(); + logger_provider.force_flush().unwrap(); + + let spans = span_exporter.0.lock().unwrap(); + // The wall clock moves on between the two clock reads of a conversion. + let tolerance = Duration::from_millis(10); + assert!(before - tolerance <= spans[0].start_time); + assert!(spans[0].start_time <= spans[0].end_time); + assert!(spans[0].end_time <= after + tolerance); + let records = log_exporter.0.lock().unwrap(); + let timestamp = records[0].timestamp().unwrap(); + assert!(before <= timestamp && timestamp <= after); + assert_eq!(records[0].observed_timestamp(), Some(timestamp)); + } + #[test] fn exports_events_as_log_records_of_their_span() { let span_exporter = RecordingExporter::default(); From 52c07049ee54322368f01b71ec64c12f2f3db353 Mon Sep 17 00:00:00 2001 From: Luc Varoqui Date: Mon, 19 Oct 2026 16:30:43 +0200 Subject: [PATCH 08/10] feat(cargo-codspeed): compile tracing out of measured builds, add a telemetry mode `tracing` spans and events weigh on the measurements of the code they instrument. Every build for the simulation, walltime and memory modes now passes `--cfg=codspeed_tracing_off`, behind which the `codspeed` crate depends on `tracing` with `max_level_off`: features unification compiles every span and event of the build out, dependencies included. Unlike `--features tracing/max_level_off`, it doesn't require each selected package to depend on `tracing` directly. `--keep-tracing` opts out, e.g. to benchmark a `tracing` subscriber. The new `telemetry` measurement mode builds the benchmarks to collect the telemetry they emit instead of measuring them: they run once like in simulation mode (`--cfg=codspeed`), keep `tracing`, and land in `target/codspeed/telemetry`. It can't be combined with another mode, nor with `--keep-tracing`, which it implies. `release_max_level_*` features still take precedence over `max_level_off` in measured builds, since debug assertions are off. Refs COD-3862 Co-Authored-By: Claude Opus 5.5 --- crates/cargo-codspeed/src/app.rs | 25 ++++++ crates/cargo-codspeed/src/build.rs | 17 +++- crates/cargo-codspeed/src/measurement_mode.rs | 15 ++++ .../tests/tracing.in/Cargo.toml | 15 ++++ .../tracing.in/benches/tracing_example.rs | 13 +++ crates/cargo-codspeed/tests/tracing.rs | 83 +++++++++++++++++++ crates/codspeed/Cargo.toml | 5 ++ 7 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 crates/cargo-codspeed/tests/tracing.in/Cargo.toml create mode 100644 crates/cargo-codspeed/tests/tracing.in/benches/tracing_example.rs create mode 100644 crates/cargo-codspeed/tests/tracing.rs diff --git a/crates/cargo-codspeed/src/app.rs b/crates/cargo-codspeed/src/app.rs index 6c309a10..055252f3 100644 --- a/crates/cargo-codspeed/src/app.rs +++ b/crates/cargo-codspeed/src/app.rs @@ -36,7 +36,26 @@ impl Cli { offline, frozen, measurement_mode, + keep_tracing, } => { + if measurement_mode.contains(&MeasurementMode::Telemetry) { + if measurement_mode + .iter() + .any(|m| *m != MeasurementMode::Telemetry) + { + bail!( + "The telemetry mode can't be combined with other measurement modes: \ + build it separately with `cargo codspeed build -m telemetry`" + ); + } + if keep_tracing { + bail!( + "`--keep-tracing` has no effect in telemetry mode, which always keeps \ + `tracing`" + ); + } + } + let passthrough_flags = { let mut passthrough_flags = Vec::new(); if all_features { @@ -89,6 +108,7 @@ impl Cli { profile: profile.clone(), quiet: self.quiet, build_mode, + keep_tracing, passthrough_flags: passthrough_flags.clone(), }, )?; @@ -197,6 +217,11 @@ enum Commands { env = "CODSPEED_RUNNER_MODE" )] measurement_mode: Vec, + + /// Keep `tracing` spans and events compiled in. By default, the `max_level_off` feature of + /// `tracing` compiles them out of every mode but `telemetry`. + #[arg(long, help_heading = COMPILATION_HELP)] + keep_tracing: bool, }, /// Run the previously built benchmarks Run { diff --git a/crates/cargo-codspeed/src/build.rs b/crates/cargo-codspeed/src/build.rs index c99ad51e..577ac60b 100644 --- a/crates/cargo-codspeed/src/build.rs +++ b/crates/cargo-codspeed/src/build.rs @@ -9,11 +9,16 @@ use cargo_metadata::{camino::Utf8PathBuf, Message, Metadata, TargetKind}; use std::collections::HashMap; use std::process::{exit, Command, Stdio}; +/// Set by `cargo-codspeed` in the builds `tracing` is compiled out of, see the `codspeed` crate +/// manifest +const TRACING_OFF_CFG: &str = "codspeed_tracing_off"; + struct BuildOptions<'a> { bench_target_filters: BenchTargetFilters, package_filters: PackageFilters, features: &'a Option>, profile: &'a str, + keep_tracing: bool, passthrough_flags: &'a Vec, } @@ -31,6 +36,8 @@ pub struct BuildConfig { pub profile: String, pub quiet: bool, pub build_mode: BuildMode, + /// Keep `tracing` compiled in when building for a measurement mode + pub keep_tracing: bool, pub passthrough_flags: Vec, } @@ -226,10 +233,17 @@ See `cargo codspeed build --help` for more information."); ]; // Add the codspeed cfg flag if the benchmark should only run once - if build_mode == BuildMode::Analysis { + if build_mode.runs_once() { flags.push("--cfg=codspeed".to_owned()); } + // Compile `tracing` out of measured benchmarks, so that it doesn't weigh on the + // measurements: the `codspeed` crate enables the `max_level_off` feature of `tracing` + // behind this cfg. Only the telemetry build keeps it, to export what it records. + if build_mode != BuildMode::Telemetry && !self.keep_tracing { + flags.push(format!("--cfg={TRACING_OFF_CFG}")); + } + match std::env::var("RUSTFLAGS") { Result::Ok(existing_rustflags) => { // Expand already existing RUSTFLAGS env var @@ -307,6 +321,7 @@ pub fn build_benches(metadata: &Metadata, config: BuildConfig) -> Result<()> { package_filters: config.package_filters, features: &config.features, profile: &config.profile, + keep_tracing: config.keep_tracing, passthrough_flags: &config.passthrough_flags, } .build(metadata, config.quiet, build_mode)?; diff --git a/crates/cargo-codspeed/src/measurement_mode.rs b/crates/cargo-codspeed/src/measurement_mode.rs index f2635a0c..66a46006 100644 --- a/crates/cargo-codspeed/src/measurement_mode.rs +++ b/crates/cargo-codspeed/src/measurement_mode.rs @@ -7,6 +7,16 @@ pub enum BuildMode { #[default] Analysis, Walltime, + /// Runs each benchmark like [`BuildMode::Analysis`], to collect the telemetry it emits rather + /// than measure it + Telemetry, +} + +impl BuildMode { + /// Whether the benchmarks run once, behind `cfg(codspeed)`, rather than in a walltime loop + pub fn runs_once(self) -> bool { + matches!(self, BuildMode::Analysis | BuildMode::Telemetry) + } } impl fmt::Display for BuildMode { @@ -17,6 +27,7 @@ impl fmt::Display for BuildMode { match self { BuildMode::Analysis => "analysis", BuildMode::Walltime => "walltime", + BuildMode::Telemetry => "telemetry", } ) } @@ -30,6 +41,8 @@ pub enum MeasurementMode { Simulation, Walltime, Memory, + /// Collects the telemetry the benchmarks emit instead of measuring them + Telemetry, } impl From for BuildMode { @@ -37,6 +50,7 @@ impl From for BuildMode { match measurement_mode { MeasurementMode::Simulation | MeasurementMode::Memory => BuildMode::Analysis, MeasurementMode::Walltime => BuildMode::Walltime, + MeasurementMode::Telemetry => BuildMode::Telemetry, } } } @@ -50,6 +64,7 @@ impl fmt::Display for MeasurementMode { MeasurementMode::Simulation => "simulation", MeasurementMode::Walltime => "walltime", MeasurementMode::Memory => "memory", + MeasurementMode::Telemetry => "telemetry", } ) } diff --git a/crates/cargo-codspeed/tests/tracing.in/Cargo.toml b/crates/cargo-codspeed/tests/tracing.in/Cargo.toml new file mode 100644 index 00000000..bf323847 --- /dev/null +++ b/crates/cargo-codspeed/tests/tracing.in/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "tracing-example" +version = "0.0.0" +edition = "2021" +publish = false + +[dependencies] +divan = { package = "codspeed-divan-compat", path = "../../../divan_compat" } +tracing = "0.1" + +[workspace] + +[[bench]] +name = "tracing_example" +harness = false diff --git a/crates/cargo-codspeed/tests/tracing.in/benches/tracing_example.rs b/crates/cargo-codspeed/tests/tracing.in/benches/tracing_example.rs new file mode 100644 index 00000000..5d4e7d10 --- /dev/null +++ b/crates/cargo-codspeed/tests/tracing.in/benches/tracing_example.rs @@ -0,0 +1,13 @@ +#[divan::bench] +fn traced() -> u32 { + let _span = tracing::info_span!("traced").entered(); + divan::black_box(1) + 1 +} + +fn main() { + eprintln!( + "tracing static max level: {}", + tracing::level_filters::STATIC_MAX_LEVEL + ); + divan::main(); +} diff --git a/crates/cargo-codspeed/tests/tracing.rs b/crates/cargo-codspeed/tests/tracing.rs new file mode 100644 index 00000000..3c067a2e --- /dev/null +++ b/crates/cargo-codspeed/tests/tracing.rs @@ -0,0 +1,83 @@ +use predicates::str::contains; + +mod helpers; +use helpers::*; + +const DIR: &str = "tests/tracing.in"; + +fn build_and_run(build_args: &[&str], run_args: &[&str], expected_level: &str) { + let dir = setup(DIR, Project::Simple); + cargo_codspeed(&dir) + .arg("build") + .args(build_args) + .assert() + .success(); + cargo_codspeed(&dir) + .arg("run") + .args(run_args) + .assert() + .success() + .stderr(contains(format!( + "tracing static max level: {expected_level}" + ))); + teardown(dir); +} + +#[test] +fn test_tracing_compiled_out_for_simulation() { + build_and_run(&[], &[], "off"); +} + +#[test] +fn test_tracing_compiled_out_for_walltime() { + build_and_run(&["-m", "walltime"], &["-m", "walltime"], "off"); +} + +#[test] +fn test_tracing_kept_with_keep_tracing() { + build_and_run(&["--keep-tracing"], &[], "trace"); +} + +#[test] +fn test_tracing_kept_for_telemetry() { + build_and_run(&["-m", "telemetry"], &["-m", "telemetry"], "trace"); +} + +#[test] +fn test_telemetry_run_without_build() { + let dir = setup(DIR, Project::Simple); + cargo_codspeed(&dir) + .args(["run", "-m", "telemetry"]) + .assert() + .failure() + .stderr(contains( + "Error: No benchmarks found for the telemetry mode. Run `cargo codspeed build -m telemetry` first.", + )); + teardown(dir); +} + +#[test] +fn test_telemetry_not_combined_with_other_modes() { + let dir = setup(DIR, Project::Simple); + cargo_codspeed(&dir) + .args(["build", "-m", "simulation,telemetry"]) + .assert() + .failure() + .stderr(contains( + "Error: The telemetry mode can't be combined with other measurement modes", + )); + teardown(dir); +} + +#[test] +fn test_telemetry_rejects_keep_tracing() { + let dir = setup(DIR, Project::Simple); + cargo_codspeed(&dir) + .args(["build", "-m", "telemetry", "--keep-tracing"]) + .assert() + .failure() + .stderr(contains( + "Error: `--keep-tracing` has no effect in telemetry mode", + )); + teardown(dir); +} diff --git a/crates/codspeed/Cargo.toml b/crates/codspeed/Cargo.toml index ecd290ff..942d34fc 100644 --- a/crates/codspeed/Cargo.toml +++ b/crates/codspeed/Cargo.toml @@ -48,6 +48,11 @@ tracing-subscriber = { version = "0.3", default-features = false, features = [ # Unused directly: raises the floor of `sharded-slab`'s `lazy_static = "1"`, which fails to build below 1.1 lazy_static = { version = "1.1", optional = true } +# Unused directly: `cargo-codspeed` sets this cfg when building benchmarks for a measurement mode, +# so that features unification compiles the `tracing` spans and events of the whole build out. +[target.'cfg(codspeed_tracing_off)'.dependencies] +tracing = { version = "0.1", default-features = false, features = ["max_level_off"] } + [features] otel = [ "dep:futures-executor", From 5468bd4083c377cc7b0c46952fdeea713544259b Mon Sep 17 00:00:00 2001 From: Luc Varoqui Date: Mon, 19 Oct 2026 16:35:22 +0200 Subject: [PATCH 09/10] feat(codspeed): export spans and logs in the background as they end Telemetry is now collected in a run of its own (the `telemetry` mode of `cargo codspeed`), whose performance is not measured, so spans and log records no longer need to be held until each benchmark exits. They go through the SDK batch processors instead, from a background thread, with a queue of 65536 rather than the default 2048 so that a benchmark emitting on every iteration doesn't overflow it. The monotonic clock timestamps are kept, by wrapping the batch processors. `otel::init` and `otel::layer` return `None` when `tracing` is compiled out, as in measured builds: nothing could be exported, and `EnvFilter` would warn that its directives are disabled statically. Drops the `futures-executor` dependency, only used by the held exports. Refs COD-3862 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 - crates/codspeed/Cargo.toml | 2 - crates/codspeed/src/benchmark_scope.rs | 4 +- crates/codspeed/src/otel.rs | 238 ++++++++----------------- 4 files changed, 72 insertions(+), 173 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f1d78cfb..3d75bd63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -581,7 +581,6 @@ dependencies = [ "anyhow", "cc", "colored", - "futures-executor", "getrandom 0.4.3", "glob", "lazy_static", diff --git a/crates/codspeed/Cargo.toml b/crates/codspeed/Cargo.toml index 942d34fc..6ea24bd1 100644 --- a/crates/codspeed/Cargo.toml +++ b/crates/codspeed/Cargo.toml @@ -31,7 +31,6 @@ statrs = { version = "0.18.0", default-features = false } opentelemetry = { version = "0.33", default-features = false, features = ["trace", "logs"], optional = true } opentelemetry_sdk = { version = "0.33", default-features = false, features = ["trace", "logs"], optional = true } opentelemetry-appender-tracing = { version = "0.33", default-features = false, optional = true } -futures-executor = { version = "0.3", default-features = false, features = ["std"], optional = true } opentelemetry-otlp = { version = "0.33", default-features = false, features = [ "trace", "logs", @@ -55,7 +54,6 @@ tracing = { version = "0.1", default-features = false, features = ["max_level_of [features] otel = [ - "dep:futures-executor", "dep:opentelemetry", "dep:opentelemetry_sdk", "dep:opentelemetry-appender-tracing", diff --git a/crates/codspeed/src/benchmark_scope.rs b/crates/codspeed/src/benchmark_scope.rs index 83bf41c9..45bad76e 100644 --- a/crates/codspeed/src/benchmark_scope.rs +++ b/crates/codspeed/src/benchmark_scope.rs @@ -12,9 +12,7 @@ pub fn enter(uri: &str) { let _ = uri; } -/// Marks the end of the current benchmark and exports the spans held so far. -/// -/// Call it outside the measured section, before the benchmark is reported as executed. +/// Marks the end of the current benchmark. pub fn exit() { #[cfg(feature = "otel")] crate::otel::exit_benchmark(); diff --git a/crates/codspeed/src/otel.rs b/crates/codspeed/src/otel.rs index 310fa629..631bb4cd 100644 --- a/crates/codspeed/src/otel.rs +++ b/crates/codspeed/src/otel.rs @@ -3,16 +3,15 @@ //! //! Everything is a no-op unless [`ENDPOINT_ENV`] is set. //! -//! Ended spans and log records are held in memory and only exported when a benchmark scope exits -//! (see [`crate::benchmark_scope`]) or the exporters shut down, so no export runs while a benchmark -//! is measured. +//! Ended spans and log records are exported in batches from a background thread. Telemetry is +//! collected in a run of its own, whose performance is not measured, so exporting while a +//! benchmark runs doesn't matter. //! //! With instrument hooks, spans and log records are timestamped with the monotonic clock of the //! benchmark markers, shifted by [`InstrumentHooks::monotonic_offset`] to read as wall clock //! times. The hooks send that offset to the runner. -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex, OnceLock, PoisonError, RwLock}; +use std::sync::{Arc, PoisonError, RwLock}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use opentelemetry::logs::{AnyValue, LogRecord as _}; @@ -21,8 +20,10 @@ use opentelemetry::{Context, InstrumentationScope, KeyValue}; use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge; use opentelemetry_otlp::{LogExporter, SpanExporter, WithExportConfig}; use opentelemetry_sdk::error::OTelSdkResult; -use opentelemetry_sdk::logs::{LogBatch, LogProcessor, SdkLogRecord, SdkLoggerProvider}; -use opentelemetry_sdk::trace::{SdkTracerProvider, Span, SpanData, SpanProcessor}; +use opentelemetry_sdk::logs::{BatchLogProcessor, LogProcessor, SdkLogRecord, SdkLoggerProvider}; +use opentelemetry_sdk::trace::{ + BatchSpanProcessor, SdkTracerProvider, Span, SpanData, SpanProcessor, +}; use opentelemetry_sdk::Resource; use tracing::Subscriber; use tracing_subscriber::filter::{filter_fn, FilterExt}; @@ -42,17 +43,16 @@ const DEFAULT_FILTER: &str = "info"; /// or the record was emitted. pub const BENCHMARK_URI_ATTRIBUTE: &str = "codspeed.benchmark.uri"; -/// Ended spans, and log records, held in memory between two exports, past which they are dropped. -/// Bounds the memory used by a benchmark emitting on every iteration. -const MAX_HELD: usize = 1_000_000; +/// Ended spans, and log records, queued for export, past which they are dropped. Larger than the +/// SDK default of 2048, which a benchmark emitting on every iteration fills faster than the +/// exporter drains it. +const MAX_QUEUE_SIZE: usize = 1 << 16; /// Spans or log records sent per export request. const EXPORT_BATCH_SIZE: usize = 4_096; static CURRENT_BENCHMARK: RwLock>> = RwLock::new(None); -static PROVIDERS: OnceLock = OnceLock::new(); - /// Spans emitted by the exporter itself would be exported in turn, in an endless loop. const EXPORTER_FILTER_DIRECTIVES: &[&str] = &[ "h2=off", @@ -72,17 +72,6 @@ struct Providers { logger: SdkLoggerProvider, } -impl Providers { - fn flush(&self) { - if let Err(error) = self.tracer.force_flush() { - eprintln!("Warning: failed to export OpenTelemetry spans: {error}"); - } - if let Err(error) = self.logger.force_flush() { - eprintln!("Warning: failed to export OpenTelemetry logs: {error}"); - } - } -} - /// Flushes and shuts down the exporters when dropped. #[must_use = "dropping the guard shuts the exporters down"] pub struct OtelGuard { @@ -102,7 +91,7 @@ impl Drop for OtelGuard { /// Builds a layer exporting spans and events to the receiver at /// [`ENDPOINT_ENV`], to add to a subscriber of your own. Returns `None` when -/// the variable is unset. +/// the variable is unset or `tracing` is compiled out. /// /// Events are exported as log records carrying the trace and span IDs of the span they are emitted /// in, rather than as span events. @@ -113,7 +102,6 @@ where S: Subscriber + for<'span> LookupSpan<'span>, { let providers = build_providers()?; - let _ = PROVIDERS.set(providers.clone()); Some((build_layer(&providers), OtelGuard { providers })) } @@ -132,7 +120,8 @@ where /// Installs a global `tracing` subscriber exporting spans and events to the receiver at /// [`ENDPOINT_ENV`], and registers its provider as the global OpenTelemetry tracer provider. /// -/// Returns `None` when the variable is unset or a global subscriber is already set. +/// Returns `None` when the variable is unset, `tracing` is compiled out, or a global subscriber is +/// already set. pub fn init() -> Option { if tracing::dispatcher::has_been_set() { return None; @@ -148,6 +137,10 @@ pub fn init() -> Option { } fn build_providers() -> Option { + // `cargo-codspeed` compiles `tracing` out of measured builds: nothing would be exported. + if tracing::level_filters::STATIC_MAX_LEVEL == tracing::level_filters::LevelFilter::OFF { + return None; + } let endpoint = std::env::var(ENDPOINT_ENV) .ok() .filter(|endpoint| !endpoint.is_empty())?; @@ -176,15 +169,31 @@ fn build_providers() -> Option { let resource = Resource::builder() .with_service_name(service_name()) .build(); + let span_processor = BatchSpanProcessor::builder(span_exporter) + .with_batch_config( + opentelemetry_sdk::trace::BatchConfigBuilder::default() + .with_max_queue_size(MAX_QUEUE_SIZE) + .with_max_export_batch_size(EXPORT_BATCH_SIZE) + .build(), + ) + .build(); + let log_processor = BatchLogProcessor::builder(log_exporter) + .with_batch_config( + opentelemetry_sdk::logs::BatchConfigBuilder::default() + .with_max_queue_size(MAX_QUEUE_SIZE) + .with_max_export_batch_size(EXPORT_BATCH_SIZE) + .build(), + ) + .build(); Some(Providers { tracer: SdkTracerProvider::builder() .with_span_processor(BenchmarkLabelProcessor) - .with_span_processor(HoldingSpanProcessor::new(span_exporter, clock)) + .with_span_processor(ClockSpanProcessor::new(span_processor, clock)) .with_resource(resource.clone()) .build(), logger: SdkLoggerProvider::builder() .with_log_processor(BenchmarkLabelProcessor) - .with_log_processor(HoldingLogProcessor::new(log_exporter, clock)) + .with_log_processor(ClockLogProcessor::new(log_processor, clock)) .with_resource(resource) .build(), }) @@ -239,10 +248,6 @@ pub(crate) fn exit_benchmark() { *CURRENT_BENCHMARK .write() .unwrap_or_else(PoisonError::into_inner) = None; - - if let Some(providers) = PROVIDERS.get() { - providers.flush(); - } } fn current_benchmark() -> Option> { @@ -291,79 +296,23 @@ impl LogProcessor for BenchmarkLabelProcessor { } } -/// Items held until a flush, up to [`MAX_HELD`]. +/// Moves the times of ended spans to `clock`, before handing them to `inner`. #[derive(Debug)] -struct Held { - items: Mutex>, - dropped: AtomicUsize, -} - -impl Held { - fn new() -> Self { - Self { - items: Mutex::new(Vec::new()), - dropped: AtomicUsize::new(0), - } - } - - fn push(&self, item: T) { - let mut items = self.items.lock().unwrap_or_else(PoisonError::into_inner); - if items.len() < MAX_HELD { - items.push(item); - } else { - self.dropped.fetch_add(1, Ordering::Relaxed); - } - } - - /// Takes the held items, warning about those dropped since the last take. - fn take(&self, kind: &str) -> Vec { - let items = std::mem::take(&mut *self.items.lock().unwrap_or_else(PoisonError::into_inner)); - let dropped = self.dropped.swap(0, Ordering::Relaxed); - if dropped > 0 { - eprintln!( - "Warning: dropped {dropped} OpenTelemetry {kind}, past the {MAX_HELD} held per \ - benchmark" - ); - } - items - } -} - -/// Exports in batches, carrying on past a failed batch. -fn export_in_batches( - items: Vec, - mut export: impl FnMut(Vec) -> OTelSdkResult, -) -> OTelSdkResult { - let mut result = Ok(()); - let mut items = items.into_iter().peekable(); - while items.peek().is_some() { - if let Err(error) = export(items.by_ref().take(EXPORT_BATCH_SIZE).collect()) { - result = Err(error); - } - } - result -} - -/// Holds the ended spans until a flush, after moving their times to `clock`. -#[derive(Debug)] -struct HoldingSpanProcessor { - exporter: E, +struct ClockSpanProcessor

{ + inner: P, clock: Option, - spans: Held, } -impl HoldingSpanProcessor { - fn new(exporter: E, clock: Option) -> Self { - Self { - exporter, - clock, - spans: Held::new(), - } +impl

ClockSpanProcessor

{ + fn new(inner: P, clock: Option) -> Self { + Self { inner, clock } } } -impl SpanProcessor for HoldingSpanProcessor { - fn on_start(&self, _span: &mut Span, _cx: &Context) {} +impl SpanProcessor for ClockSpanProcessor

{ + fn on_start(&self, span: &mut Span, cx: &Context) { + self.inner.on_start(span, cx); + } fn on_end(&self, mut span: SpanData) { // Spans end as they are closed, so their times are recent. @@ -379,44 +328,36 @@ impl SpanProcessor for HoldingSpanPro .chain(event_times), ); } - self.spans.push(span); + self.inner.on_end(span); } fn force_flush(&self) -> OTelSdkResult { - export_in_batches(self.spans.take("spans"), |batch| { - futures_executor::block_on(self.exporter.export(batch)) - }) + self.inner.force_flush() } fn shutdown_with_timeout(&self, timeout: Duration) -> OTelSdkResult { - self.force_flush() - .and(self.exporter.shutdown_with_timeout(timeout)) + self.inner.shutdown_with_timeout(timeout) } fn set_resource(&mut self, resource: &Resource) { - self.exporter.set_resource(resource); + self.inner.set_resource(resource); } } -/// Holds the emitted log records until a flush, after timestamping them with `clock`. +/// Timestamps the emitted log records with `clock`, before handing them to `inner`. #[derive(Debug)] -struct HoldingLogProcessor { - exporter: E, +struct ClockLogProcessor

{ + inner: P, clock: Option, - records: Held<(SdkLogRecord, InstrumentationScope)>, } -impl HoldingLogProcessor { - fn new(exporter: E, clock: Option) -> Self { - Self { - exporter, - clock, - records: Held::new(), - } +impl

ClockLogProcessor

{ + fn new(inner: P, clock: Option) -> Self { + Self { inner, clock } } } -impl LogProcessor for HoldingLogProcessor { +impl LogProcessor for ClockLogProcessor

{ fn emit(&self, record: &mut SdkLogRecord, instrumentation: &InstrumentationScope) { // Records are emitted as their event happens. if let Some(clock) = &self.clock { @@ -424,26 +365,19 @@ impl LogProcessor for HoldingLogProcess record.set_timestamp(now); record.set_observed_timestamp(now); } - self.records.push((record.clone(), instrumentation.clone())); + self.inner.emit(record, instrumentation); } fn force_flush(&self) -> OTelSdkResult { - export_in_batches(self.records.take("log records"), |batch| { - let batch = batch - .iter() - .map(|(record, instrumentation)| (record, instrumentation)) - .collect::>(); - futures_executor::block_on(self.exporter.export(LogBatch::new(&batch))) - }) + self.inner.force_flush() } fn shutdown_with_timeout(&self, timeout: Duration) -> OTelSdkResult { - self.force_flush() - .and(self.exporter.shutdown_with_timeout(timeout)) + self.inner.shutdown_with_timeout(timeout) } fn set_resource(&mut self, resource: &Resource) { - self.exporter.set_resource(resource); + self.inner.set_resource(resource); } } @@ -480,6 +414,9 @@ mod tests { use super::*; use opentelemetry::logs::{Logger as _, LoggerProvider as _}; use opentelemetry::trace::Tracer as _; + use opentelemetry_sdk::logs::{LogBatch, SimpleLogProcessor}; + use opentelemetry_sdk::trace::SimpleSpanProcessor; + use std::sync::Mutex; /// Records the exported spans, and the size of each batch. #[derive(Debug, Clone, Default)] @@ -585,42 +522,6 @@ mod tests { assert_eq!(log_labels, expected); } - #[test] - fn holds_spans_until_flushed() { - let exporter = RecordingExporter::default(); - let provider = SdkTracerProvider::builder() - .with_span_processor(HoldingSpanProcessor::new(exporter.clone(), None)) - .build(); - let tracer = provider.tracer("test"); - - for _ in 0..=EXPORT_BATCH_SIZE { - tracer.start("span").end(); - } - assert!(exporter.0.lock().unwrap().is_empty()); - - provider.force_flush().unwrap(); - assert_eq!(exporter.0.lock().unwrap().len(), EXPORT_BATCH_SIZE + 1); - assert_eq!(*exporter.1.lock().unwrap(), [EXPORT_BATCH_SIZE, 1]); - } - - #[test] - fn holds_log_records_until_flushed() { - let exporter = RecordingLogExporter::default(); - let provider = SdkLoggerProvider::builder() - .with_log_processor(HoldingLogProcessor::new(exporter.clone(), None)) - .build(); - let logger = provider.logger("test"); - - for _ in 0..=EXPORT_BATCH_SIZE { - logger.emit(logger.create_log_record()); - } - assert!(exporter.0.lock().unwrap().is_empty()); - - provider.force_flush().unwrap(); - assert_eq!(exporter.0.lock().unwrap().len(), EXPORT_BATCH_SIZE + 1); - assert_eq!(*exporter.1.lock().unwrap(), [EXPORT_BATCH_SIZE, 1]); - } - #[test] fn shifts_times_by_their_distance_to_now() { let at = |secs| UNIX_EPOCH + Duration::from_secs(secs); @@ -633,14 +534,17 @@ mod tests { let clock = MonotonicClock { offset_ns: 1_000 }; let span_exporter = RecordingExporter::default(); let tracer_provider = SdkTracerProvider::builder() - .with_span_processor(HoldingSpanProcessor::new( - span_exporter.clone(), + .with_span_processor(ClockSpanProcessor::new( + SimpleSpanProcessor::new(span_exporter.clone()), Some(clock), )) .build(); let log_exporter = RecordingLogExporter::default(); let logger_provider = SdkLoggerProvider::builder() - .with_log_processor(HoldingLogProcessor::new(log_exporter.clone(), Some(clock))) + .with_log_processor(ClockLogProcessor::new( + SimpleLogProcessor::new(log_exporter.clone()), + Some(clock), + )) .build(); let logger = logger_provider.logger("test"); From 05a797fe1d8a2566336ce7ccbd9eae956821cb0b Mon Sep 17 00:00:00 2001 From: Luc Varoqui Date: Wed, 21 Oct 2026 12:36:32 +0200 Subject: [PATCH 10/10] feat(codspeed): clear the generated names of exported log records The OpenTelemetry log bridge sets the event name of every record to the name of its `tracing` callsite, which `tracing` generates as `event :` for an event given no `name:`, and `log event` for a record of the `log` crate. In the OpenTelemetry logs data model a record with an event name is an event, so every log line was exported as a named event. A log processor now clears these generated names, so that only the events named at their callsite keep one. It compares the name with the record's own location, leaving any other name as is. The bridge's `experimental_metadata_attributes` feature is enabled for that location: the `code.filepath`, `code.lineno` and `code.namespace` attributes, which keep it exported once the name is cleared. Refs COD-3862 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 12 +++++ crates/codspeed/Cargo.toml | 22 +++++++--- crates/codspeed/src/otel.rs | 87 +++++++++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3d75bd63..f7e0991f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1626,6 +1626,7 @@ dependencies = [ "opentelemetry", "tracing", "tracing-core", + "tracing-log", "tracing-subscriber", ] @@ -2569,6 +2570,17 @@ dependencies = [ "valuable", ] +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + [[package]] name = "tracing-opentelemetry" version = "0.34.0" diff --git a/crates/codspeed/Cargo.toml b/crates/codspeed/Cargo.toml index 6ea24bd1..2c307018 100644 --- a/crates/codspeed/Cargo.toml +++ b/crates/codspeed/Cargo.toml @@ -28,16 +28,26 @@ serde = { workspace = true } serde_json = { workspace = true } statrs = { version = "0.18.0", default-features = false } -opentelemetry = { version = "0.33", default-features = false, features = ["trace", "logs"], optional = true } -opentelemetry_sdk = { version = "0.33", default-features = false, features = ["trace", "logs"], optional = true } -opentelemetry-appender-tracing = { version = "0.33", default-features = false, optional = true } +opentelemetry = { version = "0.33", default-features = false, features = [ + "trace", + "logs", +], optional = true } +opentelemetry_sdk = { version = "0.33", default-features = false, features = [ + "trace", + "logs", +], optional = true } +opentelemetry-appender-tracing = { version = "0.33", default-features = false, features = [ + "experimental_metadata_attributes", +], optional = true } opentelemetry-otlp = { version = "0.33", default-features = false, features = [ "trace", "logs", "http-proto", "reqwest-blocking-client", ], optional = true } -tracing = { version = "0.1", default-features = false, features = ["std"], optional = true } +tracing = { version = "0.1", default-features = false, features = [ + "std", +], optional = true } tracing-opentelemetry = { version = "0.34", default-features = false, optional = true } tracing-subscriber = { version = "0.3", default-features = false, features = [ "std", @@ -50,7 +60,9 @@ lazy_static = { version = "1.1", optional = true } # Unused directly: `cargo-codspeed` sets this cfg when building benchmarks for a measurement mode, # so that features unification compiles the `tracing` spans and events of the whole build out. [target.'cfg(codspeed_tracing_off)'.dependencies] -tracing = { version = "0.1", default-features = false, features = ["max_level_off"] } +tracing = { version = "0.1", default-features = false, features = [ + "max_level_off", +] } [features] otel = [ diff --git a/crates/codspeed/src/otel.rs b/crates/codspeed/src/otel.rs index 631bb4cd..1105bc8b 100644 --- a/crates/codspeed/src/otel.rs +++ b/crates/codspeed/src/otel.rs @@ -193,6 +193,7 @@ fn build_providers() -> Option { .build(), logger: SdkLoggerProvider::builder() .with_log_processor(BenchmarkLabelProcessor) + .with_log_processor(GeneratedEventNameProcessor) .with_log_processor(ClockLogProcessor::new(log_processor, clock)) .with_resource(resource) .build(), @@ -296,6 +297,57 @@ impl LogProcessor for BenchmarkLabelProcessor { } } +/// Event name `tracing-log` gives every record of the `log` crate. +const LOG_EVENT_NAME: &str = "log event"; + +/// Clears the event names `tracing` generates for the events given none, so that only the +/// events named at their callsite are exported with a name. Their location stays in the +/// `code.*` attributes. +#[derive(Debug)] +struct GeneratedEventNameProcessor; + +impl LogProcessor for GeneratedEventNameProcessor { + fn emit(&self, record: &mut SdkLogRecord, _instrumentation: &InstrumentationScope) { + if has_generated_event_name(record) { + record.set_event_name(""); + } + } + + fn force_flush(&self) -> OTelSdkResult { + Ok(()) + } + + fn shutdown_with_timeout(&self, _timeout: Duration) -> OTelSdkResult { + Ok(()) + } +} + +/// Whether the event name is `log event`, or the `event :` that `tracing` generates +/// from the location of the record. +fn has_generated_event_name(record: &SdkLogRecord) -> bool { + let Some(name) = record.event_name() else { + return false; + }; + if name == LOG_EVENT_NAME { + return true; + } + let attribute = |key: &str| { + record + .attributes_iter() + .find(|(attribute_key, _)| attribute_key.as_str() == key) + .map(|(_, value)| value) + }; + let (Some(AnyValue::String(file)), Some(&AnyValue::Int(line))) = + (attribute("code.filepath"), attribute("code.lineno")) + else { + return false; + }; + name.strip_prefix("event ") + .and_then(|location| location.strip_prefix(file.as_str())) + .and_then(|location| location.strip_prefix(':')) + .is_some_and(|location| location.parse() == Ok(line)) +} + /// Moves the times of ended spans to `clock`, before handing them to `inner`. #[derive(Debug)] struct ClockSpanProcessor

{ @@ -601,4 +653,39 @@ mod tests { assert_eq!(trace_context.trace_id, span.span_context.trace_id()); assert_eq!(trace_context.span_id, span.span_context.span_id()); } + + #[test] + fn clears_the_event_names_generated_by_tracing() { + let log_exporter = RecordingLogExporter::default(); + let providers = Providers { + tracer: SdkTracerProvider::builder().build(), + logger: SdkLoggerProvider::builder() + .with_log_processor(GeneratedEventNameProcessor) + .with_simple_exporter(log_exporter.clone()) + .build(), + }; + let subscriber = tracing_subscriber::registry().with(build_layer(&providers)); + + tracing::subscriber::with_default(subscriber, || { + tracing::info!("unnamed"); + tracing::info!(name: "cache.miss", "named"); + }); + + let records = log_exporter.0.lock().unwrap(); + let names = records + .iter() + .map(|record| (log_body(record), record.event_name())) + .collect::>(); + assert_eq!( + names, + [ + ("unnamed".to_owned(), Some("")), + ("named".to_owned(), Some("cache.miss")), + ] + ); + let has_file = records[0].attributes_iter().any(|(key, value)| { + key.as_str() == "code.filepath" && value == &AnyValue::from(file!()) + }); + assert!(has_file); + } }