Skip to main content

servers/http/
splunk.rs

1// Copyright 2023 Greptime Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Splunk HTTP Event Collector (HEC) compatible ingestion endpoint.
16//!
17//! Clients point their base endpoint at `/v1/splunk`, so the full paths are e.g.
18//! `/v1/splunk/services/collector/event` and `/v1/splunk/services/collector/health`.
19
20use 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
56/// Default table used when neither the event's `index` nor a `?table=` query
57/// param is provided.
58const DEFAULT_SPLUNK_TABLE: &str = "splunk_logs";
59/// HEC response code for a healthy collector. Splunk returns
60/// `{"text":"HEC is healthy","code":17}`.
61const HEC_HEALTHY_CODE: u32 = 17;
62
63/// Query parameters for `/services/collector/raw`.
64/// `channel` is accepted but ignored until indexer acknowledgment lands. `table`,
65/// `pipeline_name`, `version`, and `linebreaker` are Greptime extensions
66/// (`linebreaker` opts into event breaking; without it the body is one event).
67#[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
81/// Splits a raw body into events. Without `?linebreaker=`, the whole body is ONE
82/// event. With `?linebreaker=<literal>` (percent-encoded, e.g. `%0A` for `\n`),
83/// the body is split on that literal delimiter; whitespace-only segments are
84/// dropped, segment content is kept verbatim.
85fn 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
101/// Column holding the verbatim raw body on `/raw` (Splunk's `_raw`). Named `message`
102/// to avoid clashing with `/event`'s `event` column (whose shape
103/// varies by client: string vs identity-flattened object).
104const RAW_MESSAGE_COLUMN: &str = "message";
105
106/// Collects request-level `/raw` metadata (`host`/`source`/`sourcetype`) present in
107/// the query params. The keys double as the tag-column names; values apply to every
108/// event in the request (HEC `/raw` metadata is request-level, unlike `/event`).
109fn raw_metadata(params: &SplunkRawQueryParams) -> Vec<(&'static str, Bytes)> {
110    [
111        ("host", &params.host),
112        ("source", &params.source),
113        ("sourcetype", &params.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
124/// Maps one raw event to a per-event map: `{ greptime_timestamp: ts, message: <event>,
125/// <metadata columns> }`. The event text is stored as it is.
126fn 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
146/// HEC response body `{"text", "code"}`; clients branch on `code`.
147fn hec_response(status: StatusCode, code: u32, text: &str) -> axum::response::Response {
148    (status, axum::Json(json!({ "text": text, "code": code }))).into_response()
149}
150
151/// Parses a HEC body into a flat list of events. Handles both batch forms: objects
152/// concatenated with any/no separator, and a top-level array (flattened).
153fn parse_hec_events(body: &[u8]) -> Result<Vec<VrlValue>> {
154    let values = Deserializer::from_slice(body).into_iter::<VrlValue>();
155    // ignore_error = false: reject the whole batch on a malformed value.
156    transform_ndjson_array_factory(values, false)
157}
158
159/// HEC `time`: epoch seconds (optionally fractional); values past ~1e12 are read as
160/// milliseconds. `None` if absent/unparseable (caller falls back to ingest time).
161fn 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    // Safe (`Option`-returning) constructors: out-of-range input yields `None`, not a panic.
174    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
183/// `event` missing -> 12, `event` blank -> 13.
184/// present, non-null but unparsable `time` -> 6.
185fn 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
203/// A HEC `event` value is blank if it's `null` or an empty/whitespace-only string.
204fn 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
212/// Maps one HEC event to `(table, per-event map, tag names)`: `time`->timestamp,
213/// `index`->table, host/source/sourcetype/`fields`->tags, `event`+rest->data.
214/// `None` if the event isn't a JSON object.
215fn 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    // Timestamp: HEC `time` is honored first, else ingest time.
228    let ts = obj
229        .remove("time")
230        .as_ref()
231        .and_then(parse_hec_time)
232        .unwrap_or_else(Utc::now);
233
234    // Table routing: `index` (consumed) -> `?table=` -> default.
235    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    // `fields` is flat: spread its keys to top-level columns, all tags.
254    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    // host / source / sourcetype are tags.
262    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    // `event` and any remaining keys are data columns.
270    for (k, v) in obj {
271        map.insert(k, v);
272    }
273
274    Some((table, VrlValue::Object(map), tag_names))
275}
276
277/// Retags `Field` columns to `Tag` per table (identity makes everything a Field) so the
278/// insert path adds them to the primary key. Tags are scoped by table name so a batch
279/// targeting multiple tables can't cross-promote a same-named field. Identity-only:
280/// rebuilds under the default opt.
281fn 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
302/// Coerces a Splunk `index` into a valid table name (`NAME_PATTERN`); `None` if empty.
303fn 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        // body-allowed set
314        if c.is_ascii_alphanumeric() || matches!(c, '_' | ':' | '-' | '.' | '@' | '#') {
315            out.push(c);
316        } else {
317            out.push('_'); // spaces, slashes, unicode, etc. → '_'
318        }
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    // Match only `/v1/splunk/<subpath>`
336    req.uri().path().starts_with("/v1/splunk/")
337}
338/// Like `ingest_logs_inner`, but retags metadata columns (identity default) before insert.
339async 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/// `GET /services/collector/health` (+ `/1.0`). Public (see `PUBLIC_API_PREFIX`),
374/// since clients probe it before sending. `ack`/`token` query params are ignored.
375#[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/// `POST /services/collector/event` (+ `/services/collector`, `/event/1.0` aliases).
381/// Parses HEC events, runs them through the pipeline (identity default, overridable),
382/// and inserts with metadata columns as tags.
383#[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        // HEC code 6 == "invalid data format".
395        Err(_) => return hec_response(StatusCode::BAD_REQUEST, 6, "invalid data format"),
396    };
397    if events.is_empty() {
398        // HEC code 5 == "No data".
399        return hec_response(StatusCode::BAD_REQUEST, 5, "No data");
400    }
401
402    // Map each event -> (table, per-event map, tag names); group by table.
403    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        // Reject the batch on an invalid event: missing/blank `event` (12/13) or an
408        // unparsable `time` (6).
409        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    // Events parsed but none were JSON objects, so nothing is ingestable. HEC code 6 == "invalid data format".
423    if requests.is_empty() {
424        return hec_response(StatusCode::BAD_REQUEST, 6, "invalid data format");
425    }
426
427    // Bad table name (e.g. invalid `?table=`) -> HEC code 7 ("incorrect index").
428    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/// `POST /services/collector/raw` (+ `/raw/1.0` alias). By default, the whole body is
449/// raw text stored verbatim as ONE event in the [`RAW_MESSAGE_COLUMN`] — multiline
450/// payloads (e.g. stack traces) are preserved intact. Explicit framing is opt-in
451/// via `?linebreaker=` (see [`split_raw_body`]). Metadata comes from query params
452/// and applies to every event. `channel` (param or `x-splunk-request-channel` header)
453/// is accepted but ignored until indexer acknowledgment lands;
454#[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    // The decompression layer runs strips `Content-Encoding` when it decompresses,
465    // so a non-identity value means the body is still compressed
466    if let Some(encoding) = headers.get(header::CONTENT_ENCODING)
467        && encoding.as_bytes() != b"identity"
468    {
469        // HEC code 6 == "invalid data format".
470        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        // HEC code 6 == "invalid data format".
476        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        // HEC code 5 == "No data".
481        return hec_response(StatusCode::BAD_REQUEST, 5, "No data");
482    }
483
484    // Request-level default timestamp: `?time=` (epoch) or ingest time. Splunk would
485    // additionally extract per-event timestamps from line content; this doesn't support that yet.
486    let ts = match &params.time {
487        Some(t) => match parse_hec_time(&VrlValue::Bytes(Bytes::from(t.clone()))) {
488            Some(ts) => ts,
489            // HEC code 6 == "invalid data format".
490            None => return hec_response(StatusCode::BAD_REQUEST, 6, "invalid data format"),
491        },
492        None => Utc::now(),
493    };
494
495    // Table routing: `?index=` (sanitized) -> `?table=` -> default.
496    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    // Bad table name (e.g. invalid `?table=`) -> HEC code 7.
503    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(&params);
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/// Shared tail of `/event` and `/raw`: resolves the pipeline (identity default;
532/// overridable via param/header, with an optional `?version=` pin), enables tag
533/// promotion + metadata-first primary-key ordering for the identity path only, runs
534/// the ingest, and maps the outcome to a HEC response.
535#[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    // Pipeline: identity by default; override via `pipeline_name` param or header.
546    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        // HEC code 6 == "invalid data format" (bad `?version=`).
556        Err(_) => return hec_response(StatusCode::BAD_REQUEST, 6, "invalid pipeline version"),
557    };
558    // Only post-process tags for the identity default; respect a user pipeline's schema.
559    let apply_tags = pipeline_name == GREPTIME_INTERNAL_IDENTITY_PIPELINE_NAME;
560    if apply_tags {
561        // Ask the insert path to lead the primary key with our metadata tags. Scoped to the
562        // identity path so a user pipeline's own key order is left untouched.
563        query_ctx.set_extension(SPLUNK_PK_METADATA_ORDER_KEY, "true");
564    }
565    // custom_time_index so timestamp doesn't get overridden by identity pipeline.
566    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        // HEC code 0 == "Success".
589        Ok(_) => hec_response(StatusCode::OK, 0, "Success"),
590        Err(e) => {
591            error!(e; "failed to ingest splunk hec events");
592            // client errors -> HEC code 6, else 8.
593            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        // newline, leading spaces, and a tab between objects — none are required.
627        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        // a top-level array immediately followed by a bare object.
643        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    // ---- split_raw_body ----
659
660    #[test]
661    fn splits_raw_body_only_with_explicit_linebreaker() {
662        // default (no linebreaker): whole body is one event, verbatim.
663        assert_eq!(split_raw_body("a\nb\r\nc\n", None), vec!["a\nb\r\nc\n"]);
664        // empty / whitespace-only body -> no events (HEC code 5 upstream).
665        assert!(split_raw_body("", None).is_empty());
666        assert!(split_raw_body(" \n \r\n ", None).is_empty());
667        // empty linebreaker behaves like none.
668        assert_eq!(split_raw_body("a\nb", Some("")), vec!["a\nb"]);
669
670        // explicit "\n": split; whitespace-only segments dropped;
671        // (a "\r\n"-separated body keeps the "\r" — pass "\r\n" to strip it).
672        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        // multi-char literal delimiters work too.
683        assert_eq!(split_raw_body("a::b::c", Some("::")), vec!["a", "b", "c"]);
684        // whitespace-only after split -> no events.
685        assert!(split_raw_body("\n \n", Some("\n")).is_empty());
686    }
687
688    // ---- raw_metadata / raw_event_to_map ----
689
690    #[test]
691    fn multiline_raw_body_is_one_event() {
692        // `/raw` must NOT split on newlines unless query parameter is provided.
693        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(&params);
714        // present params only; keys are the tag-column names.
715        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        // absent metadata (`source`) makes no column.
737        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        // no query params at all -> just timestamp + message.
744        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        // Shapes captured from Vector's `splunk_hec_logs` sink with
756        // `endpoint_target = "raw"`
757        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    // ---- parse_hec_time ----
769
770    #[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        // the .5s must survive (not be truncated like EpochProcessor would).
780        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        // past the millis threshold -> read as ms, same instant as 1447828325s.
790        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(&not_num).is_none());
810        let obj: VrlValue = json!({ "x": 1 }).into();
811        assert!(parse_hec_time(&obj).is_none());
812    }
813
814    // ---- sanitize_index ----
815
816    #[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        // Invariant: a non-empty input never yields a name the create path would reject.
851        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    // ---- hec_event_to_map ----
871
872    #[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        // `index` -> table name.
888        assert_eq!(table, "web_logs");
889
890        // tags = host/source/sourcetype + each `fields` key.
891        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        // metadata + fields became columns with their values.
901        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        // `time` became the timestamp column, not a `time` column.
905        assert!(!m.contains_key("time"));
906        assert!(matches!(
907            m.get(greptime_timestamp()),
908            Some(VrlValue::Timestamp(dt)) if dt.timestamp() == 1426279439
909        ));
910        // `index` and `fields` are consumed, not columns.
911        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    // ---- validate_event ----
940
941    #[test]
942    fn validates_event() {
943        let check = |v: serde_json::Value| validate_event(&v.into());
944
945        // missing `event` -> code 12.
946        assert_eq!(
947            check(json!({ "host": "h" })),
948            Some((12, "Event field is required"))
949        );
950        // present but blank (empty / whitespace) or null -> code 13.
951        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        // valid: non-empty string, object, or other non-blank value.
964        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        // non-object events aren't validated here (handled by `hec_event_to_map`).
968        assert_eq!(check(json!("just a string")), None);
969
970        // present but unparsable `time` -> code 6 (number string / numeric are fine).
971        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        // absent or null `time` falls back to ingest time, so it's allowed.
980        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        // Splunk docs "Example 3": minimal events (`event` + `time` only), sent both as
987        // concatenated objects (whitespace-separated) and as a JSON array.
988        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"); // no `index` -> default table
1009            assert!(tags.is_empty()); // no host/source/sourcetype/fields
1010            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    // ---- real client payload ----
1046
1047    #[test]
1048    fn parses_real_client_payloads() {
1049        // Shapes captured from real `splunk_hec` clients (values trimmed, structure
1050        // verbatim). The two clients deliberately disagree on batch separator,
1051        // `event` type, and `fields` keys — the parser must handle all of it.
1052
1053        // --- Vector splunk_hec sink: NO separator; `event` is an object. ---
1054        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); // concatenated, no separator
1062        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(_)))); // event is an object
1078
1079        // --- OTel Collector splunk_hec exporter: NEWLINE-separated; `event` is a
1080        //     string; a `fields` key contains dots. ---
1081        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); // newline-separated
1090        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        // a dotted `fields` key still becomes a tag column.
1095        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(_)))); // event is a string
1107    }
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        // One batch -> two tables, both with a `region` column. `region` is a tag in
1133        // table "a" only; table "b"'s same-named field must NOT be promoted.
1134        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, // would have been Tag before the per-table fix
1154                other => panic!("unexpected table {other}"),
1155            };
1156            assert_eq!(region.semantic_type, expected, "table {}", r.table_name);
1157        }
1158    }
1159}