1use std::collections::{BTreeMap, HashMap, HashSet};
21use std::sync::Arc;
22use std::time::Instant;
23
24use api::v1::SemanticType;
25use axum::Extension;
26use axum::extract::{Query, State};
27use axum::http::{HeaderMap, StatusCode, header};
28use axum::response::IntoResponse;
29use bytes::Bytes;
30use chrono::{DateTime, Utc};
31use common_base::regex_pattern::NAME_PATTERN_REG;
32use common_error::ext::ErrorExt;
33use common_query::prelude::greptime_timestamp;
34use common_telemetry::{debug, error};
35use operator::insert::SPLUNK_PK_METADATA_ORDER_KEY;
36use pipeline::util::to_pipeline_version;
37use pipeline::{
38 ContextReq, GREPTIME_INTERNAL_IDENTITY_PIPELINE_NAME, GreptimePipelineParams, PipelineContext,
39 PipelineDefinition,
40};
41use serde_json::{Deserializer, json};
42use session::context::{Channel, QueryContext, QueryContextRef};
43use vrl::value::{KeyString, Value as VrlValue};
44
45use crate::error::{Result, status_code_to_http_status};
46use crate::http::HttpResponse;
47use crate::http::event::{
48 LogIngesterQueryParams, LogState, PipelineIngestRequest, execute_log_context_req,
49 extract_pipeline_params_map_from_headers, transform_ndjson_array_factory,
50};
51use crate::http::header::constants::GREPTIME_PIPELINE_NAME_HEADER_NAME;
52use crate::metrics::{METRIC_HTTP_LOGS_INGESTION_COUNTER, METRIC_HTTP_LOGS_INGESTION_ELAPSED};
53use crate::pipeline::run_pipeline;
54use crate::query_handler::PipelineHandlerRef;
55
56const DEFAULT_SPLUNK_TABLE: &str = "splunk_logs";
59const HEC_HEALTHY_CODE: u32 = 17;
62
63#[derive(Debug, Default, serde::Deserialize)]
68pub struct SplunkRawQueryParams {
69 pub channel: Option<String>,
70 pub host: Option<String>,
71 pub source: Option<String>,
72 pub sourcetype: Option<String>,
73 pub index: Option<String>,
74 pub time: Option<String>,
75 pub table: Option<String>,
76 pub pipeline_name: Option<String>,
77 pub version: Option<String>,
78 pub linebreaker: Option<String>,
79}
80
81fn split_raw_body<'a>(body: &'a str, linebreaker: Option<&str>) -> Vec<&'a str> {
86 match linebreaker {
87 Some(lb) if !lb.is_empty() => body
88 .split(lb)
89 .filter(|segment| !segment.trim().is_empty())
90 .collect(),
91 _ => {
92 if body.trim().is_empty() {
93 vec![]
94 } else {
95 vec![body]
96 }
97 }
98 }
99}
100
101const RAW_MESSAGE_COLUMN: &str = "message";
105
106fn raw_metadata(params: &SplunkRawQueryParams) -> Vec<(&'static str, Bytes)> {
110 [
111 ("host", ¶ms.host),
112 ("source", ¶ms.source),
113 ("sourcetype", ¶ms.sourcetype),
114 ]
115 .into_iter()
116 .filter_map(|(key, value)| {
117 value
118 .as_deref()
119 .map(|v| (key, Bytes::copy_from_slice(v.as_bytes())))
120 })
121 .collect()
122}
123
124fn raw_event_to_map(
127 event: &str,
128 ts: DateTime<Utc>,
129 metadata: &[(&'static str, Bytes)],
130) -> VrlValue {
131 let mut map: BTreeMap<KeyString, VrlValue> = BTreeMap::new();
132 map.insert(
133 KeyString::from(greptime_timestamp()),
134 VrlValue::Timestamp(ts),
135 );
136 map.insert(
137 KeyString::from(RAW_MESSAGE_COLUMN),
138 VrlValue::Bytes(Bytes::copy_from_slice(event.as_bytes())),
139 );
140 for (key, value) in metadata {
141 map.insert(KeyString::from(*key), VrlValue::Bytes(value.clone()));
142 }
143 VrlValue::Object(map)
144}
145
146fn hec_response(status: StatusCode, code: u32, text: &str) -> axum::response::Response {
148 (status, axum::Json(json!({ "text": text, "code": code }))).into_response()
149}
150
151fn parse_hec_events(body: &[u8]) -> Result<Vec<VrlValue>> {
154 let values = Deserializer::from_slice(body).into_iter::<VrlValue>();
155 transform_ndjson_array_factory(values, false)
157}
158
159fn parse_hec_time(value: &VrlValue) -> Option<DateTime<Utc>> {
162 let n: f64 = match value {
163 VrlValue::Integer(i) => *i as f64,
164 VrlValue::Float(f) => f.into_inner(),
165 VrlValue::Bytes(b) => std::str::from_utf8(b).ok()?.trim().parse().ok()?,
166 VrlValue::Timestamp(dt) => return Some(*dt),
167 _ => return None,
168 };
169 if !n.is_finite() {
170 return None;
171 }
172 const MILLIS_THRESHOLD: f64 = 1e12;
173 if n >= MILLIS_THRESHOLD {
175 DateTime::from_timestamp_millis(n as i64)
176 } else {
177 let secs = n.floor() as i64;
178 let nsecs = ((n - n.floor()) * 1e9) as u32;
179 DateTime::from_timestamp(secs, nsecs)
180 }
181}
182
183fn validate_event(event: &VrlValue) -> Option<(u32, &'static str)> {
186 let VrlValue::Object(obj) = event else {
187 return None;
188 };
189 match obj.get("event") {
190 None => return Some((12, "Event field is required")),
191 Some(value) if is_blank_event(value) => return Some((13, "Event field cannot be blank")),
192 _ => {}
193 }
194 if let Some(time) = obj.get("time")
195 && !matches!(time, VrlValue::Null)
196 && parse_hec_time(time).is_none()
197 {
198 return Some((6, "invalid data format"));
199 }
200 None
201}
202
203fn is_blank_event(value: &VrlValue) -> bool {
205 match value {
206 VrlValue::Null => true,
207 VrlValue::Bytes(b) => std::str::from_utf8(b).is_ok_and(|s| s.trim().is_empty()),
208 _ => false,
209 }
210}
211
212fn hec_event_to_map(
216 event: VrlValue,
217 query_table: Option<&str>,
218) -> Option<(String, VrlValue, Vec<String>)> {
219 let mut obj = match event {
220 VrlValue::Object(obj) => obj,
221 other => {
222 debug!("skipping non-object splunk HEC event: {other:?}");
223 return None;
224 }
225 };
226
227 let ts = obj
229 .remove("time")
230 .as_ref()
231 .and_then(parse_hec_time)
232 .unwrap_or_else(Utc::now);
233
234 let index = match obj.remove("index") {
236 Some(VrlValue::Bytes(b)) => Some(String::from_utf8_lossy(&b).into_owned()),
237 _ => None,
238 };
239 let table = index
240 .as_deref()
241 .and_then(sanitize_index)
242 .or_else(|| query_table.map(str::to_string))
243 .unwrap_or_else(|| DEFAULT_SPLUNK_TABLE.to_string());
244
245 let mut map: BTreeMap<KeyString, VrlValue> = BTreeMap::new();
246 map.insert(
247 KeyString::from(greptime_timestamp()),
248 VrlValue::Timestamp(ts),
249 );
250
251 let mut tag_names: Vec<String> = Vec::new();
252
253 if let Some(VrlValue::Object(fields)) = obj.remove("fields") {
255 for (k, v) in fields {
256 tag_names.push(k.as_str().to_string());
257 map.insert(k, v);
258 }
259 }
260
261 for key in ["host", "source", "sourcetype"] {
263 if let Some(v) = obj.remove(key) {
264 tag_names.push(key.to_string());
265 map.insert(KeyString::from(key), v);
266 }
267 }
268
269 for (k, v) in obj {
271 map.insert(k, v);
272 }
273
274 Some((table, VrlValue::Object(map), tag_names))
275}
276
277fn apply_tag_columns(
282 ctx_req: ContextReq,
283 tag_columns: &HashMap<String, HashSet<String>>,
284) -> ContextReq {
285 let mut reqs = ctx_req.all_req().collect::<Vec<_>>();
286 for req in &mut reqs {
287 let Some(rows) = req.rows.as_mut() else {
288 continue;
289 };
290 let Some(tags) = tag_columns.get(&req.table_name) else {
291 continue;
292 };
293 for col in &mut rows.schema {
294 if tags.contains(&col.column_name) {
295 col.semantic_type = SemanticType::Tag as i32;
296 }
297 }
298 }
299 ContextReq::default_opt_with_reqs(reqs)
300}
301
302fn sanitize_index(raw: &str) -> Option<String> {
304 let trimmed = raw.trim();
305 if trimmed.is_empty() {
306 return None;
307 }
308 if NAME_PATTERN_REG.is_match(trimmed) {
309 return Some(trimmed.to_string());
310 }
311 let mut out = String::with_capacity(trimmed.len());
312 for c in trimmed.chars() {
313 if c.is_ascii_alphanumeric() || matches!(c, '_' | ':' | '-' | '.' | '@' | '#') {
315 out.push(c);
316 } else {
317 out.push('_'); }
319 }
320
321 let first_ok = out
322 .chars()
323 .next()
324 .map(|c| c.is_ascii_alphabetic() || matches!(c, '_' | ':' | '-'))
325 .unwrap_or(false);
326
327 if !first_ok {
328 out.insert(0, '_');
329 }
330
331 NAME_PATTERN_REG.is_match(&out).then_some(out)
332}
333
334pub(crate) fn is_splunk_request<B>(req: &axum::extract::Request<B>) -> bool {
335 req.uri().path().starts_with("/v1/splunk/")
337}
338async fn ingest_events(
340 handler: PipelineHandlerRef,
341 pipeline: PipelineDefinition,
342 requests: Vec<PipelineIngestRequest>,
343 query_ctx: QueryContextRef,
344 pipeline_params: GreptimePipelineParams,
345 tag_columns: HashMap<String, HashSet<String>>,
346 apply_tags: bool,
347) -> Result<HttpResponse> {
348 let exec_timer = Instant::now();
349 let pipeline_ctx = PipelineContext::new(&pipeline, &pipeline_params, query_ctx.channel());
350
351 let mut ctx_req = ContextReq::default();
352 for req in requests {
353 ctx_req.merge(run_pipeline(&handler, &pipeline_ctx, req, &query_ctx, true).await?);
354 }
355
356 let ctx_req = if apply_tags {
357 apply_tag_columns(ctx_req, &tag_columns)
358 } else {
359 ctx_req
360 };
361
362 execute_log_context_req(
363 handler,
364 ctx_req,
365 query_ctx,
366 exec_timer,
367 &METRIC_HTTP_LOGS_INGESTION_COUNTER,
368 &METRIC_HTTP_LOGS_INGESTION_ELAPSED,
369 )
370 .await
371}
372
373#[axum_macros::debug_handler]
376pub async fn handle_health() -> impl IntoResponse {
377 hec_response(StatusCode::OK, HEC_HEALTHY_CODE, "HEC is healthy")
378}
379
380#[axum_macros::debug_handler]
384pub async fn handle_event(
385 State(log_state): State<LogState>,
386 Query(params): Query<LogIngesterQueryParams>,
387 Extension(mut query_ctx): Extension<QueryContext>,
388 headers: HeaderMap,
389 payload: Bytes,
390) -> impl IntoResponse {
391 query_ctx.set_channel(Channel::Splunk);
392 let events = match parse_hec_events(&payload) {
393 Ok(events) => events,
394 Err(_) => return hec_response(StatusCode::BAD_REQUEST, 6, "invalid data format"),
396 };
397 if events.is_empty() {
398 return hec_response(StatusCode::BAD_REQUEST, 5, "No data");
400 }
401
402 let query_table = params.table.as_deref();
404 let mut by_table: HashMap<String, Vec<VrlValue>> = HashMap::new();
405 let mut tag_columns: HashMap<String, HashSet<String>> = HashMap::new();
406 for event in events {
407 if let Some((code, text)) = validate_event(&event) {
410 return hec_response(StatusCode::BAD_REQUEST, code, text);
411 }
412 if let Some((table, map, tags)) = hec_event_to_map(event, query_table) {
413 tag_columns.entry(table.clone()).or_default().extend(tags);
414 by_table.entry(table).or_default().push(map);
415 }
416 }
417 let requests: Vec<PipelineIngestRequest> = by_table
418 .into_iter()
419 .map(|(table, values)| PipelineIngestRequest { table, values })
420 .collect();
421
422 if requests.is_empty() {
424 return hec_response(StatusCode::BAD_REQUEST, 6, "invalid data format");
425 }
426
427 if let Some(bad) = requests
429 .iter()
430 .find(|r| !NAME_PATTERN_REG.is_match(&r.table))
431 {
432 let msg = format!("incorrect index: {}", bad.table);
433 return hec_response(StatusCode::BAD_REQUEST, 7, &msg);
434 }
435
436 resolve_pipeline_and_ingest(
437 log_state,
438 query_ctx,
439 &headers,
440 params.pipeline_name.clone(),
441 params.version.clone(),
442 requests,
443 tag_columns,
444 )
445 .await
446}
447
448#[axum_macros::debug_handler]
455pub async fn handle_raw(
456 State(log_state): State<LogState>,
457 Query(params): Query<SplunkRawQueryParams>,
458 Extension(mut query_ctx): Extension<QueryContext>,
459 headers: HeaderMap,
460 payload: Bytes,
461) -> impl IntoResponse {
462 query_ctx.set_channel(Channel::Splunk);
463
464 if let Some(encoding) = headers.get(header::CONTENT_ENCODING)
467 && encoding.as_bytes() != b"identity"
468 {
469 return hec_response(StatusCode::BAD_REQUEST, 6, "invalid data format");
471 }
472
473 let Ok(body) = std::str::from_utf8(&payload) else {
474 debug!("splunk raw body contains invalid UTF-8; rejecting");
475 return hec_response(StatusCode::BAD_REQUEST, 6, "invalid data format");
477 };
478 let events = split_raw_body(body, params.linebreaker.as_deref());
479 if events.is_empty() {
480 return hec_response(StatusCode::BAD_REQUEST, 5, "No data");
482 }
483
484 let ts = match ¶ms.time {
487 Some(t) => match parse_hec_time(&VrlValue::Bytes(Bytes::from(t.clone()))) {
488 Some(ts) => ts,
489 None => return hec_response(StatusCode::BAD_REQUEST, 6, "invalid data format"),
491 },
492 None => Utc::now(),
493 };
494
495 let table = params
497 .index
498 .as_deref()
499 .and_then(sanitize_index)
500 .or_else(|| params.table.clone())
501 .unwrap_or_else(|| DEFAULT_SPLUNK_TABLE.to_string());
502 if !NAME_PATTERN_REG.is_match(&table) {
504 let msg = format!("Invalid index name: {table}");
505 return hec_response(StatusCode::BAD_REQUEST, 7, &msg);
506 }
507
508 let metadata = raw_metadata(¶ms);
509 let values = events
510 .iter()
511 .map(|event| raw_event_to_map(event, ts, &metadata))
512 .collect();
513 let tag_columns = HashMap::from([(
514 table.clone(),
515 metadata.iter().map(|(key, _)| key.to_string()).collect(),
516 )]);
517 let requests = vec![PipelineIngestRequest { table, values }];
518
519 resolve_pipeline_and_ingest(
520 log_state,
521 query_ctx,
522 &headers,
523 params.pipeline_name.clone(),
524 params.version.clone(),
525 requests,
526 tag_columns,
527 )
528 .await
529}
530
531#[allow(clippy::too_many_arguments)]
536async fn resolve_pipeline_and_ingest(
537 log_state: LogState,
538 mut query_ctx: QueryContext,
539 headers: &HeaderMap,
540 pipeline_name: Option<String>,
541 version: Option<String>,
542 requests: Vec<PipelineIngestRequest>,
543 tag_columns: HashMap<String, HashSet<String>>,
544) -> axum::response::Response {
545 let pipeline_name = pipeline_name.unwrap_or_else(|| {
547 headers
548 .get(GREPTIME_PIPELINE_NAME_HEADER_NAME)
549 .and_then(|v| v.to_str().ok())
550 .unwrap_or(GREPTIME_INTERNAL_IDENTITY_PIPELINE_NAME)
551 .to_string()
552 });
553 let version = match to_pipeline_version(version.as_deref()) {
554 Ok(version) => version,
555 Err(_) => return hec_response(StatusCode::BAD_REQUEST, 6, "invalid pipeline version"),
557 };
558 let apply_tags = pipeline_name == GREPTIME_INTERNAL_IDENTITY_PIPELINE_NAME;
560 if apply_tags {
561 query_ctx.set_extension(SPLUNK_PK_METADATA_ORDER_KEY, "true");
564 }
565 let custom_time_index = Some((format!("{};epoch;ns", greptime_timestamp()), false));
567 let pipeline = match PipelineDefinition::from_name(&pipeline_name, version, custom_time_index) {
568 Ok(pipeline) => pipeline,
569 Err(e) => {
570 error!(e; "failed to resolve splunk pipeline definition: {pipeline_name}");
571 return hec_response(StatusCode::INTERNAL_SERVER_ERROR, 8, "pipeline error");
572 }
573 };
574 let pipeline_params =
575 GreptimePipelineParams::from_map(extract_pipeline_params_map_from_headers(headers));
576
577 match ingest_events(
578 log_state.log_handler,
579 pipeline,
580 requests,
581 Arc::new(query_ctx),
582 pipeline_params,
583 tag_columns,
584 apply_tags,
585 )
586 .await
587 {
588 Ok(_) => hec_response(StatusCode::OK, 0, "Success"),
590 Err(e) => {
591 error!(e; "failed to ingest splunk hec events");
592 let status = status_code_to_http_status(&e.status_code());
594 let code = if status.is_client_error() { 6 } else { 8 };
595 let msg = e.to_string();
596 hec_response(status, code, &msg)
597 }
598 }
599}
600
601#[cfg(test)]
602mod tests {
603 use super::*;
604
605 fn events_for(body: &[u8]) -> Vec<VrlValue> {
606 parse_hec_events(body).unwrap()
607 }
608
609 #[test]
610 fn parses_single_object() {
611 let events = events_for(br#"{"event":"hello","time":1}"#);
612 assert_eq!(events, vec![json!({"event":"hello","time":1}).into()]);
613 }
614
615 #[test]
616 fn parses_concatenated_objects_without_separator() {
617 let events = events_for(br#"{"event":"a"}{"event":"b"}"#);
618 assert_eq!(
619 events,
620 vec![json!({"event":"a"}).into(), json!({"event":"b"}).into()]
621 );
622 }
623
624 #[test]
625 fn parses_newline_and_whitespace_separated_objects() {
626 let events = events_for(b"{\"event\":\"a\"}\n {\"event\":\"b\"}\t{\"event\":\"c\"}");
628 assert_eq!(events.len(), 3);
629 }
630
631 #[test]
632 fn parses_top_level_array_into_flat_events() {
633 let events = events_for(br#"[{"event":"a"},{"event":"b"}]"#);
634 assert_eq!(
635 events,
636 vec![json!({"event":"a"}).into(), json!({"event":"b"}).into()]
637 );
638 }
639
640 #[test]
641 fn flattens_mixed_array_and_trailing_object() {
642 let events = events_for(br#"[{"event":"a"},{"event":"b"}]{"event":"c"}"#);
644 assert_eq!(events.len(), 3);
645 }
646
647 #[test]
648 fn empty_or_whitespace_body_yields_no_events() {
649 assert!(events_for(b"").is_empty());
650 assert!(events_for(b" \n ").is_empty());
651 }
652
653 #[test]
654 fn malformed_json_is_rejected() {
655 assert!(parse_hec_events(br#"{"event":"a"}{bad}"#).is_err());
656 }
657
658 #[test]
661 fn splits_raw_body_only_with_explicit_linebreaker() {
662 assert_eq!(split_raw_body("a\nb\r\nc\n", None), vec!["a\nb\r\nc\n"]);
664 assert!(split_raw_body("", None).is_empty());
666 assert!(split_raw_body(" \n \r\n ", None).is_empty());
667 assert_eq!(split_raw_body("a\nb", Some("")), vec!["a\nb"]);
669
670 assert_eq!(split_raw_body("a\nb\n", Some("\n")), vec!["a", "b"]);
673 assert_eq!(
674 split_raw_body("a\n\n \n\t\nb", Some("\n")),
675 vec!["a", "b"]
676 );
677 assert_eq!(
678 split_raw_body("line one\n indented stack frame", Some("\n")),
679 vec!["line one", " indented stack frame"]
680 );
681 assert_eq!(split_raw_body("a\r\nb", Some("\r\n")), vec!["a", "b"]);
682 assert_eq!(split_raw_body("a::b::c", Some("::")), vec!["a", "b", "c"]);
684 assert!(split_raw_body("\n \n", Some("\n")).is_empty());
686 }
687
688 #[test]
691 fn multiline_raw_body_is_one_event() {
692 let stack_trace = "java.lang.NullPointerException: boom\n\
694 \tat com.example.Foo.bar(Foo.java:42)\n\
695 \tat com.example.Main.main(Main.java:7)";
696 let ts = DateTime::from_timestamp(1447828325, 0).unwrap();
697 let VrlValue::Object(m) = raw_event_to_map(stack_trace, ts, &[]) else {
698 panic!("expected object");
699 };
700 assert_eq!(
701 m.get(RAW_MESSAGE_COLUMN),
702 Some(&VrlValue::from(json!(stack_trace)))
703 );
704 }
705
706 #[test]
707 fn maps_raw_line_with_request_metadata() {
708 let params = SplunkRawQueryParams {
709 host: Some("web-01".to_string()),
710 sourcetype: Some("access_log".to_string()),
711 ..Default::default()
712 };
713 let meta = raw_metadata(¶ms);
714 assert_eq!(
716 meta,
717 vec![
718 ("host", Bytes::from_static(b"web-01")),
719 ("sourcetype", Bytes::from_static(b"access_log"))
720 ]
721 );
722
723 let ts = DateTime::from_timestamp(1447828325, 0).unwrap();
724 let VrlValue::Object(m) = raw_event_to_map("GET /api 200", ts, &meta) else {
725 panic!("expected object");
726 };
727 assert_eq!(
728 m.get(RAW_MESSAGE_COLUMN),
729 Some(&VrlValue::from(json!("GET /api 200")))
730 );
731 assert_eq!(m.get("host"), Some(&VrlValue::from(json!("web-01"))));
732 assert_eq!(
733 m.get("sourcetype"),
734 Some(&VrlValue::from(json!("access_log")))
735 );
736 assert!(!m.contains_key("source"));
738 assert!(matches!(
739 m.get(greptime_timestamp()),
740 Some(VrlValue::Timestamp(dt)) if dt.timestamp() == 1447828325
741 ));
742
743 let default_params = SplunkRawQueryParams::default();
745 let empty = raw_metadata(&default_params);
746 assert!(empty.is_empty());
747 let VrlValue::Object(m) = raw_event_to_map("x", ts, &empty) else {
748 panic!("expected object");
749 };
750 assert_eq!(m.len(), 2);
751 }
752
753 #[test]
754 fn parses_real_raw_client_payloads() {
755 let single = "190.79.85.36 - b0rnc0nfused [13/Jul/2026:05:10:25 +0000] \"GET /money HTTP/2.0\" 300 29099";
758 let ts = DateTime::from_timestamp(1447828325, 0).unwrap();
759 let VrlValue::Object(m) = raw_event_to_map(single, ts, &[]) else {
760 panic!("expected object");
761 };
762 assert_eq!(
763 m.get(RAW_MESSAGE_COLUMN),
764 Some(&VrlValue::from(json!(single)))
765 );
766 }
767
768 #[test]
771 fn parse_time_integer_seconds() {
772 let v: VrlValue = json!(1426279439).into();
773 assert_eq!(parse_hec_time(&v).unwrap().timestamp(), 1426279439);
774 }
775
776 #[test]
777 fn parse_time_fractional_seconds_keeps_millis() {
778 let v: VrlValue = json!(1426279439.5).into();
779 assert_eq!(
781 parse_hec_time(&v).unwrap().timestamp_millis(),
782 1426279439500
783 );
784 }
785
786 #[test]
787 fn parse_time_integer_millis() {
788 let v: VrlValue = json!(1447828325000_i64).into();
789 assert_eq!(parse_hec_time(&v).unwrap().timestamp(), 1447828325);
791 }
792
793 #[test]
794 fn parse_time_string_number() {
795 let v: VrlValue = json!("1426279439").into();
796 assert_eq!(parse_hec_time(&v).unwrap().timestamp(), 1426279439);
797 }
798
799 #[test]
800 fn parse_time_passthrough_timestamp() {
801 let dt = DateTime::from_timestamp_nanos(123_456_789);
802 assert_eq!(parse_hec_time(&VrlValue::Timestamp(dt)), Some(dt));
803 }
804
805 #[test]
806 fn parse_time_missing_or_invalid_is_none() {
807 assert!(parse_hec_time(&VrlValue::Null).is_none());
808 let not_num: VrlValue = json!("not a number").into();
809 assert!(parse_hec_time(¬_num).is_none());
810 let obj: VrlValue = json!({ "x": 1 }).into();
811 assert!(parse_hec_time(&obj).is_none());
812 }
813
814 #[test]
817 fn sanitize_keeps_valid_names() {
818 assert_eq!(sanitize_index("main").as_deref(), Some("main"));
819 assert_eq!(
820 sanitize_index("web-prod.2024").as_deref(),
821 Some("web-prod.2024")
822 );
823 assert_eq!(
824 sanitize_index("cpu:metrics").as_deref(),
825 Some("cpu:metrics")
826 );
827 }
828
829 #[test]
830 fn sanitize_replaces_invalid_chars() {
831 assert_eq!(
832 sanitize_index("my index/v2").as_deref(),
833 Some("my_index_v2")
834 );
835 }
836
837 #[test]
838 fn sanitize_fixes_leading_digit() {
839 assert_eq!(sanitize_index("123logs").as_deref(), Some("_123logs"));
840 }
841
842 #[test]
843 fn sanitize_empty_is_none() {
844 assert!(sanitize_index("").is_none());
845 assert!(sanitize_index(" ").is_none());
846 }
847
848 #[test]
849 fn sanitize_output_is_always_a_valid_table_name() {
850 for raw in [
852 "main",
853 "web-prod.2024",
854 "my index/v2",
855 "123",
856 "@#@#",
857 "...",
858 "日本語 logs",
859 "a/b\\c",
860 ] {
861 if let Some(name) = sanitize_index(raw) {
862 assert!(
863 NAME_PATTERN_REG.is_match(&name),
864 "sanitized {raw:?} -> {name:?} is not a valid table name"
865 );
866 }
867 }
868 }
869
870 #[test]
873 fn map_extracts_metadata_and_routes_by_index() {
874 let event: VrlValue = json!({
875 "time": 1426279439,
876 "host": "web-01",
877 "source": "nginx",
878 "sourcetype": "access",
879 "index": "web_logs",
880 "event": "GET /api 200",
881 "fields": { "region": "us-east" }
882 })
883 .into();
884
885 let (table, map, tags) = hec_event_to_map(event, None).unwrap();
886
887 assert_eq!(table, "web_logs");
889
890 let tagset: HashSet<&str> = tags.iter().map(String::as_str).collect();
892 assert_eq!(
893 tagset,
894 HashSet::from(["host", "source", "sourcetype", "region"])
895 );
896
897 let VrlValue::Object(m) = map else {
898 panic!("expected object");
899 };
900 assert_eq!(m.get("host"), Some(&VrlValue::from(json!("web-01"))));
902 assert_eq!(m.get("region"), Some(&VrlValue::from(json!("us-east"))));
903 assert_eq!(m.get("event"), Some(&VrlValue::from(json!("GET /api 200"))));
904 assert!(!m.contains_key("time"));
906 assert!(matches!(
907 m.get(greptime_timestamp()),
908 Some(VrlValue::Timestamp(dt)) if dt.timestamp() == 1426279439
909 ));
910 assert!(!m.contains_key("index"));
912 assert!(!m.contains_key("fields"));
913 }
914
915 #[test]
916 fn map_falls_back_to_query_table_then_default() {
917 let ev1: VrlValue = json!({ "event": "x" }).into();
918 let (t1, _, _) = hec_event_to_map(ev1, Some("from_query")).unwrap();
919 assert_eq!(t1, "from_query");
920
921 let ev2: VrlValue = json!({ "event": "x" }).into();
922 let (t2, _, _) = hec_event_to_map(ev2, None).unwrap();
923 assert_eq!(t2, "splunk_logs");
924 }
925
926 #[test]
927 fn map_sanitizes_index_for_table() {
928 let ev: VrlValue = json!({ "index": "web/prod", "event": "x" }).into();
929 let (table, _, _) = hec_event_to_map(ev, None).unwrap();
930 assert_eq!(table, "web_prod");
931 }
932
933 #[test]
934 fn map_rejects_non_object_event() {
935 let ev: VrlValue = json!("just a string").into();
936 assert!(hec_event_to_map(ev, None).is_none());
937 }
938
939 #[test]
942 fn validates_event() {
943 let check = |v: serde_json::Value| validate_event(&v.into());
944
945 assert_eq!(
947 check(json!({ "host": "h" })),
948 Some((12, "Event field is required"))
949 );
950 assert_eq!(
952 check(json!({ "event": "" })),
953 Some((13, "Event field cannot be blank"))
954 );
955 assert_eq!(
956 check(json!({ "event": " " })),
957 Some((13, "Event field cannot be blank"))
958 );
959 assert_eq!(
960 check(json!({ "event": null })),
961 Some((13, "Event field cannot be blank"))
962 );
963 assert_eq!(check(json!({ "event": "hello" })), None);
965 assert_eq!(check(json!({ "event": { "a": 1 } })), None);
966 assert_eq!(check(json!({ "event": 0 })), None);
967 assert_eq!(check(json!("just a string")), None);
969
970 let bad_time = Some((6, "invalid data format"));
972 assert_eq!(
973 check(json!({ "event": "x", "time": "not-a-time" })),
974 bad_time
975 );
976 assert_eq!(check(json!({ "event": "x", "time": { "a": 1 } })), bad_time);
977 assert_eq!(check(json!({ "event": "x", "time": 1700000000 })), None);
978 assert_eq!(check(json!({ "event": "x", "time": "1700000000" })), None);
979 assert_eq!(check(json!({ "event": "x" })), None);
981 assert_eq!(check(json!({ "event": "x", "time": null })), None);
982 }
983
984 #[test]
985 fn parses_minimal_events_in_both_batch_forms() {
986 let concatenated = r#"{
989 "event": "event 1",
990 "time": 1447828325
991}
992
993{
994 "event": "event 2",
995 "time": 1447828326
996}"#;
997 let array = r#"[
998 { "event": "event 1", "time": 1447828325 },
999 { "event": "event 2", "time": 1447828326 }
1000]"#;
1001
1002 for body in [concatenated, array] {
1003 let events = parse_hec_events(body.as_bytes()).unwrap();
1004 assert_eq!(events.len(), 2);
1005
1006 let (table, map, tags) =
1007 hec_event_to_map(events.into_iter().next().unwrap(), None).unwrap();
1008 assert_eq!(table, "splunk_logs"); assert!(tags.is_empty()); let VrlValue::Object(m) = map else {
1011 panic!("expected object");
1012 };
1013 assert_eq!(m.get("event"), Some(&VrlValue::from(json!("event 1"))));
1014 assert!(matches!(
1015 m.get(greptime_timestamp()),
1016 Some(VrlValue::Timestamp(dt)) if dt.timestamp() == 1447828325
1017 ));
1018 assert!(!m.contains_key("host"));
1019 }
1020 }
1021
1022 #[test]
1023 fn map_keeps_event_object_for_pipeline_flattening() {
1024 let ev: VrlValue = json!({ "event": { "a": 1 } }).into();
1025 let (_, map, _) = hec_event_to_map(ev, None).unwrap();
1026 let VrlValue::Object(m) = map else {
1027 panic!("expected object");
1028 };
1029 assert!(matches!(m.get("event"), Some(VrlValue::Object(_))));
1030 }
1031
1032 #[test]
1033 fn map_uses_ingest_time_when_time_absent() {
1034 let ev: VrlValue = json!({ "event": "x" }).into();
1035 let (_, map, _) = hec_event_to_map(ev, None).unwrap();
1036 let VrlValue::Object(m) = map else {
1037 panic!("expected object");
1038 };
1039 assert!(matches!(
1040 m.get(greptime_timestamp()),
1041 Some(VrlValue::Timestamp(_))
1042 ));
1043 }
1044
1045 #[test]
1048 fn parses_real_client_payloads() {
1049 let vector = concat!(
1055 r#"{"event":{"message":"GET /api 200","status":"200"},"fields":{"region":"us-east"},"#,
1056 r#""time":1781713834.069,"host":"web-01","index":"main","source":"vector-src","sourcetype":"vector_demo"}"#,
1057 r#"{"event":{"message":"POST /login 401","status":"401"},"fields":{"region":"us-west"},"#,
1058 r#""time":1781713834.119,"host":"web-02","index":"main","source":"vector-src","sourcetype":"vector_demo"}"#,
1059 );
1060 let events = parse_hec_events(vector.as_bytes()).unwrap();
1061 assert_eq!(events.len(), 2); let (table, map, tags) =
1063 hec_event_to_map(events.into_iter().next().unwrap(), None).unwrap();
1064 assert_eq!(table, "main");
1065 let tagset: HashSet<&str> = tags.iter().map(String::as_str).collect();
1066 assert_eq!(
1067 tagset,
1068 HashSet::from(["host", "source", "sourcetype", "region"])
1069 );
1070 let VrlValue::Object(m) = map else {
1071 panic!("expected object");
1072 };
1073 assert!(matches!(
1074 m.get(greptime_timestamp()),
1075 Some(VrlValue::Timestamp(dt)) if dt.timestamp() == 1781713834
1076 ));
1077 assert!(matches!(m.get("event"), Some(VrlValue::Object(_)))); let otel = concat!(
1082 r#"{"event":"{\"level\":\"info\",\"msg\":\"login ok\"}","fields":{"log.file.name":"app.log"},"#,
1083 r#""host":"unknown","source":"otel-src","sourcetype":"otel_st","index":"main","time":1781714234.6849608}"#,
1084 "\n",
1085 r#"{"event":"{\"level\":\"error\",\"msg\":\"disk full\"}","fields":{"log.file.name":"app.log"},"#,
1086 r#""host":"unknown","source":"otel-src","sourcetype":"otel_st","index":"main","time":1781714234.6849632}"#,
1087 );
1088 let events = parse_hec_events(otel.as_bytes()).unwrap();
1089 assert_eq!(events.len(), 2); let (table, map, tags) =
1091 hec_event_to_map(events.into_iter().next().unwrap(), None).unwrap();
1092 assert_eq!(table, "main");
1093 let tagset: HashSet<&str> = tags.iter().map(String::as_str).collect();
1094 assert_eq!(
1096 tagset,
1097 HashSet::from(["host", "source", "sourcetype", "log.file.name"])
1098 );
1099 let VrlValue::Object(m) = map else {
1100 panic!("expected object");
1101 };
1102 assert!(matches!(
1103 m.get(greptime_timestamp()),
1104 Some(VrlValue::Timestamp(dt)) if dt.timestamp() == 1781714234
1105 ));
1106 assert!(matches!(m.get("event"), Some(VrlValue::Bytes(_)))); }
1108
1109 #[test]
1110 fn tag_promotion_is_scoped_per_table() {
1111 use api::v1::{ColumnDataType, ColumnSchema, RowInsertRequest, Rows};
1112
1113 fn field_col(name: &str) -> ColumnSchema {
1114 ColumnSchema {
1115 column_name: name.to_string(),
1116 datatype: ColumnDataType::String as i32,
1117 semantic_type: SemanticType::Field as i32,
1118 datatype_extension: None,
1119 options: None,
1120 }
1121 }
1122 fn req(table: &str, cols: &[&str]) -> RowInsertRequest {
1123 RowInsertRequest {
1124 table_name: table.to_string(),
1125 rows: Some(Rows {
1126 schema: cols.iter().map(|c| field_col(c)).collect(),
1127 rows: vec![],
1128 }),
1129 }
1130 }
1131
1132 let ctx_req =
1135 ContextReq::default_opt_with_reqs(vec![req("a", &["region"]), req("b", &["region"])]);
1136 let mut tags: HashMap<String, HashSet<String>> = HashMap::new();
1137 tags.insert("a".to_string(), HashSet::from(["region".to_string()]));
1138 tags.insert("b".to_string(), HashSet::new());
1139
1140 let out = apply_tag_columns(ctx_req, &tags);
1141
1142 for r in out.ref_all_req() {
1143 let region = r
1144 .rows
1145 .as_ref()
1146 .unwrap()
1147 .schema
1148 .iter()
1149 .find(|c| c.column_name == "region")
1150 .unwrap();
1151 let expected = match r.table_name.as_str() {
1152 "a" => SemanticType::Tag as i32,
1153 "b" => SemanticType::Field as i32, other => panic!("unexpected table {other}"),
1155 };
1156 assert_eq!(region.semantic_type, expected, "table {}", r.table_name);
1157 }
1158 }
1159}