Skip to content

enhancement(context): allow mutating tagsets on metric contexts#1270

Draft
tobz wants to merge 1 commit intotobz/core-data-model-sharedtagset-to-tagsetfrom
tobz/mutable-tagset-metric-context
Draft

enhancement(context): allow mutating tagsets on metric contexts#1270
tobz wants to merge 1 commit intotobz/core-data-model-sharedtagset-to-tagsetfrom
tobz/mutable-tagset-metric-context

Conversation

@tobz
Copy link
Copy Markdown
Member

@tobz tobz commented Mar 27, 2026

Summary

Change Type

  • Bug fix
  • New feature
  • Non-functional (chore, refactoring, docs)
  • Performance

How did you test this PR?

References

Copilot AI review requested due to automatic review settings March 27, 2026 01:03
@dd-octo-sts dd-octo-sts bot added area/core Core functionality, event model, etc. area/components Sources, transforms, and destinations. transform/host-tags Host Tags synchronous transform. labels Mar 27, 2026
Copy link
Copy Markdown
Member Author

tobz commented Mar 27, 2026

Warning

This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
Learn more

This stack of pull requests is managed by Graphite. Learn more about stacking.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds copy-on-write tag mutation support to Context so metric transforms can enrich tags without rebuilding contexts, and refactors the host-tags transform to use the new API.

Changes:

  • Added Context::with_tags_mut / Context::with_origin_tags_mut with COW semantics and automatic key rehashing.
  • Updated host-tags enrichment to mutate metric contexts in place instead of resolving a new context.
  • Consolidated/adjusted TagSet property tests and imports.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

File Description
lib/saluki-context/src/tags/tagset/owned.rs Test module cleanup and minor proptest strategy adjustments/renames.
lib/saluki-context/src/context.rs Introduces mutable tag/origin-tag mutation APIs and adds unit tests for COW + rehash behavior.
lib/saluki-components/src/transforms/host_tags/mod.rs Removes resolver-based context rebuilding and switches to in-place tag mutation for host-tag enrichment.

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

if let Some(context) = resolver.resolve_with_origin_tags(metric.context().name(), tags, origin_tags) {
*metric.context_mut() = context;
}
metric.context_mut().with_tags_mut(|tags| tags.merge_shared(host_tags));
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

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

merge_shared explicitly does not avoid duplicate tags, so this enrichment can introduce duplicates when a metric already has one of the host tags (or if enrichment is applied more than once). This changes behavior vs the previous implementation (which deduped via collect::<TagSet>()) and can inflate tag lists / affect downstream hashing and serialization. Consider using merge_missing_shared (or otherwise deduping) when merging host tags into the metric context.

Suggested change
metric.context_mut().with_tags_mut(|tags| tags.merge_shared(host_tags));
metric
.context_mut()
.with_tags_mut(|tags| tags.merge_missing_shared(host_tags));

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The feedback is valid, but in this case, we actually don't care about duplicates:

  • host tags are incredibly unlikely to clash with actual instrumented tags
  • we still dedupe during encoding to allow for more efficient chaining earlier in the pipeline

Comment on lines 44 to 61
@@ -65,19 +54,8 @@ impl SynchronousTransformBuilder for HostTagsConfiguration {
.map(Tag::from)
.collect::<SharedTagSet>();

let context_string_interner_size =
NonZeroUsize::new(self.host_tags_context_string_interner_bytes.as_u64() as usize)
.ok_or_else(|| generic_error!("host_tags_context_string_interner_bytes must be greater than 0"))
.unwrap();
let context_resolver =
ContextResolverBuilder::from_name(format!("{}/host_tags/primary", context.component_id()))
.expect("resolver name is not empty")
.with_interner_capacity_bytes(context_string_interner_size)
.with_idle_context_expiration(Duration::from_secs(30))
.build();
Ok(Box::new(HostTagsEnrichment {
start: Instant::now(),
context_resolver: Some(context_resolver),
expected_tags_duration: Duration::from_secs(self.expected_tags_duration),
host_tags: Some(host_tags),
}))
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

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

This transform used to rebuild contexts via a ContextResolver, which provides interning + caching of resolved contexts. With the new in-place with_tags_mut approach, any metric contexts that are shared (common with ContextResolver-produced contexts) will be detached via Arc::make_mut, potentially producing one enriched ContextInner per metric and losing cross-metric sharing. If host-tag enrichment is expected to touch many metrics, consider retaining a resolver/cache for enriched contexts or another strategy that preserves sharing.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Host Tags transform generally only operates for a few minutes after startup, so the reduction in sharing due to creating an off-shoot Context, and not caching that via something like ContextResolver, is pretty small and not sustained over time.

However, it could be useful to add something in ContextResolver where we support modifying how the input pieces are used to build the resulting context. In order to actually make use of ContextResolver in this scenario, we'd want to feed it to the incoming metric context untouched, and then generate a new context (which is the one we'd cache) with the additional host tags bolted on... such that we didn't have to do the chaining/additional host tags for subsequent lookups.

We could do a cache, too, but ContextResolver already wraps up most of the things we want here -- cache config, expiration, telemetry, etc -- in a nice package that's specific to contexts and so it'd be nicer to somehow add it there than to just use a cache naïvely.

Comment on lines +118 to +138
/// Mutates the instrumented tags of this context via a closure.
///
/// Uses copy-on-write semantics: if this context shares its inner data with other clones, the
/// inner data is cloned first so that mutations do not affect other holders. If this context is
/// the sole owner, the mutation happens in place.
///
/// The context key is automatically recomputed after the closure returns.
pub fn with_tags_mut(&mut self, f: impl FnOnce(&mut TagSet)) {
self.mutate_inner(|inner| f(&mut inner.tags));
}

/// Mutates the origin tags of this context via a closure.
///
/// Uses copy-on-write semantics: if this context shares its inner data with other clones, the
/// inner data is cloned first so that mutations do not affect other holders. If this context is
/// the sole owner, the mutation happens in place.
///
/// The context key is automatically recomputed after the closure returns.
pub fn with_origin_tags_mut(&mut self, f: impl FnOnce(&mut TagSet)) {
self.mutate_inner(|inner| f(&mut inner.origin_tags));
}
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

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

The new with_tags_mut/with_origin_tags_mut methods mutate fields that contribute to Context's Eq/Hash (via the recomputed key). Please document that callers must not mutate a Context while it is being used as a key in a HashMap/HashSet (or otherwise relied on for stable hashing), as this would break those data structures' invariants.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is true, except for the fact that normal collections (certainly the above ones from stdlib) don't actually allow modifying the key (or value, if HashSet) after insertion... and we're not doing interior mutability tricks here so we're not subverting that, and thus this isn't actually a problem for us.

Comment on lines +140 to +145
fn mutate_inner(&mut self, f: impl FnOnce(&mut ContextInner)) {
let inner = Arc::make_mut(&mut self.inner);
f(inner);
let (key, _) = hash_context(&inner.name, &inner.tags, &inner.origin_tags);
inner.key = key;
}
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

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

mutate_inner recomputes the context key via hash_context, which allocates a new PrehashedHashSet each call. If with_tags_mut is used in per-metric hot paths (e.g. enrichment), this extra allocation can be a noticeable cost. Consider using the non-allocating hash_context_with_seen with a reusable seen set (or another allocation-free rehash strategy) to avoid per-mutation allocations.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is good advice, and we might consider a thread-local seen that can be reused, which would also help us clean up the different versions of the function, the callsites using them which carry around their own PrehashedHashSet, and so on.

Good for a follow-up PR.

@pr-commenter
Copy link
Copy Markdown

pr-commenter bot commented Mar 27, 2026

Binary Size Analysis (Agent Data Plane)

Target: b0ed855 (baseline) vs 88413de (comparison) diff
Analysis Type: Stripped binaries (debug symbols excluded)
Baseline Size: 26.18 MiB
Comparison Size: 26.19 MiB
Size Change: +14.33 KiB (+0.05%)
Pass/Fail Threshold: +5%
Result: PASSED ✅

Changes by Module

Module File Size Symbols
saluki_context::tags::tagset +9.30 KiB 19
saluki_components::transforms::host_tags -4.56 KiB 8
core +4.13 KiB 160
agent_data_plane::state::metrics +3.99 KiB 3
figment -3.11 KiB 17
saluki_context::hash::hash_context +2.87 KiB 1
axum +2.66 KiB 2
saluki_context::hash::hash_context_with_seen -1.99 KiB 1
saluki_components::destinations::dsd_stats +1.99 KiB 1
saluki_components::sources::otlp +1.74 KiB 5
saluki_context::resolver::ContextResolver -1.64 KiB 4
saluki_env::workload::metadata -1.54 KiB 1
saluki_context::resolver::TagsResolver -1.21 KiB 8
agent_data_plane::cli::run -1.04 KiB 1
smallvec +975 B 10
saluki_env::workload::collectors +946 B 7
saluki_env::workload::stores -811 B 2
saluki_context::context::Context -797 B 3
saluki_components::transforms::apm_stats +793 B 4
saluki_env::workload::aggregator +765 B 1

Detailed Symbol Changes

    FILE SIZE        VM SIZE    
 --------------  -------------- 
  [NEW] +22.7Ki  [NEW] +22.6Ki    saluki_components::sources::otlp::logs::transform::transform_log_record::h510ca1204d3ade65
  [NEW] +18.5Ki  [NEW] +18.3Ki    _<saluki_components::transforms::host_tags::HostTagsConfiguration as saluki_core::components::transforms::builder::SynchronousTransformBuilder>::build::_{{closure}}::h6032f81f6d4dd9d0
  +0.7% +16.9Ki  +0.8% +15.0Ki    [762 Others]
  [NEW] +10.6Ki  [NEW] +10.5Ki    <saluki_core::data_model::event::Event as core::clone::Clone>::clone.10057
  [NEW] +5.52Ki  [NEW] +5.38Ki    <hickory_proto::rr::record_data::RData as core::clone::Clone>::clone.8514
  [NEW] +5.13Ki  [NEW] +5.00Ki    <hickory_proto::rr::record_data::RData as core::clone::Clone>::clone.11201
  [NEW] +4.71Ki  [NEW] +4.59Ki    <rustls::error::Error as core::clone::Clone>::clone.10035
  [NEW] +4.71Ki  [NEW] +4.59Ki    <rustls::error::Error as core::clone::Clone>::clone.11216
  [NEW] +3.87Ki  [NEW] +3.76Ki    saluki_components::common::otlp::attributes::tags_from_attributes::h510bddeea173bf1d
  [NEW] +3.83Ki  [NEW] +3.71Ki    <webpki::error::Error as core::fmt::Debug>::fmt.11394
  [NEW] +3.70Ki  [NEW] +3.60Ki    saluki_context::resolver::TagsResolver::create_tag_set::h85bfa90ac5da346a
  [DEL] -3.83Ki  [DEL] -3.71Ki    <webpki::error::Error as core::fmt::Debug>::fmt.11397
  [DEL] -3.94Ki  [DEL] -3.83Ki    saluki_components::common::otlp::attributes::tags_from_attributes::h2b53e1f4f1fdf965
  [DEL] -4.71Ki  [DEL] -4.59Ki    <rustls::error::Error as core::clone::Clone>::clone.11219
  [DEL] -4.71Ki  [DEL] -4.59Ki    <rustls::error::Error as core::clone::Clone>::clone.10041
  [DEL] -5.13Ki  [DEL] -5.00Ki    <hickory_proto::rr::record_data::RData as core::clone::Clone>::clone.11204
  [DEL] -5.16Ki  [DEL] -4.96Ki    _<saluki_components::transforms::host_tags::HostTagsEnrichment as saluki_core::components::transforms::SynchronousTransform>::transform_buffer::h8298bc763a5b1ed2
  [DEL] -5.52Ki  [DEL] -5.38Ki    <hickory_proto::rr::record_data::RData as core::clone::Clone>::clone.8520
  [DEL] -10.7Ki  [DEL] -10.6Ki    <saluki_core::data_model::event::Event as core::clone::Clone>::clone.10063
  [DEL] -19.3Ki  [DEL] -19.1Ki    _<saluki_components::transforms::host_tags::HostTagsConfiguration as saluki_core::components::transforms::builder::SynchronousTransformBuilder>::build::_{{closure}}::h51276be7dee73d6d
  [DEL] -22.9Ki  [DEL] -22.8Ki    saluki_components::sources::otlp::logs::transform::transform_log_record::h1731e541914d182a
  +0.1% +14.3Ki  +0.1% +12.5Ki    TOTAL

@pr-commenter
Copy link
Copy Markdown

pr-commenter bot commented Mar 27, 2026

Regression Detector (Agent Data Plane)

Regression Detector Results

Run ID: 7e4eb572-6543-455d-b630-ec2ff0a4350d

Baseline: b0ed855
Comparison: 88413de
Diff

Optimization Goals: ✅ No significant changes detected

Experiments ignored for regressions

Regressions in experiments with settings containing erratic: true are ignored.

perf experiment goal Δ mean % Δ mean % CI trials links
otlp_ingest_logs_5mb_cpu % cpu utilization +2.26 [-2.70, +7.22] 1 (metrics) (profiles) (logs)
otlp_ingest_logs_5mb_throughput ingress throughput -0.02 [-0.15, +0.10] 1 (metrics) (profiles) (logs)
otlp_ingest_logs_5mb_memory memory utilization -3.53 [-3.99, -3.06] 1 (metrics) (profiles) (logs)

Fine details of change detection per experiment

perf experiment goal Δ mean % Δ mean % CI trials links
otlp_ingest_metrics_5mb_cpu % cpu utilization +5.82 [-3.22, +14.86] 1 (metrics) (profiles) (logs)
otlp_ingest_traces_ottl_transform_5mb_cpu % cpu utilization +3.42 [+1.18, +5.66] 1 (metrics) (profiles) (logs)
dsd_uds_1mb_3k_contexts_cpu % cpu utilization +2.96 [-52.33, +58.25] 1 (metrics) (profiles) (logs)
otlp_ingest_logs_5mb_cpu % cpu utilization +2.26 [-2.70, +7.22] 1 (metrics) (profiles) (logs)
otlp_ingest_traces_ottl_filtering_5mb_cpu % cpu utilization +1.43 [-0.98, +3.84] 1 (metrics) (profiles) (logs)
quality_gates_rss_dsd_ultraheavy memory utilization +0.98 [+0.84, +1.11] 1 (metrics) (profiles) (logs)
dsd_uds_100mb_3k_contexts_cpu % cpu utilization +0.59 [-5.61, +6.80] 1 (metrics) (profiles) (logs)
dsd_uds_100mb_3k_contexts_memory memory utilization +0.52 [+0.33, +0.70] 1 (metrics) (profiles) (logs)
dsd_uds_512kb_3k_contexts_memory memory utilization +0.39 [+0.23, +0.56] 1 (metrics) (profiles) (logs)
quality_gates_rss_dsd_medium memory utilization +0.33 [+0.13, +0.52] 1 (metrics) (profiles) (logs)
quality_gates_rss_idle memory utilization +0.30 [+0.26, +0.34] 1 (metrics) (profiles) (logs)
quality_gates_rss_dsd_heavy memory utilization +0.30 [+0.16, +0.44] 1 (metrics) (profiles) (logs)
dsd_uds_10mb_3k_contexts_memory memory utilization +0.22 [+0.04, +0.41] 1 (metrics) (profiles) (logs)
quality_gates_rss_dsd_low memory utilization +0.17 [-0.03, +0.37] 1 (metrics) (profiles) (logs)
otlp_ingest_metrics_5mb_throughput ingress throughput +0.02 [-0.10, +0.13] 1 (metrics) (profiles) (logs)
dsd_uds_10mb_3k_contexts_throughput ingress throughput +0.00 [-0.14, +0.15] 1 (metrics) (profiles) (logs)
dsd_uds_512kb_3k_contexts_throughput ingress throughput +0.00 [-0.05, +0.05] 1 (metrics) (profiles) (logs)
otlp_ingest_traces_ottl_transform_5mb_throughput ingress throughput +0.00 [-0.02, +0.02] 1 (metrics) (profiles) (logs)
dsd_uds_1mb_3k_contexts_throughput ingress throughput -0.00 [-0.06, +0.06] 1 (metrics) (profiles) (logs)
otlp_ingest_traces_5mb_throughput ingress throughput -0.00 [-0.02, +0.02] 1 (metrics) (profiles) (logs)
otlp_ingest_traces_ottl_filtering_5mb_throughput ingress throughput -0.00 [-0.02, +0.02] 1 (metrics) (profiles) (logs)
dsd_uds_100mb_3k_contexts_throughput ingress throughput -0.01 [-0.04, +0.02] 1 (metrics) (profiles) (logs)
otlp_ingest_logs_5mb_throughput ingress throughput -0.02 [-0.15, +0.10] 1 (metrics) (profiles) (logs)
dsd_uds_1mb_3k_contexts_memory memory utilization -0.04 [-0.21, +0.14] 1 (metrics) (profiles) (logs)
otlp_ingest_traces_ottl_filtering_5mb_memory memory utilization -0.09 [-0.43, +0.24] 1 (metrics) (profiles) (logs)
dsd_uds_500mb_3k_contexts_memory memory utilization -0.18 [-0.35, -0.00] 1 (metrics) (profiles) (logs)
dsd_uds_500mb_3k_contexts_cpu % cpu utilization -0.27 [-1.74, +1.20] 1 (metrics) (profiles) (logs)
otlp_ingest_traces_5mb_memory memory utilization -0.39 [-0.63, -0.15] 1 (metrics) (profiles) (logs)
otlp_ingest_traces_5mb_cpu % cpu utilization -0.42 [-2.62, +1.77] 1 (metrics) (profiles) (logs)
dsd_uds_10mb_3k_contexts_cpu % cpu utilization -0.52 [-31.36, +30.32] 1 (metrics) (profiles) (logs)
otlp_ingest_traces_ottl_transform_5mb_memory memory utilization -0.73 [-0.97, -0.48] 1 (metrics) (profiles) (logs)
otlp_ingest_metrics_5mb_memory memory utilization -1.09 [-1.33, -0.84] 1 (metrics) (profiles) (logs)
dsd_uds_512kb_3k_contexts_cpu % cpu utilization -1.64 [-57.83, +54.55] 1 (metrics) (profiles) (logs)
dsd_uds_500mb_3k_contexts_throughput ingress throughput -2.78 [-2.91, -2.64] 1 (metrics) (profiles) (logs)
otlp_ingest_logs_5mb_memory memory utilization -3.53 [-3.99, -3.06] 1 (metrics) (profiles) (logs)

Bounds Checks: ✅ Passed

perf experiment bounds_check_name replicates_passed observed_value links
quality_gates_rss_dsd_heavy memory_usage 10/10 114.07MiB ≤ 140MiB (metrics) (profiles) (logs)
quality_gates_rss_dsd_low memory_usage 10/10 33.54MiB ≤ 50MiB (metrics) (profiles) (logs)
quality_gates_rss_dsd_medium memory_usage 10/10 54.13MiB ≤ 75MiB (metrics) (profiles) (logs)
quality_gates_rss_dsd_ultraheavy memory_usage 10/10 168.78MiB ≤ 200MiB (metrics) (profiles) (logs)
quality_gates_rss_idle memory_usage 10/10 21.03MiB ≤ 40MiB (metrics) (profiles) (logs)

Explanation

Confidence level: 90.00%
Effect size tolerance: |Δ mean %| ≥ 5.00%

Performance changes are noted in the perf column of each table:

  • ✅ = significantly better comparison variant performance
  • ❌ = significantly worse comparison variant performance
  • ➖ = no significant change in performance

A regression test is an A/B test of target performance in a repeatable rig, where "performance" is measured as "comparison variant minus baseline variant" for an optimization goal (e.g., ingress throughput). Due to intrinsic variability in measuring that goal, we can only estimate its mean value for each experiment; we report uncertainty in that value as a 90.00% confidence interval denoted "Δ mean % CI".

For each experiment, we decide whether a change in performance is a "regression" -- a change worth investigating further -- if all of the following criteria are true:

  1. Its estimated |Δ mean %| ≥ 5.00%, indicating the change is big enough to merit a closer look.

  2. Its 90.00% confidence interval "Δ mean % CI" does not contain zero, indicating that if our statistical model is accurate, there is at least a 90.00% chance there is a difference in performance between baseline and comparison variants.

  3. Its configuration does not mark it "erratic".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/components Sources, transforms, and destinations. area/core Core functionality, event model, etc. transform/host-tags Host Tags synchronous transform.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants