Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
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
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -930,6 +930,7 @@ sinks-logs = [
"sinks-nats",
"sinks-new_relic",
"sinks-new_relic_logs",
"sinks-opensearch",
"sinks-opentelemetry",
"sinks-papertrail",
"sinks-postgres",
Expand Down Expand Up @@ -1001,6 +1002,7 @@ sinks-mqtt = ["dep:rumqttc"]
sinks-nats = ["dep:async-nats", "dep:nkeys"]
sinks-new_relic_logs = ["sinks-http"]
sinks-new_relic = []
sinks-opensearch = ["sinks-elasticsearch"]
sinks-opentelemetry = ["sinks-http", "codecs-opentelemetry"]
sinks-papertrail = ["dep:syslog"]
sinks-prometheus = ["dep:base64", "dep:prost", "vector-lib/prometheus"]
Expand Down Expand Up @@ -1053,6 +1055,7 @@ all-integration-tests = [
"mqtt-integration-tests",
"nats-integration-tests",
"nginx-integration-tests",
"opensearch-integration-tests",
"opentelemetry-integration-tests",
"postgresql_metrics-integration-tests",
"postgres_sink-integration-tests",
Expand Down Expand Up @@ -1106,6 +1109,7 @@ datadog-traces-integration-tests = ["sources-datadog_agent", "sinks-datadog_trac
docker-logs-integration-tests = ["sources-docker_logs"]
doris-integration-tests = ["sinks-doris"]
es-integration-tests = ["sinks-elasticsearch", "aws-core"]
opensearch-integration-tests = ["es-integration-tests", "sinks-opensearch"]
eventstoredb_metrics-integration-tests = ["sources-eventstoredb_metrics"]
fluent-integration-tests = ["docker", "sources-fluent"]
gcp-cloud-storage-integration-tests = ["sinks-gcp"]
Expand Down
6 changes: 6 additions & 0 deletions changelog.d/11738_opensearch_sink.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Add an `opensearch` sink for self-managed OpenSearch, Amazon OpenSearch Service,
and Amazon OpenSearch Serverless. It shares the Elasticsearch sink's configuration
options and delivery behavior. The new sink selects an OpenSearch-compatible bulk
API version by default, so an explicit `api_version: v7` is not needed.

authors: pront
29 changes: 24 additions & 5 deletions src/sinks/elasticsearch/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ use crate::{
transforms::metric_to_log::MetricToLog,
};

/// The backend selected by the sink constructor.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Distribution {
Elasticsearch,
OpenSearch,
}

#[derive(Debug, Clone)]
pub struct ElasticsearchCommon {
pub base_url: String,
Expand Down Expand Up @@ -50,6 +57,7 @@ impl ElasticsearchCommon {
#[allow(clippy::too_many_lines, reason = "Keep endpoint setup together.")]
pub async fn parse_config(
config: &ElasticsearchConfig,
distribution: Distribution,
endpoint: &HttpEndpoint,
proxy_config: &ProxyConfig,
version: &mut Option<usize>,
Expand Down Expand Up @@ -157,6 +165,9 @@ impl ElasticsearchCommon {
ElasticsearchApiVersion::V6 => 6,
ElasticsearchApiVersion::V7 => 7,
ElasticsearchApiVersion::V8 => 8,
// OpenSearch's bulk API is compatible with Elasticsearch 7. Its own
// major version is unrelated, so no cluster-version probe is needed.
ElasticsearchApiVersion::Auto if distribution == Distribution::OpenSearch => 7,
ElasticsearchApiVersion::Auto => {
match get_version(
&base_url,
Expand Down Expand Up @@ -286,6 +297,7 @@ impl ElasticsearchCommon {
/// or any endpoint fails [`Self::parse_config`].
pub async fn parse_many(
config: &ElasticsearchConfig,
distribution: Distribution,
proxy_config: &ProxyConfig,
) -> crate::Result<Vec<Self>> {
let mut version = None;
Expand All @@ -295,7 +307,8 @@ impl ElasticsearchCommon {
);
if config.endpoints.is_empty() {
Ok(vec![
Self::parse_config(config, endpoint, proxy_config, &mut version).await?,
Self::parse_config(config, distribution, endpoint, proxy_config, &mut version)
.await?,
])
} else {
Err(ParseError::EndpointsExclusive.into())
Expand All @@ -305,8 +318,10 @@ impl ElasticsearchCommon {
} else {
let mut commons = Vec::new();
for endpoint in &config.endpoints {
commons
.push(Self::parse_config(config, endpoint, proxy_config, &mut version).await?);
commons.push(
Self::parse_config(config, distribution, endpoint, proxy_config, &mut version)
.await?,
);
}
Ok(commons)
}
Expand All @@ -321,8 +336,12 @@ impl ElasticsearchCommon {
/// Panics if the configuration contains more than one endpoint.
#[cfg(test)]
pub async fn parse_single(config: &ElasticsearchConfig) -> crate::Result<Self> {
let mut commons =
Self::parse_many(config, crate::config::SinkContext::default().proxy()).await?;
let mut commons = Self::parse_many(
config,
Distribution::Elasticsearch,
crate::config::SinkContext::default().proxy(),
)
.await?;
assert_eq!(commons.len(), 1);
Ok(commons.remove(0))
}
Expand Down
16 changes: 14 additions & 2 deletions src/sinks/elasticsearch/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ use crate::{
sinks::{
Healthcheck, VectorSink,
elasticsearch::{
ElasticsearchApiVersion, ElasticsearchAuthConfig, ElasticsearchCommon,
Distribution, ElasticsearchApiVersion, ElasticsearchAuthConfig, ElasticsearchCommon,
ElasticsearchCommonMode, ElasticsearchMode, ParseError, VersionType,
health::ElasticsearchHealthLogic,
retry::ElasticsearchRetryLogic,
Expand Down Expand Up @@ -833,12 +833,24 @@ impl ValidatedSink for ElasticsearchConfig {
&self,
validated: &ValidatedElasticsearch,
cx: SinkContext,
) -> crate::Result<(VectorSink, Healthcheck)> {
self.build_with_distribution(validated, cx, Distribution::Elasticsearch)
.await
}
}

impl ElasticsearchConfig {
pub(crate) async fn build_with_distribution(
&self,
validated: &ValidatedElasticsearch,
cx: SinkContext,
distribution: Distribution,
) -> crate::Result<(VectorSink, Healthcheck)> {
// Confinement of the active mode's routing templates happens in
// [`ElasticsearchConfig::common_mode`], which each `ElasticsearchCommon` calls while
// parsing (which performs I/O: AWS credential resolution and API-version autodetection).
// The pure request/health settings were resolved during `validate`.
let commons = ElasticsearchCommon::parse_many(self, cx.proxy()).await?;
let commons = ElasticsearchCommon::parse_many(self, distribution, cx.proxy()).await?;
let common = commons[0].clone();

let client = HttpClient::new(common.tls_settings.clone(), cx.proxy())?;
Expand Down
85 changes: 84 additions & 1 deletion src/sinks/elasticsearch/integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,89 @@ async fn insert_events_over_http() {
.await;
}

#[cfg(feature = "opensearch-integration-tests")]
mod opensearch {
use super::*;
use crate::config::BoxedSink;

#[tokio::test]
async fn insert_events_with_auto_api_version_over_http() {
insert_event(false).await;
}

#[tokio::test]
async fn insert_events_with_auto_api_version_over_https() {
insert_event(true).await;
}

async fn insert_event(secure: bool) {
trace_init();
let endpoint = if secure {
https_server()
} else {
http_server()
}
.to_string();
let endpoint = endpoint.trim_end_matches('/');
let index = gen_index().to_string();
let mut config = json!({
"type": "opensearch",
"endpoints": [endpoint],
"bulk": { "index": index },
"compression": "gzip",
});
if secure {
config["auth"] = json!({
"strategy": "basic",
"user": http_username(),
"password": http_password(),
});
config["tls"] = json!({ "ca_file": tls::TEST_PEM_CA_PATH });
}
// Omit api_version to exercise the constructor-selected OpenSearch API version.
let config: BoxedSink = serde_json::from_value(config).unwrap();
let (sink, healthcheck) = config.build(SinkContext::default()).await.unwrap();
healthcheck.await.unwrap();

let (batch, mut receiver) = BatchNotifier::new_with_receiver();
let event = LogEvent::from("OpenSearch integration test").with_batch_notifier(&batch);
drop(batch);
run_and_assert_sink_compliance(
sink,
stream::once(ready(Event::from(event))),
&HTTP_SINK_TAGS,
)
.await;
assert_eq!(receiver.try_recv(), Ok(BatchStatus::Delivered));

let client = create_http_client();
client
.post(format!("{endpoint}/{index}/_refresh"))
.basic_auth(http_username(), Some(http_password()))
.send()
.await
.unwrap()
.error_for_status()
.unwrap();
let response: Value = client
.get(format!("{endpoint}/{index}/_search"))
.basic_auth(http_username(), Some(http_password()))
.send()
.await
.unwrap()
.error_for_status()
.unwrap()
.json()
.await
.unwrap();
assert_eq!(response["hits"]["total"]["value"], 1);
assert_eq!(
response["hits"]["hits"][0]["_source"]["message"],
"OpenSearch integration test"
);
}
}

#[tokio::test]
async fn insert_events_with_data_volume() {
trace_init();
Expand Down Expand Up @@ -823,7 +906,7 @@ async fn run_insert_tests_with_config(

async fn run_insert_tests_with_multiple_endpoints(config: &ElasticsearchConfig) {
let cx = SinkContext::default();
let commons = ElasticsearchCommon::parse_many(config, cx.proxy())
let commons = ElasticsearchCommon::parse_many(config, Distribution::Elasticsearch, cx.proxy())
.await
.expect("Config error");
let index = match config.mode {
Expand Down
5 changes: 4 additions & 1 deletion src/sinks/elasticsearch/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,10 @@ impl ElasticsearchCommonMode {
#[serde(deny_unknown_fields, rename_all = "snake_case")]
#[derive(Default)]
pub enum ElasticsearchApiVersion {
/// Auto-detect the API version.
/// Automatically select the API version.
///
/// The `opensearch` sink selects an OpenSearch-compatible API version without querying the server.
/// The `elasticsearch` sink detects the version from the server.
///
/// If the [cluster state version endpoint][es_version] isn't reachable, a warning is logged to
/// stdout, and the version is assumed to be V6 if the `suppress_type_name` option is set to
Expand Down
Loading
Loading