Skip to content

Commit df25d28

Browse files
committed
feat(sources): post-processing in SourceSender
Add a `PostProcessor` hook to `SourceSender` so sources can attach a VRL program or a hard-coded Rust closure that runs on every event after schema metadata is attached, immediately before the event is placed on the output channel. This provides a first-class, reusable facility (e.g. for HEC token enrichment) instead of requiring per-source workarounds.
1 parent 5072d8b commit df25d28

6 files changed

Lines changed: 222 additions & 20 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Add a `PostProcessor` hook to `SourceSender` that allows a hard-coded Rust closure to be applied
2+
to every event emitted by a source, immediately after schema metadata is attached and before the
3+
event is placed on the output channel.
4+
5+
authors: 20agbekodo

lib/vector-core/src/source_sender/builder.rs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ use vector_common::histogram;
55
use vector_common::internal_event::DEFAULT_OUTPUT;
66

77
use super::{
8-
CHUNK_SIZE, LAG_TIME_NAME, Output, OutputMetrics, SEND_BATCH_LATENCY_NAME, SEND_LATENCY_NAME,
9-
SourceSender, SourceSenderItem,
8+
CHUNK_SIZE, LAG_TIME_NAME, Output, OutputMetrics, PostProcessor, SEND_BATCH_LATENCY_NAME,
9+
SEND_LATENCY_NAME, SourceSender, SourceSenderItem,
1010
};
1111
use crate::config::{ComponentKey, OutputId, SourceOutput};
1212

@@ -17,6 +17,7 @@ pub struct Builder {
1717
output_metrics: OutputMetrics,
1818
timeout: Option<Duration>,
1919
ewma_half_life_seconds: Option<f64>,
20+
post_processor: Option<PostProcessor>,
2021
}
2122

2223
impl Default for Builder {
@@ -32,6 +33,7 @@ impl Default for Builder {
3233
),
3334
timeout: None,
3435
ewma_half_life_seconds: None,
36+
post_processor: None,
3537
}
3638
}
3739
}
@@ -55,6 +57,18 @@ impl Builder {
5557
self
5658
}
5759

60+
/// Attach a post-processing step that will be applied to every event on **all** outputs
61+
/// (default and named ports) produced by this builder.
62+
///
63+
/// The processor runs after schema metadata has been attached to each event, immediately
64+
/// before the event is placed on the output channel. See [`PostProcessor`] for the available
65+
/// variants and their error-handling semantics.
66+
#[must_use]
67+
pub fn with_post_processor(mut self, post_processor: PostProcessor) -> Self {
68+
self.post_processor = Some(post_processor);
69+
self
70+
}
71+
5872
pub fn add_source_output(
5973
&mut self,
6074
output: SourceOutput,
@@ -75,6 +89,7 @@ impl Builder {
7589
output_id,
7690
self.timeout,
7791
self.ewma_half_life_seconds,
92+
self.post_processor.clone(),
7893
);
7994
self.default_output = Some(output);
8095
rx
@@ -88,6 +103,7 @@ impl Builder {
88103
output_id,
89104
self.timeout,
90105
self.ewma_half_life_seconds,
106+
self.post_processor.clone(),
91107
);
92108
self.named_outputs.insert(name, output);
93109
rx

lib/vector-core/src/source_sender/mod.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,24 @@ use vector_common::internal_event::HistogramName;
2727
const LAG_TIME_NAME: HistogramName = HistogramName::SourceLagTimeSeconds;
2828
const SEND_LATENCY_NAME: HistogramName = HistogramName::SourceSendLatencySeconds;
2929
const SEND_BATCH_LATENCY_NAME: HistogramName = HistogramName::SourceSendBatchLatencySeconds;
30+
31+
/// A post-processing step applied to every event that flows through a [`SourceSender`].
32+
///
33+
/// The hook executes on each event after schema metadata has been attached, immediately before the
34+
/// event is placed on the output channel. It is applied *globally* — to all outputs (default and
35+
/// named ports) produced by the same [`Builder`].
36+
///
37+
/// Currently one variant is provided:
38+
///
39+
/// - [`PostProcessor::HardCoded`]: calls an infallible Rust closure. No events are dropped by
40+
/// this variant.
41+
///
42+
/// If per-output post-processing is needed in the future, a `with_post_processor_for_port` API
43+
/// can be added without breaking this interface.
44+
#[derive(Clone)]
45+
pub enum PostProcessor {
46+
/// Call a hard-coded Rust function against every event.
47+
///
48+
/// The closure is infallible; no events are dropped by this variant.
49+
HardCoded(std::sync::Arc<dyn Fn(&mut crate::event::Event) + Send + Sync>),
50+
}

lib/vector-core/src/source_sender/output.rs

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ use vector_common::{
2424
};
2525
use vrl::value::Value;
2626

27-
use super::{CHUNK_SIZE, SendError, SourceSenderItem};
27+
use super::{CHUNK_SIZE, PostProcessor, SendError, SourceSenderItem};
2828
use crate::{
2929
EstimatedJsonEncodedSizeOf,
3030
config::{OutputId, log_schema},
@@ -93,6 +93,8 @@ pub(super) struct Output {
9393
/// `EventMetadata` for all event sent through here.
9494
id: Arc<OutputId>,
9595
timeout: Option<Duration>,
96+
/// Optional post-processing step applied to every event before it is placed on the channel.
97+
post_processor: Option<PostProcessor>,
9698
}
9799

98100
#[derive(Clone, Default)]
@@ -129,6 +131,7 @@ impl fmt::Debug for Output {
129131
}
130132

131133
impl Output {
134+
#[expect(clippy::too_many_arguments)]
132135
pub(super) fn new_with_buffer(
133136
n: usize,
134137
output: String,
@@ -137,6 +140,7 @@ impl Output {
137140
output_id: OutputId,
138141
timeout: Option<Duration>,
139142
ewma_half_life_seconds: Option<f64>,
143+
post_processor: Option<PostProcessor>,
140144
) -> (Self, LimitedReceiver<SourceSenderItem>) {
141145
let limit = MemoryBufferSize::MaxEvents(NonZeroUsize::new(n).unwrap());
142146
let channel_metrics =
@@ -152,6 +156,7 @@ impl Output {
152156
log_definition,
153157
id: Arc::new(output_id),
154158
timeout,
159+
post_processor,
155160
},
156161
rx,
157162
)
@@ -168,7 +173,7 @@ impl Output {
168173

169174
async fn send_inner(
170175
&mut self,
171-
mut events: EventArray,
176+
events: EventArray,
172177
unsent_event_count: &mut UnsentEventCount,
173178
reference: i64,
174179
) -> Result<(), SendError> {
@@ -177,29 +182,49 @@ impl Output {
177182
.iter_events()
178183
.for_each(|event| self.emit_lag_time(event, reference));
179184

180-
events.iter_events_mut().for_each(|mut event| {
181-
// attach runtime schema definitions from the source
182-
if let Some(log_definition) = &self.log_definition {
183-
event.metadata_mut().set_schema_definition(log_definition);
184-
}
185-
event.metadata_mut().set_upstream_id(Arc::clone(&self.id));
186-
});
185+
let post_processor = self.post_processor.clone();
186+
let log_definition = self.log_definition.clone();
187+
let id = Arc::clone(&self.id);
188+
189+
// Iterate over individual events to apply metadata and the optional post-processor.
190+
// We collect into a Vec<Event> first so that we can then re-group them into
191+
// EventArrays (which are homogeneous by type). We send each sub-array separately to
192+
// handle mixed-type batches correctly — no events are silently dropped.
193+
let processed: Vec<Event> = events
194+
.into_events()
195+
.map(|mut event| {
196+
if let Some(ref def) = log_definition {
197+
event.metadata_mut().set_schema_definition(def);
198+
}
199+
event.metadata_mut().set_upstream_id(Arc::clone(&id));
200+
if let Some(PostProcessor::HardCoded(ref f)) = post_processor {
201+
f(&mut event);
202+
}
203+
event
204+
})
205+
.collect();
187206

188-
let byte_size = events.estimated_json_encoded_size_of();
189-
let count = events.len();
207+
if processed.is_empty() {
208+
return Ok(());
209+
}
190210

211+
// Re-group individual events back into typed EventArrays and send each one.
212+
// Using `events_into_arrays(...).collect::<Vec<_>>()` ensures ALL sub-arrays are
213+
// captured — no type group is silently discarded (avoids the `.next()`-only bug).
191214
let send_start = Instant::now();
215+
for sub_array in array::events_into_arrays(processed.into_iter(), None) {
216+
let byte_size = sub_array.estimated_json_encoded_size_of();
217+
let count = sub_array.len();
192218

193-
let send_result = self.send_with_timeout(events, send_reference).await;
219+
self.send_with_timeout(sub_array, send_reference).await?;
194220

221+
self.events_sent.emit(CountByteSize(count, byte_size));
222+
unsent_event_count.decr(count);
223+
}
195224
if let Some(send_latency) = &self.metrics.send_latency {
196225
send_latency.record(send_start.elapsed().as_secs_f64());
197226
}
198227

199-
send_result?;
200-
201-
self.events_sent.emit(CountByteSize(count, byte_size));
202-
unsent_event_count.decr(count);
203228
Ok(())
204229
}
205230

@@ -295,6 +320,11 @@ impl Output {
295320
Ok(())
296321
}
297322

323+
/// Attach a post-processing step to this output, replacing any previously set one.
324+
pub(super) fn set_post_processor(&mut self, pp: &PostProcessor) {
325+
self.post_processor = Some(pp.clone());
326+
}
327+
298328
/// Calculate the difference between the reference time and the
299329
/// timestamp stored in the given event reference, and emit the
300330
/// different, as expressed in milliseconds, as a histogram.
@@ -336,3 +366,4 @@ const fn get_timestamp_millis(value: &Value) -> Option<i64> {
336366
_ => None,
337367
}
338368
}
369+

lib/vector-core/src/source_sender/sender.rs

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ use vector_common::{
2020
json_size::JsonSize,
2121
};
2222

23-
use super::{Builder, Output, SendError};
23+
use super::{Builder, Output, PostProcessor, SendError};
2424
#[cfg(any(test, feature = "test"))]
2525
use super::{
2626
LAG_TIME_NAME, OutputMetrics, SEND_BATCH_LATENCY_NAME, SEND_LATENCY_NAME, TEST_BUFFER_SIZE,
@@ -104,6 +104,20 @@ impl SourceSender {
104104
Builder::default()
105105
}
106106

107+
/// Attach a post-processing step to every output on this sender.
108+
///
109+
/// The processor runs after schema metadata has been attached to each event, immediately
110+
/// before the event is placed on the output channel. Replaces any previously set
111+
/// post-processor.
112+
pub fn set_post_processor(&mut self, pp: &PostProcessor) {
113+
if let Some(output) = &mut self.default_output {
114+
output.set_post_processor(pp);
115+
}
116+
for output in self.named_outputs.values_mut() {
117+
output.set_post_processor(pp);
118+
}
119+
}
120+
107121
#[cfg(any(test, feature = "test"))]
108122
pub fn new_test_sender_with_options(
109123
n: usize,
@@ -124,6 +138,37 @@ impl SourceSender {
124138
output_id,
125139
timeout,
126140
None,
141+
None,
142+
);
143+
(
144+
Self {
145+
default_output: Some(default_output),
146+
named_outputs: Default::default(),
147+
},
148+
rx,
149+
)
150+
}
151+
152+
#[cfg(any(test, feature = "test"))]
153+
pub fn new_test_with_post_processor(
154+
post_processor: super::PostProcessor,
155+
) -> (Self, LimitedReceiver<SourceSenderItem>) {
156+
let lag_time = Some(histogram!(LAG_TIME_NAME));
157+
let send_latency = Some(histogram!(SEND_LATENCY_NAME));
158+
let send_batch_latency = Some(histogram!(SEND_BATCH_LATENCY_NAME));
159+
let output_id = OutputId {
160+
component: "test".to_string().into(),
161+
port: None,
162+
};
163+
let (default_output, rx) = Output::new_with_buffer(
164+
TEST_BUFFER_SIZE,
165+
DEFAULT_OUTPUT.to_owned(),
166+
OutputMetrics::new(lag_time, send_latency, send_batch_latency),
167+
None,
168+
output_id,
169+
None,
170+
None,
171+
Some(post_processor),
127172
);
128173
(
129174
Self {
@@ -204,6 +249,7 @@ impl SourceSender {
204249
output_id,
205250
None,
206251
None,
252+
None,
207253
);
208254
let recv = recv.into_stream().map(move |mut item| {
209255
item.events.iter_events_mut().for_each(|mut event| {

0 commit comments

Comments
 (0)