Skip to content

Commit 2e949e6

Browse files
authored
otel: option to preserve at metadata field in log record body (#50191)
* otel: add tests for kafkaexporter
1 parent abb6e18 commit 2e949e6

6 files changed

Lines changed: 173 additions & 70 deletions

File tree

libbeat/beat/info.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,11 @@ type Info struct {
4343
UserAgent string // A string of the user-agent that can be passed to any outputs or network connections
4444
FIPSDistribution bool // If the beat was compiled as a FIPS distribution.
4545

46-
LogConsumer consumer.Logs // otel log consumer
47-
ComponentID string // otel component id from the collector config e.g. "filebeatreceiver/logs"
48-
Logger *logp.Logger
49-
Paths *paths.Path // per beat paths definition
46+
LogConsumer consumer.Logs // otel log consumer
47+
ComponentID string // otel component id from the collector config e.g. "filebeatreceiver/logs"
48+
IncludeMetadata bool // when true, otelconsumer includes @metadata in the log record body
49+
Logger *logp.Logger
50+
Paths *paths.Path // per beat paths definition
5051
}
5152

5253
func (i Info) FQDNAwareHostname(useFQDN bool) string {

libbeat/otel/otelconsumer/otelconsumer.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,15 @@ func (out *otelConsumer) logsPublish(ctx context.Context, batch publisher.Batch)
143143
if beatEvent == nil {
144144
beatEvent = mapstr.M{}
145145
}
146+
147+
if out.beatInfo.IncludeMetadata {
148+
meta := event.Content.Meta.Clone()
149+
meta["beat"] = out.beatInfo.Beat
150+
meta["version"] = out.beatInfo.Version
151+
meta["type"] = "_doc"
152+
beatEvent["@metadata"] = meta
153+
}
154+
146155
beatEvent["@timestamp"] = event.Content.Timestamp
147156
logRecord.SetTimestamp(pcommon.NewTimestampFromTime(event.Content.Timestamp))
148157

x-pack/filebeat/fbreceiver/config_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,16 @@ func TestUnmarshal(t *testing.T) {
7575
})
7676
}
7777

78+
func TestUnmarshalIncludeMetadata(t *testing.T) {
79+
cfg := &Config{}
80+
userConf := confmap.NewFromStringMap(map[string]any{
81+
"include_metadata": true,
82+
"filebeat": map[string]any{"inputs": []any{}},
83+
})
84+
require.NoError(t, cfg.Unmarshal(userConf))
85+
assert.Equal(t, true, cfg.Beatconfig["include_metadata"])
86+
}
87+
7888
func TestValidate(t *testing.T) {
7989
tests := map[string]struct {
8090
c *Config

x-pack/filebeat/tests/integration/otel_kafka_test.go

Lines changed: 78 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,18 @@
77
package integration
88

99
import (
10+
"encoding/json"
1011
"fmt"
1112
"path/filepath"
1213
"testing"
1314
"time"
1415

1516
"github.com/gofrs/uuid/v5"
16-
"github.com/google/go-cmp/cmp"
17+
"github.com/stretchr/testify/assert"
1718
"github.com/stretchr/testify/require"
18-
"go.opentelemetry.io/collector/pdata/pcommon"
19-
"go.opentelemetry.io/collector/pdata/plog"
2019

21-
"github.com/elastic/beats/v7/libbeat/otel/otelmap"
20+
"github.com/elastic/beats/v7/libbeat/tests/integration"
21+
"github.com/elastic/beats/v7/x-pack/otel/oteltest"
2222
"github.com/elastic/beats/v7/x-pack/otel/oteltestcol"
2323
"github.com/elastic/elastic-agent-libs/mapstr"
2424
"github.com/elastic/sarama"
@@ -27,15 +27,19 @@ import (
2727
// kafkaBroker is the Kafka OUTSIDE listener, which advertises localhost:9094
2828
const kafkaBroker = "localhost:9094"
2929

30-
func TestFilebeatOTelKafkaExporter(t *testing.T) {
31-
topic := fmt.Sprintf("test-otel-kafka-%s", uuid.Must(uuid.NewV4()).String())
30+
func TestFilebeatOTelKafkaE2E(t *testing.T) {
31+
numEvents := 1
3232

3333
tmpdir := t.TempDir()
34-
logFilePath := filepath.Join(tmpdir, "kafka_test.log")
35-
writeEventsToLogFile(t, logFilePath, 1)
34+
fbTopic := fmt.Sprintf("test-fb-kafka-%s", uuid.Must(uuid.NewV4()).String())
35+
otelTopic := fmt.Sprintf("test-otel-kafka-%s", uuid.Must(uuid.NewV4()).String())
36+
37+
logFilePath := filepath.Join(tmpdir, "kafka_e2e.log")
38+
writeEventsToLogFile(t, logFilePath, numEvents)
3639

3740
otelCfg := fmt.Sprintf(`receivers:
3841
filebeatreceiver:
42+
include_metadata: true
3943
filebeat:
4044
inputs:
4145
- type: filestream
@@ -45,6 +49,11 @@ func TestFilebeatOTelKafkaExporter(t *testing.T) {
4549
- %s
4650
prospector.scanner.fingerprint.enabled: false
4751
file_identity.native: ~
52+
processors:
53+
- add_host_metadata: ~
54+
- add_cloud_metadata: ~
55+
- add_docker_metadata: ~
56+
- add_kubernetes_metadata: ~
4857
queue.mem.flush.timeout: 0s
4958
setup.template.enabled: false
5059
path.home: %s
@@ -54,7 +63,7 @@ exporters:
5463
- %s
5564
logs:
5665
topic: %s
57-
encoding: otlp_json
66+
encoding: raw
5867
service:
5968
pipelines:
6069
logs:
@@ -65,10 +74,68 @@ service:
6574
telemetry:
6675
metrics:
6776
level: none
68-
`, logFilePath, tmpdir, kafkaBroker, topic)
77+
`, logFilePath, tmpdir, kafkaBroker, otelTopic)
6978

7079
oteltestcol.New(t, otelCfg)
7180

81+
fbCfg := fmt.Sprintf(`
82+
filebeat.inputs:
83+
- type: filestream
84+
id: filestream-input-id
85+
enabled: true
86+
file_identity.native: ~
87+
prospector.scanner.fingerprint.enabled: false
88+
paths:
89+
- %s
90+
output:
91+
kafka:
92+
hosts:
93+
- %s
94+
topic: %s
95+
queue.mem.flush.timeout: 0s
96+
setup.template.enabled: false
97+
processors:
98+
- add_host_metadata: ~
99+
- add_cloud_metadata: ~
100+
- add_docker_metadata: ~
101+
- add_kubernetes_metadata: ~
102+
`, logFilePath, kafkaBroker, fbTopic)
103+
104+
filebeat := integration.NewBeat(t, "filebeat", "../../filebeat.test")
105+
filebeat.WriteConfigFile(fbCfg)
106+
filebeat.Start()
107+
defer filebeat.Stop()
108+
109+
otelMsg := consumeKafkaTopic(t, otelTopic)
110+
fbMsg := consumeKafkaTopic(t, fbTopic)
111+
112+
t.Logf("otel kafka message: %s", string(otelMsg))
113+
t.Logf("filebeat kafka message: %s", string(fbMsg))
114+
115+
var otelBody, fbBody mapstr.M
116+
require.NoError(t, json.Unmarshal(otelMsg, &otelBody), "OTel kafka message is not valid JSON")
117+
require.NoError(t, json.Unmarshal(fbMsg, &fbBody), "filebeat kafka message is not valid JSON")
118+
119+
assert.NotEmpty(t, otelBody["@metadata"], "expected @metadata to be present in OTel kafka message")
120+
121+
ignoredFields := []string{
122+
"@timestamp",
123+
"agent.ephemeral_id",
124+
"agent.id",
125+
"log.file.inode",
126+
"log.file.device_id",
127+
}
128+
129+
oteltest.AssertMapsEqual(t, fbBody, otelBody, ignoredFields, "expected documents to be equal")
130+
131+
assert.Equal(t, "filebeat", otelBody.Flatten()["agent.type"], "expected agent.type to be 'filebeat' in otel doc")
132+
assert.Equal(t, "filebeat", fbBody.Flatten()["agent.type"], "expected agent.type to be 'filebeat' in filebeat doc")
133+
}
134+
135+
// consumeKafkaTopic waits for a topic to appear and returns the first message received.
136+
func consumeKafkaTopic(t *testing.T, topic string) []byte {
137+
t.Helper()
138+
72139
cfg := sarama.NewConfig()
73140
cfg.Consumer.Return.Errors = true
74141

@@ -88,14 +155,12 @@ service:
88155
require.NoError(t, err, "failed to create Kafka consumer")
89156
t.Cleanup(func() { _ = consumer.Close() })
90157

91-
// Wait for the topic
92158
msgs := make(chan *sarama.ConsumerMessage, 10)
93159
require.Eventually(t, func() bool {
94160
partitions, err := consumer.Partitions(topic)
95161
if err != nil || len(partitions) == 0 {
96162
return false
97163
}
98-
// Spin up one goroutine per partition forwarding into msgs.
99164
for _, partition := range partitions {
100165
pc, err := consumer.ConsumePartition(topic, partition, sarama.OffsetOldest)
101166
if err != nil {
@@ -111,7 +176,6 @@ service:
111176
return true
112177
}, 30*time.Second, 500*time.Millisecond, "topic %q did not appear within 30s", topic)
113178

114-
// Poll until at least one message arrives.
115179
var received []byte
116180
require.Eventually(t, func() bool {
117181
select {
@@ -123,57 +187,5 @@ service:
123187
}
124188
}, 30*time.Second, 500*time.Millisecond, "no message received from Kafka topic %q", topic)
125189

126-
t.Logf("received Kafka message: %s", string(received))
127-
128-
// Decode the OTLP JSON payload.
129-
unmarshaler := &plog.JSONUnmarshaler{}
130-
logs, err := unmarshaler.UnmarshalLogs(received)
131-
require.NoError(t, err, "Kafka message is not valid OTLP JSON")
132-
require.Equal(t, 1, logs.ResourceLogs().Len(), "expected 1 resource log")
133-
134-
scopeLogs := logs.ResourceLogs().At(0).ScopeLogs()
135-
require.Equal(t, 1, scopeLogs.Len(), "expected 1 scope log")
136-
137-
logRecords := scopeLogs.At(0).LogRecords()
138-
require.Equal(t, 1, logRecords.Len(), "expected 1 log record")
139-
140-
body := logRecords.At(0).Body()
141-
require.Equal(t, pcommon.ValueTypeMap, body.Type(), "expected log record body to be a map (bodymap encoding)")
142-
143-
got := otelmap.ToMapstr(body.Map()).Flatten()
144-
145-
// Check non-deterministic fields are present
146-
agentVersion, _ := got.GetValue("agent.version")
147-
require.NotEmpty(t, agentVersion, "expected agent.version to be set")
148-
agentID, _ := got.GetValue("agent.id")
149-
require.NotEmpty(t, agentID, "expected agent.id to be set")
150-
agentEphemeralID, _ := got.GetValue("agent.ephemeral_id")
151-
require.NotEmpty(t, agentEphemeralID, "expected agent.ephemeral_id to be set")
152-
hostName, _ := got.GetValue("host.name")
153-
require.NotEmpty(t, hostName, "expected host.name to be set")
154-
timestamp, _ := got.GetValue("@timestamp")
155-
require.NotEmpty(t, timestamp, "expected @timestamp to be set")
156-
157-
// Remove non-deterministic fields before comparison.
158-
_ = got.Delete("@timestamp")
159-
_ = got.Delete("agent.id")
160-
_ = got.Delete("agent.ephemeral_id")
161-
_ = got.Delete("agent.name")
162-
_ = got.Delete("agent.version")
163-
_ = got.Delete("host.name")
164-
_ = got.Delete("log.file.device_id")
165-
_ = got.Delete("log.file.inode")
166-
167-
want := mapstr.M{
168-
"message": "Line 0",
169-
"agent.type": "filebeat",
170-
"input.type": "filestream",
171-
"ecs.version": "8.0.0",
172-
"log.offset": int64(0),
173-
"log.file.path": logFilePath,
174-
}
175-
176-
if diff := cmp.Diff(want, got); diff != "" {
177-
t.Fatalf("log event fields mismatch (-want +got):\n%s", diff)
178-
}
190+
return received
179191
}

x-pack/filebeat/tests/integration/otel_test.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2029,6 +2029,70 @@ service:
20292029
}
20302030
}
20312031

2032+
func BenchmarkOTelConsumerIncludeMetadata(b *testing.B) {
2033+
const eventCount = 1000
2034+
2035+
for _, tc := range []struct {
2036+
name string
2037+
includeMetadata bool
2038+
}{
2039+
{name: "WithoutMetadata", includeMetadata: false},
2040+
{name: "WithMetadata", includeMetadata: true},
2041+
} {
2042+
b.Run(tc.name, func(b *testing.B) {
2043+
for b.Loop() {
2044+
b.StopTimer()
2045+
tmpDir := b.TempDir()
2046+
2047+
cfg := renderOtelConfig(b, `receivers:
2048+
filebeatreceiver:
2049+
include_metadata: {{.IncludeMetadata}}
2050+
filebeat:
2051+
inputs:
2052+
- type: benchmark
2053+
enabled: true
2054+
count: {{.EventCount}}
2055+
path.home: {{.PathHome}}
2056+
queue.mem.flush.timeout: 0s
2057+
exporters:
2058+
debug:
2059+
verbosity: detailed
2060+
service:
2061+
pipelines:
2062+
logs:
2063+
receivers:
2064+
- filebeatreceiver
2065+
exporters:
2066+
- debug
2067+
telemetry:
2068+
logs:
2069+
level: DEBUG
2070+
metrics:
2071+
level: none
2072+
`, struct {
2073+
PathHome string
2074+
EventCount int
2075+
IncludeMetadata bool
2076+
}{
2077+
PathHome: tmpDir,
2078+
EventCount: eventCount,
2079+
IncludeMetadata: tc.includeMetadata,
2080+
})
2081+
2082+
b.StartTimer()
2083+
2084+
col := oteltestcol.New(b, cfg)
2085+
require.NotNil(b, col)
2086+
require.Eventually(b, func() bool {
2087+
return col.ObservedLogs().
2088+
FilterMessageSnippet("Publish event").Len() == eventCount
2089+
}, 30*time.Second, 1*time.Millisecond, "expected all events to be published")
2090+
col.Shutdown()
2091+
}
2092+
})
2093+
}
2094+
}
2095+
20322096
// TestBeatProcessorSharedAcrossPipelines verifies that when the same beat
20332097
// processor component ID is referenced by multiple OTel pipelines, only a
20342098
// single underlying beatProcessor instance is created. This avoids duplicate

x-pack/libbeat/cmd/instance/beat.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,13 @@ func NewBeatForReceiver(settings instance.Settings, receiverConfig map[string]an
6565
b.Info.ComponentID = componentID
6666
b.Info.LogConsumer = consumer
6767

68+
if v, ok := receiverConfig["include_metadata"]; ok {
69+
if include, ok := v.(bool); ok {
70+
b.Info.IncludeMetadata = include
71+
}
72+
delete(receiverConfig, "include_metadata")
73+
}
74+
6875
// begin code similar to configure
6976
if err = plugin.Initialize(); err != nil {
7077
return nil, fmt.Errorf("error initializing plugins: %w", err)

0 commit comments

Comments
 (0)