Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
eb68a4a
Add HttpEndpoint type and migrate HTTP sinks to validated endpoint URIs
thomasqueirozb Aug 14, 2026
135c724
feat(sinks): migrate sink endpoints to HttpEndpoint
thomasqueirozb Aug 14, 2026
4fd530d
chore(changelog): note sink endpoint validation
thomasqueirozb Aug 14, 2026
e45a1a4
Require a non-empty host in HttpEndpoint validation
thomasqueirozb Aug 14, 2026
b12d379
Default a missing endpoint scheme to https in HttpEndpoint
thomasqueirozb Aug 14, 2026
8cb4c9e
chore(changelog): narrow endpoint validation claim to load-time sinks
thomasqueirozb Aug 14, 2026
17d819d
fix(pubsub sink): preserve :publish method suffix on topic path
thomasqueirozb Aug 14, 2026
1b1f2d5
fix(sinks): reject malformed ports in HttpEndpoint
thomasqueirozb Aug 14, 2026
2c777aa
fix(datadog sinks): normalize scheme-less endpoints in healthcheck
thomasqueirozb Aug 14, 2026
110d222
fix(sinks): trim leading slash when appending to a non-root base path
thomasqueirozb Aug 14, 2026
9633a53
Revert "chore(changelog): narrow endpoint validation claim to load-ti…
thomasqueirozb Aug 14, 2026
2b7b923
test(azure_logs_ingestion): expect trailing slash in endpoint string …
thomasqueirozb Aug 14, 2026
d30d42f
Fix examples
thomasqueirozb Aug 14, 2026
fca2931
fix(gcs sink): preserve significant leading slashes in object keys
thomasqueirozb Aug 14, 2026
b8080a6
test(sinks): document rejection of multiple port separators
thomasqueirozb Aug 14, 2026
0576d67
fix(sinks): detect endpoint scheme only at the start
thomasqueirozb Aug 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog.d/sink_endpoint_absolute_urls.enhancement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Sink `endpoint` options now require an absolute `http://` or `https://` URL that includes a host. Previously, partial or empty endpoints (for example `endpoint: ""` or `endpoint: "localhost:8080"` without a scheme) were accepted at configuration load and only failed when the sink attempted to send data, or were silently completed with a default scheme and host. Such configurations now fail validation, including with `vector validate --no-environment`, with a clear error. This affects the `appsignal`, `azure_logs_ingestion`, `azure_monitor_logs`, `datadog_metrics`, `datadog_traces`, `gcp_cloud_storage`, `gcp_pubsub`, `gcp_stackdriver_logs`, `gcp_stackdriver_metrics`, `honeycomb`, `humio`, `influxdb`, `prometheus_remote_write`, `sematext`, and `splunk_hec` sinks.
Comment thread
thomasqueirozb marked this conversation as resolved.
Outdated

authors: thomasqueirozb
51 changes: 29 additions & 22 deletions src/sinks/appsignal/config.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use derivative::Derivative;
use futures::FutureExt;
use http::{Request, Uri, header::AUTHORIZATION};
use http::{Request, header::AUTHORIZATION};
use hyper::Body;
use tower::ServiceBuilder;
use vector_lib::{
Expand All @@ -17,24 +18,27 @@ use crate::{
codecs::Transformer,
http::HttpClient,
sinks::{
BuildError, Healthcheck, HealthcheckError, VectorSink,
Healthcheck, HealthcheckError, VectorSink,
prelude::{SinkConfig, SinkContext},
util::{
BatchConfig, Compression, ServiceBuilderExt, SinkBatchSettings, TowerRequestConfig,
BatchConfig, Compression, HttpEndpoint, ServiceBuilderExt, SinkBatchSettings,
TowerRequestConfig,
http::{HttpStatusRetryLogic, RetryStrategy},
},
},
};

/// Configuration for the `appsignal` sink.
#[configurable_component(sink("appsignal", "Deliver log and metric event data to AppSignal."))]
#[derive(Clone, Debug, Default)]
#[derive(Clone, Debug, Derivative)]
#[derivative(Default)]
pub(super) struct AppsignalConfig {
/// The URI for the AppSignal API to send data to.
#[configurable(validation(format = "uri"))]
#[configurable(metadata(docs::examples = "https://appsignal-endpoint.net"))]
#[derivative(Default(value = "default_endpoint()"))]
#[serde(default = "default_endpoint")]
pub(super) endpoint: String,
pub(super) endpoint: HttpEndpoint,

/// A valid app-level AppSignal Push API key.
#[configurable(metadata(docs::examples = "00000000-0000-0000-0000-000000000000"))]
Expand Down Expand Up @@ -73,8 +77,8 @@ pub(super) struct AppsignalConfig {
retry_strategy: RetryStrategy,
}

pub(super) fn default_endpoint() -> String {
"https://appsignal-endpoint.net".to_string()
pub(super) fn default_endpoint() -> HttpEndpoint {
HttpEndpoint::parse("https://appsignal-endpoint.net").unwrap()
}

#[derive(Clone, Copy, Debug, Default)]
Expand Down Expand Up @@ -151,8 +155,13 @@ impl SinkConfig for AppsignalConfig {
}
}

async fn healthcheck(uri: Uri, push_api_key: String, client: HttpClient) -> crate::Result<()> {
let request = Request::get(uri).header(AUTHORIZATION, format!("Bearer {push_api_key}"));
async fn healthcheck(
uri: HttpEndpoint,
push_api_key: String,
client: HttpClient,
) -> crate::Result<()> {
let request =
Request::get(uri.as_uri()).header(AUTHORIZATION, format!("Bearer {push_api_key}"));
let response = client.send(request.body(Body::empty()).unwrap()).await?;

match response.status() {
Expand All @@ -161,21 +170,13 @@ async fn healthcheck(uri: Uri, push_api_key: String, client: HttpClient) -> crat
}
}

pub fn endpoint_uri(endpoint: &str, path: &str) -> crate::Result<Uri> {
let uri = if endpoint.ends_with('/') {
format!("{endpoint}{path}")
} else {
format!("{endpoint}/{path}")
};
match uri.parse::<Uri>() {
Ok(u) => Ok(u),
Err(e) => Err(Box::new(BuildError::UriParseError { source: e })),
}
pub fn endpoint_uri(endpoint: &HttpEndpoint, path: &str) -> crate::Result<HttpEndpoint> {
Ok(endpoint.append_path(path)?)
}

#[cfg(test)]
mod test {
use super::{AppsignalConfig, endpoint_uri};
use super::{AppsignalConfig, HttpEndpoint, endpoint_uri};

#[test]
fn generate_config() {
Expand All @@ -184,7 +185,10 @@ mod test {

#[test]
fn endpoint_uri_with_path() {
let uri = endpoint_uri("https://appsignal-endpoint.net", "vector/events");
let uri = endpoint_uri(
&HttpEndpoint::parse("https://appsignal-endpoint.net").unwrap(),
"vector/events",
);
assert_eq!(
uri.expect("Not a valid URI").to_string(),
"https://appsignal-endpoint.net/vector/events"
Expand All @@ -193,7 +197,10 @@ mod test {

#[test]
fn endpoint_uri_with_trailing_slash() {
let uri = endpoint_uri("https://appsignal-endpoint.net/", "vector/events");
let uri = endpoint_uri(
&HttpEndpoint::parse("https://appsignal-endpoint.net/").unwrap(),
"vector/events",
);
assert_eq!(
uri.expect("Not a valid URI").to_string(),
"https://appsignal-endpoint.net/vector/events"
Expand Down
7 changes: 5 additions & 2 deletions src/sinks/appsignal/integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ use crate::{
config::SinkConfig,
sinks::{
appsignal::config::AppsignalConfig,
util::test::{build_test_server_status, load_sink},
util::{
HttpEndpoint,
test::{build_test_server_status, load_sink},
},
},
test_util::{
addr::next_addr,
Expand All @@ -32,7 +35,7 @@ async fn start_test(events: Vec<Event>) -> (Vec<Event>, Receiver<(http::request:
let (mut config, cx) = load_sink::<AppsignalConfig>(config.as_str()).unwrap();
let (_guard, addr) = next_addr();
// Set the endpoint to a local server so we can fetch the sent events later
config.endpoint = format!("http://{addr}");
config.endpoint = HttpEndpoint::parse(&format!("http://{addr}")).unwrap();

let (sink, _) = config.build(cx).await.unwrap();

Expand Down
8 changes: 4 additions & 4 deletions src/sinks/appsignal/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use futures::{
future,
future::{BoxFuture, Ready},
};
use http::{Request, StatusCode, Uri, header::AUTHORIZATION};
use http::{Request, StatusCode, header::AUTHORIZATION};
use hyper::Body;
use tower::{Service, ServiceExt};
use vector_lib::{
Expand All @@ -18,7 +18,7 @@ use vector_lib::{
use super::request_builder::AppsignalRequest;
use crate::{
http::HttpClient,
sinks::util::{Compression, http::HttpBatchService, sink::Response},
sinks::util::{Compression, HttpEndpoint, http::HttpBatchService, sink::Response},
};

#[derive(Clone)]
Expand All @@ -33,14 +33,14 @@ pub(super) struct AppsignalService {
impl AppsignalService {
pub fn new(
http_client: HttpClient<Body>,
endpoint: Uri,
endpoint: HttpEndpoint,
push_api_key: SensitiveString,
compression: Compression,
) -> Self {
let batch_service = HttpBatchService::new(http_client, move |req| {
let req: AppsignalRequest = req;

let mut request = Request::post(&endpoint)
let mut request = Request::post(endpoint.as_uri())
.header("Content-Type", "application/json")
.header(AUTHORIZATION, format!("Bearer {}", push_api_key.inner()))
.header("Content-Length", req.payload.len());
Expand Down
3 changes: 2 additions & 1 deletion src/sinks/appsignal/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use vector_lib::{
use super::config::AppsignalConfig;
use crate::{
config::{SinkConfig, SinkContext},
sinks::util::HttpEndpoint,
test_util::{
components::{HTTP_SINK_TAGS, run_and_assert_sink_compliance},
http::{always_200_response, spawn_blackhole_http_server},
Expand All @@ -19,7 +20,7 @@ async fn component_spec_compliance() {

let mut config: AppsignalConfig =
serde_json::from_value(AppsignalConfig::generate_config()).expect("config should be valid");
config.endpoint = mock_endpoint.to_string();
config.endpoint = HttpEndpoint::parse(&mock_endpoint.to_string()).unwrap();

let context = SinkContext::default();
let (sink, _healthcheck) = config.build(context).await.unwrap();
Expand Down
12 changes: 6 additions & 6 deletions src/sinks/azure_logs_ingestion/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use crate::{
azure_common::config::AzureAuthentication,
prelude::*,
util::{
RealtimeSizeBasedDefaultBatchSettings, UriSerde,
HttpEndpoint, RealtimeSizeBasedDefaultBatchSettings,
http::{HttpStatusRetryLogic, RetryStrategy},
},
},
Expand Down Expand Up @@ -47,7 +47,7 @@ pub struct AzureLogsIngestionConfig {
#[configurable(metadata(
docs::examples = "https://my-dce-5kyl.eastus-1.ingest.monitor.azure.com"
))]
pub endpoint: String,
pub endpoint: HttpEndpoint,

/// The [Data collection rule immutable ID][dcr_immutable_id] for the Data collection endpoint.
///
Expand Down Expand Up @@ -114,7 +114,7 @@ pub struct AzureLogsIngestionConfig {
impl Default for AzureLogsIngestionConfig {
fn default() -> Self {
Self {
endpoint: Default::default(),
endpoint: HttpEndpoint::parse("http://localhost:8080").unwrap(),
dcr_immutable_id: Default::default(),
stream_name: Default::default(),
auth: Default::default(),
Expand All @@ -135,14 +135,14 @@ impl AzureLogsIngestionConfig {
pub(super) async fn build_inner(
&self,
cx: SinkContext,
endpoint: UriSerde,
endpoint: HttpEndpoint,
dcr_immutable_id: String,
stream_name: String,
credential: Arc<dyn TokenCredential>,
token_scope: String,
timestamp_field: String,
) -> crate::Result<(VectorSink, Healthcheck)> {
let endpoint = endpoint.with_default_parts().uri;
let endpoint = endpoint.into_uri();
let protocol = get_http_scheme_from_uri(&endpoint).to_string();

let batch_settings = self
Expand Down Expand Up @@ -191,7 +191,7 @@ impl_generate_config_from_default!(AzureLogsIngestionConfig);
#[typetag::serde(name = "azure_logs_ingestion")]
impl SinkConfig for AzureLogsIngestionConfig {
async fn build(&self, cx: SinkContext) -> crate::Result<(VectorSink, Healthcheck)> {
let endpoint: UriSerde = self.endpoint.parse()?;
let endpoint = self.endpoint.clone();

let credential: Arc<dyn TokenCredential> = self.auth.credential().await?;

Expand Down
12 changes: 6 additions & 6 deletions src/sinks/azure_logs_ingestion/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use super::config::AzureLogsIngestionConfig;

use crate::{
event::LogEvent,
sinks::prelude::*,
sinks::{prelude::*, util::HttpEndpoint},
test_util::{
components::{SINK_TAGS, run_and_assert_sink_compliance},
http::spawn_blackhole_http_server,
Expand Down Expand Up @@ -64,7 +64,7 @@ fn basic_config_with_client_credentials() {
.expect("Config parsing failed");

assert_eq!(
config.endpoint,
config.endpoint.to_string(),
"https://my-dce-5kyl.eastus-1.ingest.monitor.azure.com"
);
assert_eq!(
Expand Down Expand Up @@ -103,7 +103,7 @@ fn basic_config_with_managed_identity() {
.expect("Config parsing failed");

assert_eq!(
config.endpoint,
config.endpoint.to_string(),
"https://my-dce-5kyl.eastus-1.ingest.monitor.azure.com"
);
assert_eq!(
Expand Down Expand Up @@ -176,7 +176,7 @@ async fn correct_request() {
let (sink, healthcheck) = config
.build_inner(
context,
mock_endpoint.try_into().unwrap(),
HttpEndpoint::new(mock_endpoint).unwrap(),
config.dcr_immutable_id.clone(),
config.stream_name.clone(),
credential,
Expand Down Expand Up @@ -288,7 +288,7 @@ async fn mock_healthcheck_with_400_response() {
let (_sink, healthcheck) = config
.build_inner(
context,
mock_endpoint.try_into().unwrap(),
HttpEndpoint::new(mock_endpoint).unwrap(),
config.dcr_immutable_id.clone(),
config.stream_name.clone(),
credential,
Expand Down Expand Up @@ -355,7 +355,7 @@ async fn mock_healthcheck_with_403_response() {
let (_sink, healthcheck) = config
.build_inner(
context,
mock_endpoint.try_into().unwrap(),
HttpEndpoint::new(mock_endpoint).unwrap(),
config.dcr_immutable_id.clone(),
config.stream_name.clone(),
credential,
Expand Down
8 changes: 4 additions & 4 deletions src/sinks/azure_monitor_logs/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ use crate::{
sinks::{
prelude::*,
util::{
RealtimeSizeBasedDefaultBatchSettings, UriSerde,
HttpEndpoint, RealtimeSizeBasedDefaultBatchSettings,
http::{HttpStatusRetryLogic, RetryStrategy},
},
},
Expand Down Expand Up @@ -161,9 +161,9 @@ impl AzureMonitorLogsConfig {
pub(super) async fn build_inner(
&self,
cx: SinkContext,
endpoint: UriSerde,
endpoint: HttpEndpoint,
) -> crate::Result<(VectorSink, Healthcheck)> {
let endpoint = endpoint.with_default_parts().uri;
let endpoint = endpoint.into_uri();
let protocol = get_http_scheme_from_uri(&endpoint).to_string();

let batch_settings = self
Expand Down Expand Up @@ -216,7 +216,7 @@ impl_generate_config_from_default!(AzureMonitorLogsConfig);
#[typetag::serde(name = "azure_monitor_logs")]
impl SinkConfig for AzureMonitorLogsConfig {
async fn build(&self, cx: SinkContext) -> crate::Result<(VectorSink, Healthcheck)> {
let endpoint = format!("https://{}.{}", self.customer_id, self.host).parse()?;
let endpoint = HttpEndpoint::parse(&format!("https://{}.{}", self.customer_id, self.host))?;
self.build_inner(cx, endpoint).await
}

Expand Down
9 changes: 6 additions & 3 deletions src/sinks/azure_monitor_logs/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ use super::{
};
use crate::{
event::LogEvent,
sinks::{prelude::*, util::encoding::Encoder},
sinks::{
prelude::*,
util::{HttpEndpoint, encoding::Encoder},
},
test_util::{
components::{SINK_TAGS, run_and_assert_sink_compliance},
http::{always_200_response, spawn_blackhole_http_server},
Expand All @@ -37,7 +40,7 @@ async fn component_spec_compliance() {

let context = SinkContext::default();
let (sink, _healthcheck) = config
.build_inner(context, mock_endpoint.try_into().unwrap())
.build_inner(context, HttpEndpoint::new(mock_endpoint).unwrap())
.await
.unwrap();

Expand Down Expand Up @@ -184,7 +187,7 @@ async fn correct_request() {

let context = SinkContext::default();
let (sink, _healthcheck) = config
.build_inner(context, mock_endpoint.try_into().unwrap())
.build_inner(context, HttpEndpoint::new(mock_endpoint).unwrap())
.await
.unwrap();

Expand Down
Loading
Loading