From 98818c706dce832a656a7181eb9e32a757a176b5 Mon Sep 17 00:00:00 2001 From: Luc Varoqui Date: Wed, 14 Oct 2026 12:09:26 +0200 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 74886838f85048c3228e540cef8db2c12df4bdea Mon Sep 17 00:00:00 2001 From: Luc Varoqui Date: Fri, 16 Oct 2026 13:26:41 +0200 Subject: [PATCH 6/7] 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, along with `MONOTONIC_OFFSET_ATTRIBUTE`, the OpenTelemetry resource attribute name, taken from the header at compile time. The bindings module is now compiled without instrument hooks too, for its constants; its functions are then never called. Refs COD-3862 Co-Authored-By: Claude Opus 5.5 --- crates/codspeed/instrument-hooks | 2 +- .../codspeed/src/instrument_hooks/bindings.rs | 5 +++ crates/codspeed/src/instrument_hooks/mod.rs | 37 ++++++++++++++++++- .../src/instrument_hooks/update-bindings.sh | 1 + 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/crates/codspeed/instrument-hooks b/crates/codspeed/instrument-hooks index e998b76e..6bb632a3 160000 --- a/crates/codspeed/instrument-hooks +++ b/crates/codspeed/instrument-hooks @@ -1 +1 @@ -Subproject commit e998b76edcc2945d7f98c5f13c38045d44de26ae +Subproject commit 6bb632a383634a4b30d46e7e1a3236df1862dda4 diff --git a/crates/codspeed/src/instrument_hooks/bindings.rs b/crates/codspeed/src/instrument_hooks/bindings.rs index 5d4768ae..1dab966f 100644 --- a/crates/codspeed/src/instrument_hooks/bindings.rs +++ b/crates/codspeed/src/instrument_hooks/bindings.rs @@ -4,6 +4,8 @@ pub const MARKER_TYPE_SAMPLE_START: u32 = 0; pub const MARKER_TYPE_SAMPLE_END: u32 = 1; pub const MARKER_TYPE_BENCHMARK_START: u32 = 2; pub const MARKER_TYPE_BENCHMARK_END: u32 = 3; +pub const INSTRUMENT_HOOKS_MONOTONIC_OFFSET_ATTRIBUTE: &[u8; 35] = + b"codspeed.clock.monotonic_offset_ns\0"; #[repr(C)] #[derive(Debug, Copy, Clone)] pub struct InstrumentHooks { @@ -56,6 +58,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..3eac3ee5 100644 --- a/crates/codspeed/src/instrument_hooks/mod.rs +++ b/crates/codspeed/src/instrument_hooks/mod.rs @@ -1,4 +1,4 @@ -#[cfg(use_instrument_hooks)] +// Also compiled without instrument hooks for its constants; its functions are then never called. mod ffi; #[cfg(use_instrument_hooks)] @@ -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, @@ -273,6 +284,17 @@ mod other_impl { } } +/// OpenTelemetry resource attribute holding [`InstrumentHooks::monotonic_offset`] when spans and +/// logs are timestamped with the shifted monotonic clock. +pub const MONOTONIC_OFFSET_ATTRIBUTE: &str = + match std::ffi::CStr::from_bytes_with_nul(ffi::INSTRUMENT_HOOKS_MONOTONIC_OFFSET_ATTRIBUTE) { + Ok(name) => match name.to_str() { + Ok(name) => name, + Err(_) => panic!("the monotonic offset attribute is not UTF-8"), + }, + Err(_) => panic!("the monotonic offset attribute is not a C string"), + }; + #[cfg(use_instrument_hooks)] pub use linux_impl::InstrumentHooks; @@ -312,4 +334,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); + } } diff --git a/crates/codspeed/src/instrument_hooks/update-bindings.sh b/crates/codspeed/src/instrument_hooks/update-bindings.sh index 60d3ab41..4f271082 100755 --- a/crates/codspeed/src/instrument_hooks/update-bindings.sh +++ b/crates/codspeed/src/instrument_hooks/update-bindings.sh @@ -8,4 +8,5 @@ bindgen "$SCRIPT_DIR/../../instrument-hooks/includes/core.h" \ --rust-target 1.74 \ --allowlist-function "instrument_hooks_.*" \ --allowlist-var "MARKER_TYPE_.*" \ + --allowlist-var "INSTRUMENT_HOOKS_.*" \ --allowlist-type "instrument_hooks_feature_t" From 2765419a3117b358f4ececd75c54b0aea53ab82f Mon Sep 17 00:00:00 2001 From: Luc Varoqui Date: Fri, 16 Oct 2026 13:26:42 +0200 Subject: [PATCH 7/7] 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 offset is published as the `codspeed.clock.monotonic_offset_ns` resource attribute, so subtracting it gives back the clock of the markers while standard tools still read wall clock times. Without instrument hooks, times stay wall clock times and the attribute is absent. Refs COD-3862 Co-Authored-By: Claude Opus 5.5 --- crates/codspeed/src/otel.rs | 141 ++++++++++++++++++++++++++++++++---- 1 file changed, 128 insertions(+), 13 deletions(-) diff --git a/crates/codspeed/src/otel.rs b/crates/codspeed/src/otel.rs index 7c822bfe..3aee9969 100644 --- a/crates/codspeed/src/otel.rs +++ b/crates/codspeed/src/otel.rs @@ -6,10 +6,13 @@ //! 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 [`MONOTONIC_OFFSET_ATTRIBUTE`] to read as wall clock times. 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 +27,9 @@ use tracing::Subscriber; use tracing_subscriber::filter::{filter_fn, FilterExt}; use tracing_subscriber::{layer::SubscriberExt, registry::LookupSpan, EnvFilter, Layer}; +use crate::instrument_hooks::InstrumentHooks; +pub use crate::instrument_hooks::MONOTONIC_OFFSET_ATTRIBUTE; + /// Base URL of the OTLP/HTTP receiver, e.g. `http://localhost:4318`. pub const ENDPOINT_ENV: &str = "CODSPEED_OTEL_ENDPOINT"; @@ -166,23 +172,66 @@ fn build_providers() -> Option { } }; - let resource = Resource::builder() - .with_service_name(service_name()) - .build(); + let clock = MonotonicClock::new(); + let mut resource = Resource::builder().with_service_name(service_name()); + if let Some(clock) = clock { + resource = + resource.with_attribute(KeyValue::new(MONOTONIC_OFFSET_ATTRIBUTE, clock.offset_ns)); + } + let resource = resource.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 +347,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 +368,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 +401,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 +421,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 +592,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 +610,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 +624,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();