在 filter 里写 add_field => { "[@metadata][target_index]" => "logs-web" },output 的 index => 里用 %{[@metadata][target_index]} 能取到值,但写进 Elasticsearch 的 _source 里找不到这个字段。这件事的机制既不在 output 插件里,也不在 codec 里,而在 Event API 上:org.logstash.Event 有两个平级的 ConvertedMap 字段 datametadatato_hash 返回的只是 data,要连 metadata 一起拿必须改调 to_hash_with_metadata。凡是走 to_hash 的下游,看到的就是一个没有 @metadata 的 event——不需要任何人动手剥离。

上一篇确立了 JRuby/JVM/多 pipeline 的运行形态和本系列的版本前提。这一篇进入 event 的内部结构:字段空间怎么分层,@timestamp 的语义边界在哪,以及字段引用语法里那几对看着等价其实不等价的写法。

数据流全景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
Input (stdin / beats / kafka / ...)
│ bytes

[ Codec: decode ]
│ 构造 Event 对象

┌──────────────────────────────────────────────────────┐
│ org.logstash.Event │
│ │
│ ConvertedMap data │
│ ┌────────────────────────────────────────────┐ │
│ │ message : "GET /index.html 200" │ │
│ │ host : "web-01" │ │
│ │ status : 200 │ │
│ │ @timestamp : 2026-08-06T13:20:00.000Z │ │
│ │ @version : "1" │ │
│ └────────────────────────────────────────────┘ │
│ │
│ ConvertedMap metadata(与 data 平级的另一个字段) │
│ ┌────────────────────────────────────────────┐ │
│ │ target_index : "logs-web" │ │
│ │ retry_count : 0 │ │
│ └────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
│ Filter 阶段:data 与 metadata 都可读可写

Output:绝大多数插件调 event.to_hash,拿到的只有 data


目标 (elasticsearch / kafka / file / ...)

@timestamp@versiondata 里,和 messagestatus 这些业务字段住在同一个 map,区别只在于命名约定和插件对它们的特殊对待。@metadata 则不在 data 里:Event 的构造函数会把传入 map 里的 @metadata 键取出来,搬进那个独立的 metadata 字段。所以"@metadata 不进输出"不是一次剥离动作的结果,而是它从来就没在被序列化的那份数据里。

@timestamp:第一等公民

@timestamp 是 event 进入 pipeline 的时间标记,类型为 LogStash::Timestamp,底层对应 java.time.Instant

自 Logstash 8.0 起,内部的时间表示支持纳秒粒度,序列化成 ISO-8601 字符串时最多带 9 位小数:

1
2026-08-06T13:20:00.123456789Z

实际能拿到多少位取决于 JVM 与平台的时钟分辨率,官方对此的措辞是多数情况下到微秒;纳秒位为 0 时 Timestamp.toString() 退回固定 3 位小数。这件事和 pipeline.ecs_compatibility 是两回事——后者管的是插件写字段时的命名与落位(host 还是 [host][hostname]),跟时间精度无关。

行为要点:

  • input 插件在 decode 阶段(codec 调用 LogStash::Event.new)自动赋值为当前时间;如果原始数据中已有时间字段,需要显式用 date filter 解析并覆盖 @timestamp,否则它记录的是到达时间而非事件发生时间。
  • @timestamp 是可以删掉的。Event.remove 对它没有任何保护,走的是和普通字段一样的删除分支,event.remove("[@timestamp]") 会成功。删掉之后 getTimestamp() 返回 null,%{+YYYY.MM.dd} 一类的时间插值会展开成空串,Elasticsearch 侧的时间路由和 data stream 写入也都会出问题。所以实践上只用 date filter 覆盖它,不删。
  • Elasticsearch output 默认将 @timestamp 映射为 _source.@timestamp,同时用于索引名称模板(如 logstash-%{+YYYY.MM.dd})。

@metadata:管道内部暂存区

@metadata 在 filter 阶段可以像普通字段一样读写,但它不在 Eventdata 里,所以任何调 to_hash 取数据的下游都看不到它。

Elasticsearch output 是这条机制最容易被讲错的地方。它根本没有 codec 配置项——bulk 请求体是它自己拼的,每个 event 走一次 event.to_hash 转成 JSON 放进 action 三元组。这条路径上没有任何"剥离 metadata"的代码,@metadata 不进 _source 完全是因为它不在 to_hash 的返回值里。

反过来,会显示 metadata 的只有主动去要的那一方。rubydebug codec 的 metadata 选项默认 false,此时走 event.to_hash;设成 true 之后它改调 event.to_hash_with_metadata,才会把 @metadata 打出来。

典型用途:

  • 携带路由决策信息([@metadata][target_index]),在 output 中引用但不写入 Elasticsearch。
  • 记录重试次数或处理标记,避免污染业务字段。
  • Beats input 把协议侧信息放进这里:[@metadata][beat][@metadata][version],ECS 模式下还有 [@metadata][input][beats][host][name][@metadata][input][beats][host][ip]。filter 阶段可读取,但不会意外输出到下游。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# logstash.conf 示例
filter {
mutate {
add_field => {
"[@metadata][target_index]" => "logs-%{[service][name]}"
"[@metadata][retry_count]" => "0"
}
}
}

output {
elasticsearch {
index => "%{[@metadata][target_index]}"
# target_index 不会出现在 _source 中
}
}

字段引用语法

Logstash 使用方括号语法引用字段,与 JSON path 类似但有自己的规则:

写法 含义
[message] 顶层字段 message
[host][hostname] 嵌套字段:host 下的 hostname。注意不能写成 host.hostname,那会被解析成一个名叫 host.hostname 的顶层字段
[@timestamp] 保留字段(@前缀)
[@metadata][key] @metadata 子字段
%{[field]} sprintf 格式,用于字符串插值
%{+YYYY.MM.dd} Joda-Time 格式化 @timestamp;官方已把 joda 格式标为 deprecated
%{{yyyy.MM.dd}} java.time 格式化 @timestamp,官方推荐的写法,注意是两层花括号
%{{TIME_NOW}} 取求值那一刻的当前时间,而不是 @timestamp,用来量各阶段耗时

字段引用在 if 条件、mutate 插件、output 的 index/document_id 参数中普遍使用。

新旧两种日期格式不能互换:%{+YYYY.MM.dd} 里的模式串交给 Joda 的 DateTimeFormat%{{yyyy.MM.dd}} 交给 java.time.format.DateTimeFormatter,同一个字母在两套体系里的语义未必一致。已有配置里的 joda 写法仍然工作,新写的建议直接用双花括号那种。

1
2
3
4
5
6
7
8
9
10
11
12
13
# 条件分支
filter {
if [status] >= 400 {
mutate { add_tag => ["error"] }
}
}

# sprintf 插值(ECS v8 下 host 是嵌套字段,日期用 java.time 写法)
output {
file {
path => "/var/log/logstash/%{[host][hostname]}-%{{yyyy-MM-dd}}.log"
}
}

filter 插件的 event API

filter 插件通过 event 对象操作字段:

方法 说明
event.get("[field]") 读取字段值,不存在时返回 nil
event.set("[field]", value) 写入字段值
event.remove("[field]") 删除字段
event.tag("_error") tags 数组追加标签
event.cancel 取消 event,阻止其进入 output
event.to_hash data,不含 @metadata
event.to_hash_with_metadata data 并在 @metadata 非空时把它挂上

ruby filter 中可直接调用这些方法:

1
2
3
4
5
6
7
8
9
filter {
ruby {
code => '
count = event.get("[@metadata][retry_count]").to_i
event.set("[@metadata][retry_count]", count + 1)
event.cancel if count > 3
'
}
}

类型与强制转换

所有从文本来源(stdin、file input 的单行读取)进入 pipeline 的字段,初始类型均为字符串。数值比较或数值运算前需要显式转换:

1
2
3
4
5
6
7
8
9
filter {
mutate {
convert => {
"status" => "integer"
"response_time" => "float"
"retried" => "boolean"
}
}
}

Beats input 走 lumberjack v2 协议,数据帧的 payload 是 JSON(可选 zlib 压缩,帧类型上区分 CODE_JSON_FRAMECODE_COMPRESSED_FRAME)。JSON 本身带类型,数字和布尔值解码后就是数字和布尔值,所以从 Beats 来的字段不需要再走一遍 convert

可运行实验

目标:在同一次运行里让同一个 event 走两条不同的取数路径,直接看出 @metadata 的可见性由谁决定。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# /tmp/metadata_test.conf
input {
stdin {}
}

filter {
mutate {
add_field => {
"visible_field" => "this appears in output"
"[@metadata][hidden]" => "this should NOT appear"
}
}
}

output {
# metadata 默认 false,走 event.to_hash
stdout { codec => rubydebug }

# metadata => true,改走 event.to_hash_with_metadata
stdout { codec => rubydebug { metadata => true } }

# json_lines 的 encode 调 event.to_json,序列化的也只有 data
file {
path => "/tmp/logstash_out.json"
codec => json_lines
}
}
1
2
bin/logstash --pipeline.ecs_compatibility=disabled -f /tmp/metadata_test.conf
# 输入:hello world

两块 stdout 的打印顺序不保证,但内容差异是确定的。默认那块:

1
2
3
4
5
6
7
{
"message" => "hello world",
"@timestamp" => 2026-08-06T13:20:00.000Z,
"@version" => "1",
"host" => "web-01",
"visible_field" => "this appears in output"
}

metadata => true 的那块多出一整个 @metadata

1
2
3
4
5
6
7
8
9
10
{
"message" => "hello world",
"@timestamp" => 2026-08-06T13:20:00.000Z,
"@version" => "1",
"host" => "web-01",
"visible_field" => "this appears in output",
"@metadata" => {
"hidden" => "this should NOT appear"
}
}

/tmp/logstash_out.json 里的那一行:

1
{"message":"hello world","@timestamp":"2026-08-06T13:20:00.000Z","@version":"1","host":"web-01","visible_field":"this appears in output"}

同一个 event、同一次运行,三处输出的差别只在于下游取的是 data 还是 datametadatato_hashto_json 都只碰 data,只有 to_hash_with_metadata 会把 metadata 挂上去。

把命令行里的 --pipeline.ecs_compatibility=disabled 去掉重跑一遍,host 会变成 "host" => {"hostname" => "web-01"},并且多出一个 [event][original]。这是 ECS v8 默认带来的字段落位差异,和 @metadata 的可见性是两条互不相干的机制。

再演示 sprintf 字段引用:

1
2
3
4
5
6
7
8
9
output {
stdout {
codec => line {
# 上面的实验跑在 disabled 模式,host 是扁平字段;
# ECS v8 下这里要改成 %{[host][hostname]}
format => "%{{yyyy-MM-dd HH:mm:ss}} [%{host}] %{message}"
}
}
}

关键对象映射

event 概念 对应 Java/JRuby 类 说明
event 本体 org.logstash.Event 持有两个平级字段 ConvertedMap dataConvertedMap metadata
@timestamp org.logstash.Timestamp 封装 java.time.Instant,存放在 data
@metadata Eventmetadata 字段 构造时从传入 map 里摘出来,不在 data
字段引用解析 FieldReference [a][b] 解析为路径数组,@metadata 前缀走 META_PARENT/META_CHILD 分支
tag 操作 tags 字段(字符串数组) event.tag(x) 追加

模式提炼

event 的字段空间在实现上只分两块:datametadata@timestamp@version 和业务字段一起住在 data 里,靠命名约定和插件的特殊对待区分;metadata 是完全独立的另一个 map。

由此推出的是一条比"处理完成后剥离"更硬的判据:一个字段会不会进输出,只取决于它在哪个 map 里,以及下游插件调的是 to_hash 还是 to_hash_with_metadata。不需要去查每个 output 插件或每个 codec 有没有做剥离动作——那层代码根本不存在。同一个模式在别处也成立:数据结构上的隔离比流程上的过滤更可靠,因为前者没有"忘了过滤"这种失效方式。

@timestamp 的语义是事件的时间维度锚点,date filter 的职责是把原始日志里的时间字符串解析后写进 @timestamp,使它从到达时间变为发生时间。这一步是精确时序分析的前提。

工程迁移表

Logstash event 概念 Kafka Record 对应 Flink StreamRecord 对应 结构化日志对应
业务字段 value(反序列化后的 Map) value log fields (level/msg/…)
@timestamp timestamp(毫秒 epoch) timestamp + watermark timestamp 字段
@metadata headers(producer/consumer 元数据) 无直接对应,常用 SideOutput MDC / context map(不输出)
@version 无直接对应 无直接对应 schema version 字段
event.cancel filter 中 drop()或不 forward filter 中不调用 collector.collect 过滤条件
tags 数组 无直接对应,常用 header 无直接对应 labels / tags map

常见误解

误解一:“@metadata 是 Logstash 自动加的,不用管。”
实际情况:这话把责任方搞反了。Logstash 核心几乎不往里面写东西,@metadata 的内容基本来自 input 插件和使用者自己写的 filter,语义完全由配置决定。当成"系统自动维护的角落"看待,就会错过用它承载路由决策和处理状态的整个用法——那是这块空间存在的理由。

误解二:“@timestamp 就是 Logstash 收到日志的时间,直接用就行。”
实际情况:不经过 date filter 处理时,@timestamp 是 event 进入 input codec 的时刻,而非日志的原始发生时间。对于有历史回溯需求或时序分析的场景,必须用 date filter 从 message 中解析真实时间并覆盖 @timestamp

误解三:“字段引用 [host][host][hostname] 是一样的。”
实际情况:前者引用整个 host 对象(ECS 模式下是一个 map),后者引用它内部的 hostname 子字段(字符串)。在条件表达式中混用会导致类型不匹配。而如果为了省事写成 host.hostname,语法上合法但语义完全不同:那是一个名字里带点号的顶层字段,取不到任何东西。

误解四:“rubydebug 输出就是最终写入 Elasticsearch 的内容。”
实际情况:两处对不上。一是 @metadata:rubydebug 的 metadata 默认 false,开成 true 之后会多打一整个 @metadata,而 Elasticsearch 拿到的永远没有它。二是格式:rubydebug 打的是 Ruby 的 inspect 形式,@timestamp 显示为 Timestamp 对象,而 _source 里是 ISO-8601 字符串。用 rubydebug 确认字段结构没问题,把它当 _source 的预览就会踩坑。

练习

  1. 编写一条 pipeline,从 stdin 读取 Apache access log 格式的文本(如 127.0.0.1 - - [06/Aug/2026:13:20:00 +0800] "GET / HTTP/1.1" 200 1234),用 grok filter 提取字段,再用 date filter 将提取的时间覆盖 @timestamp,最后用 rubydebug 输出,对比处理前后 @timestamp 的值。然后在 output 里同时用 %{+YYYY.MM.dd}%{{yyyy.MM.dd}} 生成两个字段,确认它们对同一个 @timestamp 给出相同结果。

  2. ruby filter 在同一个 event 上先后调用 event.to_hash.keysevent.to_hash_with_metadata.keys,把两个结果分别写进两个普通字段再输出,看 @metadata 出现在哪一个里面。然后试着 event.remove("[@timestamp]"),观察删除是否成功、%{+YYYY.MM.dd} 展开成了什么。

系列导航

序号 主题
00 导读:核心对象是 event,骨架是三段管道
01 架构:JRuby、JVM 与 pipeline 的运行形态
02 event 模型:@timestamp、@metadata 与字段引用(本篇)
03 codec:字节流与 event 的边界转换
04 input 插件:拉取、监听与 Beats 接入
05 Grok 的本质:命名正则加预定义 pattern
06 dissect 与结构化 filter:放弃回溯换吞吐
07 常用 filter 组合:mutate、date、geoip 与条件
08 output 插件:Elasticsearch output 与批量写入
09 pipeline 执行模型:worker、batch 与背压
10 内存队列 vs 持久队列:可靠性的分界线
11 死信队列(DLQ):无法处理的 event 去哪
12 Multiple Pipelines 与 pipeline-to-pipeline
13 监控:Node Stats API、hot threads 与瓶颈定位
14 性能调优:JVM heap、批处理与持久队列磁盘
15 Logstash vs Beats vs Ingest Pipeline:该用谁
16 Logstash vs Fluentd vs Vector:日志管道的三种取舍
17 Logstash 的演进与 Elastic Agent 的冲击

参考资料