From 1450a37399c0819a15901fd3f2119d29b4c1699a Mon Sep 17 00:00:00 2001 From: Kay Roepke Date: Tue, 30 Jun 2026 18:21:07 +0200 Subject: [PATCH 01/20] feat(collectors): add macOS unified logging receiver config Co-Authored-By: Claude Opus 4.8 (1M context) --- .../MacOSUnifiedLoggingReceiverConfig.java | 132 ++++++++++++++++++ ...MacOSUnifiedLoggingReceiverConfigTest.java | 62 ++++++++ 2 files changed, 194 insertions(+) create mode 100644 graylog2-server/src/main/java/org/graylog/collectors/config/receiver/MacOSUnifiedLoggingReceiverConfig.java create mode 100644 graylog2-server/src/test/java/org/graylog/collectors/config/receiver/MacOSUnifiedLoggingReceiverConfigTest.java diff --git a/graylog2-server/src/main/java/org/graylog/collectors/config/receiver/MacOSUnifiedLoggingReceiverConfig.java b/graylog2-server/src/main/java/org/graylog/collectors/config/receiver/MacOSUnifiedLoggingReceiverConfig.java new file mode 100644 index 000000000000..9f05e2b87bce --- /dev/null +++ b/graylog2-server/src/main/java/org/graylog/collectors/config/receiver/MacOSUnifiedLoggingReceiverConfig.java @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +package org.graylog.collectors.config.receiver; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.auto.value.AutoValue; +import jakarta.annotation.Nullable; +import org.graylog.collectors.CollectorOSType; +import org.graylog.collectors.config.GoDurationSerializer; +import org.graylog.collectors.config.extension.FileStorageExtensionConfig; + +import java.time.Duration; +import java.util.EnumSet; + +import static org.graylog2.shared.utilities.StringUtils.f; + +/** + * OTel collector macOS Unified Logging Receiver configuration. + * + * @see macOS Unified Logging Receiver + */ +@AutoValue +@JsonInclude(JsonInclude.Include.NON_NULL) +public abstract class MacOSUnifiedLoggingReceiverConfig implements CollectorReceiverConfig { + public static final String RECEIVER_TYPE = "macos_unified_logging"; + + public String type() { + return RECEIVER_TYPE; + } + + @Override + public EnumSet osSupport() { + return EnumSet.of(CollectorOSType.MACOS); + } + + @Nullable + @JsonProperty("archive_path") + public abstract String archivePath(); + + @Nullable + @JsonProperty("predicate") + public abstract String predicate(); + + @Nullable + @JsonProperty("start_time") + public abstract String startTime(); + + @Nullable + @JsonProperty("end_time") + public abstract String endTime(); + + @Nullable + @JsonProperty("max_poll_interval") + @JsonSerialize(using = GoDurationSerializer.class) + public abstract Duration maxPollInterval(); + + @Nullable + @JsonProperty("max_log_age") + @JsonSerialize(using = GoDurationSerializer.class) + public abstract Duration maxLogAge(); + + @JsonProperty("format") + public String format() { + return "ndjson"; + } + + // Persists the live-mode forward cursor; required for cursor tracking and de-duplication. + @JsonProperty("storage") + public abstract String storage(); + + // Default predicate: security-relevant subsystems, error severity and above. + static final String DEFAULT_PREDICATE = "subsystem IN {" + + "'com.apple.opendirectoryd'," + + "'com.apple.authorization'," + + "'com.apple.loginwindow'," + + "'com.apple.securityd'," + + "'com.apple.TCC'," + + "'com.apple.alf'," + + "'com.apple.networkextension'," + + "'com.apple.DiskManagement'," + + "'com.apple.CoreStorage'," + + "'com.apple.endpointsecurity'," + + "'com.apple.syspolicyd'," + + "'com.apple.launchd'" + + "} AND messageType >= error"; + + public static Builder builder(String id) { + return new AutoValue_MacOSUnifiedLoggingReceiverConfig.Builder() + .name(f("macos_unified_logging/%s", id)) + .predicate(DEFAULT_PREDICATE) + .maxPollInterval(Duration.ofSeconds(30)) + .maxLogAge(Duration.ofHours(24)) + .storage(FileStorageExtensionConfig.defaultInstance().name()); + } + + @AutoValue.Builder + public abstract static class Builder { + public abstract Builder name(String name); + + public abstract Builder archivePath(@Nullable String archivePath); + + public abstract Builder predicate(@Nullable String predicate); + + public abstract Builder startTime(@Nullable String startTime); + + public abstract Builder endTime(@Nullable String endTime); + + public abstract Builder maxPollInterval(@Nullable Duration maxPollInterval); + + public abstract Builder maxLogAge(@Nullable Duration maxLogAge); + + public abstract Builder storage(String storage); + + public abstract MacOSUnifiedLoggingReceiverConfig build(); + } +} diff --git a/graylog2-server/src/test/java/org/graylog/collectors/config/receiver/MacOSUnifiedLoggingReceiverConfigTest.java b/graylog2-server/src/test/java/org/graylog/collectors/config/receiver/MacOSUnifiedLoggingReceiverConfigTest.java new file mode 100644 index 000000000000..3ee07f3b855b --- /dev/null +++ b/graylog2-server/src/test/java/org/graylog/collectors/config/receiver/MacOSUnifiedLoggingReceiverConfigTest.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +package org.graylog.collectors.config.receiver; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.graylog.collectors.CollectorOSType; +import org.graylog2.shared.bindings.providers.ObjectMapperProvider; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class MacOSUnifiedLoggingReceiverConfigTest { + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapperProvider().get(); + } + + @Test + void defaultsAndSerialization() throws Exception { + final var config = MacOSUnifiedLoggingReceiverConfig.builder("source-1").build(); + + assertThat(config.type()).isEqualTo("macos_unified_logging"); + assertThat(config.name()).isEqualTo("macos_unified_logging/source-1"); + assertThat(config.osSupport()).containsExactly(CollectorOSType.MACOS); + + final var tree = objectMapper.readTree(objectMapper.writeValueAsString(config)); + assertThat(tree.get("storage").asText()).isEqualTo("file_storage/default"); + assertThat(tree.get("format").asText()).isEqualTo("ndjson"); + assertThat(tree.get("predicate").asText()).contains("subsystem"); + assertThat(tree.has("max_poll_interval")).isTrue(); + assertThat(tree.has("max_log_age")).isTrue(); + // @JsonIgnore fields must not leak into the rendered receiver block + assertThat(tree.has("type")).isFalse(); + assertThat(tree.has("name")).isFalse(); + } + + @Test + void overridesPredicate() throws Exception { + final var config = MacOSUnifiedLoggingReceiverConfig.builder("s") + .predicate("subsystem == 'com.apple.securityd'") + .build(); + final var tree = objectMapper.readTree(objectMapper.writeValueAsString(config)); + assertThat(tree.get("predicate").asText()).isEqualTo("subsystem == 'com.apple.securityd'"); + } +} From ba4b65f3d23816f46d399aa9d55935057e488d89 Mon Sep 17 00:00:00 2001 From: Kay Roepke Date: Tue, 30 Jun 2026 18:27:56 +0200 Subject: [PATCH 02/20] feat(collectors): add macOS unified logging source config Co-Authored-By: Claude Opus 4.8 (1M context) --- .../graylog/collectors/CollectorsModule.java | 2 + .../db/MacOSUnifiedLoggingSourceConfig.java | 78 +++++++++++++++++++ .../collectors/db/SourceConfigTest.java | 33 +++++++- .../graylog/collectors/db/SourceDTOTest.java | 3 +- 4 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 graylog2-server/src/main/java/org/graylog/collectors/db/MacOSUnifiedLoggingSourceConfig.java diff --git a/graylog2-server/src/main/java/org/graylog/collectors/CollectorsModule.java b/graylog2-server/src/main/java/org/graylog/collectors/CollectorsModule.java index efab68da0187..db40679e4c75 100644 --- a/graylog2-server/src/main/java/org/graylog/collectors/CollectorsModule.java +++ b/graylog2-server/src/main/java/org/graylog/collectors/CollectorsModule.java @@ -26,6 +26,7 @@ import org.graylog.collectors.db.FileSourceConfig; import org.graylog.collectors.db.FleetDTO; import org.graylog.collectors.db.JournaldSourceConfig; +import org.graylog.collectors.db.MacOSUnifiedLoggingSourceConfig; import org.graylog.collectors.db.WindowsEventLogSourceConfig; import org.graylog.collectors.indexer.CollectorLogsIndexTemplateProvider; import org.graylog.collectors.input.CollectorIngestCodec; @@ -143,6 +144,7 @@ protected void configure() { registerJacksonSubtype(FileSourceConfig.class); registerJacksonSubtype(JournaldSourceConfig.class); registerJacksonSubtype(WindowsEventLogSourceConfig.class); + registerJacksonSubtype(MacOSUnifiedLoggingSourceConfig.class); final var indexTemplateProviderBinder = MapBinder.newMapBinder(binder(), String.class, IndexTemplateProvider.class); diff --git a/graylog2-server/src/main/java/org/graylog/collectors/db/MacOSUnifiedLoggingSourceConfig.java b/graylog2-server/src/main/java/org/graylog/collectors/db/MacOSUnifiedLoggingSourceConfig.java new file mode 100644 index 000000000000..26e9b50fdd0d --- /dev/null +++ b/graylog2-server/src/main/java/org/graylog/collectors/db/MacOSUnifiedLoggingSourceConfig.java @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +package org.graylog.collectors.db; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonTypeName; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.google.auto.value.AutoValue; +import jakarta.annotation.Nullable; +import org.graylog.collectors.config.receiver.CollectorReceiverConfig; +import org.graylog.collectors.config.receiver.MacOSUnifiedLoggingReceiverConfig; + +import java.util.Optional; + +@AutoValue +@JsonTypeName(MacOSUnifiedLoggingSourceConfig.TYPE_NAME) +@JsonDeserialize(builder = MacOSUnifiedLoggingSourceConfig.Builder.class) +public abstract class MacOSUnifiedLoggingSourceConfig implements SourceConfig { + public static final String TYPE_NAME = "macos_unified_logging"; + + @Override + @JsonProperty(TYPE_FIELD) + public abstract String type(); + + @Nullable + @JsonProperty("predicate") + public abstract String predicate(); + + public static Builder builder() { + return Builder.create(); + } + + @Override + public void validate() { + // No required fields — predicate is optional, defaults are sensible. + } + + @Override + public Optional toReceiverConfig(String id) { + final var builder = MacOSUnifiedLoggingReceiverConfig.builder(id); + if (predicate() != null) { + builder.predicate(predicate()); + } + return Optional.of(builder.build()); + } + + @AutoValue.Builder + public abstract static class Builder { + @JsonCreator + public static Builder create() { + return new AutoValue_MacOSUnifiedLoggingSourceConfig.Builder() + .type(TYPE_NAME); + } + + @JsonProperty(TYPE_FIELD) + public abstract Builder type(String type); + + @JsonProperty("predicate") + public abstract Builder predicate(@Nullable String predicate); + + public abstract MacOSUnifiedLoggingSourceConfig build(); + } +} diff --git a/graylog2-server/src/test/java/org/graylog/collectors/db/SourceConfigTest.java b/graylog2-server/src/test/java/org/graylog/collectors/db/SourceConfigTest.java index 217f410d2df3..22df8b022650 100644 --- a/graylog2-server/src/test/java/org/graylog/collectors/db/SourceConfigTest.java +++ b/graylog2-server/src/test/java/org/graylog/collectors/db/SourceConfigTest.java @@ -38,7 +38,8 @@ void setUp() { objectMapper.registerSubtypes( new NamedType(FileSourceConfig.class, FileSourceConfig.TYPE_NAME), new NamedType(JournaldSourceConfig.class, JournaldSourceConfig.TYPE_NAME), - new NamedType(WindowsEventLogSourceConfig.class, WindowsEventLogSourceConfig.TYPE_NAME) + new NamedType(WindowsEventLogSourceConfig.class, WindowsEventLogSourceConfig.TYPE_NAME), + new NamedType(MacOSUnifiedLoggingSourceConfig.class, MacOSUnifiedLoggingSourceConfig.TYPE_NAME) ); } @@ -130,4 +131,34 @@ void serializationIncludesTypeField() throws Exception { assertThat(tree.get("type").asText()).isEqualTo("file"); } + @Test + void macosRoundTripAndReceiverConfig() throws Exception { + final var original = MacOSUnifiedLoggingSourceConfig.builder() + .predicate("subsystem == 'com.apple.securityd'") + .build(); + + final var json = objectMapper.writeValueAsString(original); + final var deserialized = objectMapper.readValue(json, SourceConfig.class); + assertThat(deserialized).isEqualTo(original); + + final var receiver = original.toReceiverConfig("src-1").orElseThrow(); + assertThat(receiver.type()).isEqualTo("macos_unified_logging"); + assertThat(receiver.name()).isEqualTo("macos_unified_logging/src-1"); + assertThat(receiver).isInstanceOf( + org.graylog.collectors.config.receiver.MacOSUnifiedLoggingReceiverConfig.class); + final var macReceiver = + (org.graylog.collectors.config.receiver.MacOSUnifiedLoggingReceiverConfig) receiver; + assertThat(macReceiver.predicate()).isEqualTo("subsystem == 'com.apple.securityd'"); + assertThat(macReceiver.storage()).isEqualTo("file_storage/default"); + } + + @Test + void macosUsesDefaultPredicateWhenUnset() { + final var config = MacOSUnifiedLoggingSourceConfig.builder().build(); + final var macReceiver = + (org.graylog.collectors.config.receiver.MacOSUnifiedLoggingReceiverConfig) + config.toReceiverConfig("s").orElseThrow(); + assertThat(macReceiver.predicate()).contains("subsystem"); + } + } diff --git a/graylog2-server/src/test/java/org/graylog/collectors/db/SourceDTOTest.java b/graylog2-server/src/test/java/org/graylog/collectors/db/SourceDTOTest.java index e2db7110de87..666bef54f498 100644 --- a/graylog2-server/src/test/java/org/graylog/collectors/db/SourceDTOTest.java +++ b/graylog2-server/src/test/java/org/graylog/collectors/db/SourceDTOTest.java @@ -36,7 +36,8 @@ void setUp() { objectMapper.registerSubtypes( new NamedType(FileSourceConfig.class, FileSourceConfig.TYPE_NAME), new NamedType(JournaldSourceConfig.class, JournaldSourceConfig.TYPE_NAME), - new NamedType(WindowsEventLogSourceConfig.class, WindowsEventLogSourceConfig.TYPE_NAME) + new NamedType(WindowsEventLogSourceConfig.class, WindowsEventLogSourceConfig.TYPE_NAME), + new NamedType(MacOSUnifiedLoggingSourceConfig.class, MacOSUnifiedLoggingSourceConfig.TYPE_NAME) ); } From 315e9693eefa6fc675fbeb004a99f6224a8bc17b Mon Sep 17 00:00:00 2001 From: Kay Roepke Date: Tue, 30 Jun 2026 18:34:00 +0200 Subject: [PATCH 03/20] feat(collectors): map macOS unified logging records to GIM fields Co-Authored-By: Claude Opus 4.8 (1M context) --- .../graylog/collectors/CollectorsModule.java | 3 + .../MacOSUnifiedLoggingRecordProcessor.java | 102 ++++++++++++ ...acOSUnifiedLoggingRecordProcessorTest.java | 154 ++++++++++++++++++ .../processor/macos-unified-log-record.json | 34 ++++ 4 files changed, 293 insertions(+) create mode 100644 graylog2-server/src/main/java/org/graylog/collectors/input/processor/MacOSUnifiedLoggingRecordProcessor.java create mode 100644 graylog2-server/src/test/java/org/graylog/collectors/input/processor/MacOSUnifiedLoggingRecordProcessorTest.java create mode 100644 graylog2-server/src/test/resources/org/graylog/collectors/input/processor/macos-unified-log-record.json diff --git a/graylog2-server/src/main/java/org/graylog/collectors/CollectorsModule.java b/graylog2-server/src/main/java/org/graylog/collectors/CollectorsModule.java index db40679e4c75..3722e2677737 100644 --- a/graylog2-server/src/main/java/org/graylog/collectors/CollectorsModule.java +++ b/graylog2-server/src/main/java/org/graylog/collectors/CollectorsModule.java @@ -22,6 +22,7 @@ import org.graylog.collectors.cloud.CloudCollectorIngestService; import org.graylog.collectors.config.receiver.FilelogReceiverConfig; import org.graylog.collectors.config.receiver.JournaldReceiverConfig; +import org.graylog.collectors.config.receiver.MacOSUnifiedLoggingReceiverConfig; import org.graylog.collectors.config.receiver.WindowsEventLogReceiverConfig; import org.graylog.collectors.db.FileSourceConfig; import org.graylog.collectors.db.FleetDTO; @@ -38,6 +39,7 @@ import org.graylog.collectors.input.processor.FilelogRecordProcessor; import org.graylog.collectors.input.processor.JournaldRecordProcessor; import org.graylog.collectors.input.processor.LogRecordProcessor; +import org.graylog.collectors.input.processor.MacOSUnifiedLoggingRecordProcessor; import org.graylog.collectors.input.processor.WindowsEventLogRecordProcessor; import org.graylog.collectors.input.transport.CollectorIngestHttpTransport; import org.graylog.collectors.opamp.OpAmpModule; @@ -98,6 +100,7 @@ protected void configure() { logRecordProcessorBinder.addBinding(FilelogReceiverConfig.RECEIVER_TYPE).to(FilelogRecordProcessor.class); logRecordProcessorBinder.addBinding(JournaldReceiverConfig.RECEIVER_TYPE).to(JournaldRecordProcessor.class); logRecordProcessorBinder.addBinding(WindowsEventLogReceiverConfig.RECEIVER_TYPE).to(WindowsEventLogRecordProcessor.class); + logRecordProcessorBinder.addBinding(MacOSUnifiedLoggingReceiverConfig.RECEIVER_TYPE).to(MacOSUnifiedLoggingRecordProcessor.class); logRecordProcessorBinder.addBinding(CollectorLogRecordProcessor.RECEIVER_TYPE).to(CollectorLogRecordProcessor.class); if (otlpDumpEnabled) { diff --git a/graylog2-server/src/main/java/org/graylog/collectors/input/processor/MacOSUnifiedLoggingRecordProcessor.java b/graylog2-server/src/main/java/org/graylog/collectors/input/processor/MacOSUnifiedLoggingRecordProcessor.java new file mode 100644 index 000000000000..1ea592d9fa16 --- /dev/null +++ b/graylog2-server/src/main/java/org/graylog/collectors/input/processor/MacOSUnifiedLoggingRecordProcessor.java @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +package org.graylog.collectors.input.processor; + +import org.graylog.inputs.otel.OTelJournal; +import org.graylog.schema.EventFields; +import org.graylog.schema.ProcessFields; +import org.graylog.schema.UserFields; +import org.graylog.schema.VendorFields; + +import java.util.HashMap; +import java.util.Map; + +/** + * Processes macOS unified logging records into GIM fields. + *

+ * The fixed receiver emits a plain-text body (the human-readable message), native OTel severity + * and timestamp — all handled by {@link org.graylog.collectors.input.CollectorIngestCodec} — plus + * structured {@code macos.*} attributes. This processor maps only those attributes: well-fitting + * ones to GIM fields, and macOS-specific identifiers (boot session, activity/trace IDs, sender + * image) preserved under a {@code macos_*} prefix. + * + * @see Apple Unified Logging + */ +public class MacOSUnifiedLoggingRecordProcessor implements LogRecordProcessor { + + @Override + public Map process(OTelJournal.Log log) { + final Map result = new HashMap<>(); + String processImagePath = null; + + for (final var attr : log.getLogRecord().getAttributesList()) { + final var value = attr.getValue(); + switch (attr.getKey()) { + // Mapped to GIM fields. + case "macos.subsystem" -> putStr(result, EventFields.EVENT_SOURCE, value.getStringValue()); + case "macos.category" -> putStr(result, VendorFields.VENDOR_SUBTYPE, value.getStringValue()); + case "macos.eventType" -> putStr(result, VendorFields.VENDOR_EVENT_TYPE, value.getStringValue()); + case "macos.formatString" -> putStr(result, VendorFields.VENDOR_EVENT_DESCRIPTION, value.getStringValue()); + case "macos.processImagePath" -> { + final var path = emptyToNull(value.getStringValue()); + if (path != null) { + processImagePath = path; + result.put(ProcessFields.PROCESS_PATH, path); + } + } + case "macos.processID" -> result.put(ProcessFields.PROCESS_ID, value.getIntValue()); + case "macos.userID" -> result.put(UserFields.USER_ID, value.getIntValue()); + // macOS-specific identifiers preserved under a macos_* prefix. + case "macos.threadID" -> result.put("macos_thread_id", value.getIntValue()); + case "macos.bootUUID" -> putStr(result, "macos_boot_uuid", value.getStringValue()); + case "macos.machTimestamp" -> result.put("macos_mach_timestamp", value.getIntValue()); + case "macos.traceID" -> result.put("macos_trace_id", value.getIntValue()); + case "macos.activityIdentifier" -> result.put("macos_activity_id", value.getIntValue()); + case "macos.parentActivityIdentifier" -> result.put("macos_parent_activity_id", value.getIntValue()); + case "macos.creatorActivityID" -> result.put("macos_creator_activity_id", value.getIntValue()); + case "macos.processImageUUID" -> putStr(result, "macos_process_image_uuid", value.getStringValue()); + case "macos.senderImagePath" -> putStr(result, "macos_sender_image_path", value.getStringValue()); + case "macos.senderImageUUID" -> putStr(result, "macos_sender_image_uuid", value.getStringValue()); + case "macos.senderProgramCounter" -> result.put("macos_sender_program_counter", value.getIntValue()); + default -> { + // Ignore non-macos attributes. + } + } + } + + // Derive the process name from the executable path. + if (processImagePath != null) { + final var lastSlash = processImagePath.lastIndexOf('/'); + final var name = lastSlash >= 0 ? processImagePath.substring(lastSlash + 1) : processImagePath; + if (!name.isEmpty()) { + result.put(ProcessFields.PROCESS_NAME, name); + } + } + + return result; + } + + private static void putStr(Map target, String field, String value) { + if (value != null && !value.isEmpty()) { + target.put(field, value); + } + } + + private static String emptyToNull(String s) { + return (s == null || s.isEmpty()) ? null : s; + } +} diff --git a/graylog2-server/src/test/java/org/graylog/collectors/input/processor/MacOSUnifiedLoggingRecordProcessorTest.java b/graylog2-server/src/test/java/org/graylog/collectors/input/processor/MacOSUnifiedLoggingRecordProcessorTest.java new file mode 100644 index 000000000000..1ccb76cdde52 --- /dev/null +++ b/graylog2-server/src/test/java/org/graylog/collectors/input/processor/MacOSUnifiedLoggingRecordProcessorTest.java @@ -0,0 +1,154 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +package org.graylog.collectors.input.processor; + +import com.google.common.io.Resources; +import com.google.protobuf.util.JsonFormat; +import io.opentelemetry.proto.common.v1.AnyValue; +import io.opentelemetry.proto.common.v1.KeyValue; +import io.opentelemetry.proto.logs.v1.LogRecord; +import org.graylog.collectors.CollectorJournal; +import org.graylog.inputs.otel.OTelJournal; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class MacOSUnifiedLoggingRecordProcessorTest { + + private MacOSUnifiedLoggingRecordProcessor processor; + + @BeforeEach + void setUp() { + processor = new MacOSUnifiedLoggingRecordProcessor(); + } + + @Test + void mapsMacosAttributesToFields() { + final var logRecord = LogRecord.newBuilder() + .setBody(AnyValue.newBuilder().setStringValue("hello world")) + .addAttributes(strAttr("macos.subsystem", "com.apple.bluetooth")) + .addAttributes(strAttr("macos.category", "Server.LE.Scan")) + .addAttributes(strAttr("macos.eventType", "logEvent")) + .addAttributes(strAttr("macos.formatString", "fmt %@")) + .addAttributes(strAttr("macos.processImagePath", "/usr/sbin/bluetoothd")) + .addAttributes(intAttr("macos.processID", 401)) + .addAttributes(intAttr("macos.userID", 205)) + .addAttributes(intAttr("macos.threadID", 11537025L)) + .addAttributes(strAttr("macos.bootUUID", "BOOT-A")) + .addAttributes(intAttr("macos.machTimestamp", 12868010147176L)) + .addAttributes(intAttr("macos.traceID", 45473881108119556L)) + .addAttributes(intAttr("macos.activityIdentifier", 0)) + .addAttributes(intAttr("macos.parentActivityIdentifier", 0)) + .addAttributes(intAttr("macos.creatorActivityID", 0)) + .addAttributes(strAttr("macos.processImageUUID", "PUUID")) + .addAttributes(strAttr("macos.senderImagePath", "/usr/sbin/bluetoothd")) + .addAttributes(strAttr("macos.senderImageUUID", "SUUID")) + .addAttributes(intAttr("macos.senderProgramCounter", 7787736L)) + .build(); + + final var result = processor.process(wrap(logRecord)); + + assertThat(result).containsExactlyInAnyOrderEntriesOf(Map.ofEntries( + Map.entry("event_source", "com.apple.bluetooth"), + Map.entry("vendor_subtype", "Server.LE.Scan"), + Map.entry("vendor_event_type", "logEvent"), + Map.entry("vendor_event_description", "fmt %@"), + Map.entry("process_path", "/usr/sbin/bluetoothd"), + Map.entry("process_name", "bluetoothd"), + Map.entry("process_id", 401L), + Map.entry("user_id", 205L), + Map.entry("macos_thread_id", 11537025L), + Map.entry("macos_boot_uuid", "BOOT-A"), + Map.entry("macos_mach_timestamp", 12868010147176L), + Map.entry("macos_trace_id", 45473881108119556L), + Map.entry("macos_activity_id", 0L), + Map.entry("macos_parent_activity_id", 0L), + Map.entry("macos_creator_activity_id", 0L), + Map.entry("macos_process_image_uuid", "PUUID"), + Map.entry("macos_sender_image_path", "/usr/sbin/bluetoothd"), + Map.entry("macos_sender_image_uuid", "SUUID"), + Map.entry("macos_sender_program_counter", 7787736L) + )); + } + + @Test + void ignoresNonMacosAttributesAndEmptyStrings() { + final var logRecord = LogRecord.newBuilder() + .addAttributes(strAttr("macos.subsystem", "")) + .addAttributes(strAttr("other.key", "value")) + .addAttributes(strAttr("macos.eventType", "logEvent")) + .build(); + + final var result = processor.process(wrap(logRecord)); + + assertThat(result).containsExactlyInAnyOrderEntriesOf(Map.of("vendor_event_type", "logEvent")); + } + + @Test + void derivesProcessNameFromPathWithoutSlash() { + final var logRecord = LogRecord.newBuilder() + .addAttributes(strAttr("macos.processImagePath", "standalone-binary")) + .build(); + + final var result = processor.process(wrap(logRecord)); + + assertThat(result).containsEntry("process_path", "standalone-binary"); + assertThat(result).containsEntry("process_name", "standalone-binary"); + } + + @Test + void mapsFieldsFromFixture() throws IOException { + final var logRecord = parseFixture("macos-unified-log-record.json"); + + final var result = processor.process(wrap(logRecord)); + + assertThat(result).containsEntry("event_source", "com.example.network"); + assertThat(result).containsEntry("vendor_subtype", "connection"); + assertThat(result).containsEntry("process_name", "example-daemon"); + assertThat(result).containsEntry("user_id", 501L); + assertThat(result).containsEntry("macos_boot_uuid", "FFEEDDCC-BBAA-9988-7766-554433221100"); + assertThat(result).containsEntry("macos_trace_id", 45473881108119556L); + } + + private static OTelJournal.Log wrap(LogRecord logRecord) { + return OTelJournal.Log.newBuilder().setLogRecord(logRecord).build(); + } + + private static KeyValue strAttr(String key, String value) { + return KeyValue.newBuilder().setKey(key) + .setValue(AnyValue.newBuilder().setStringValue(value)).build(); + } + + private static KeyValue intAttr(String key, long value) { + return KeyValue.newBuilder().setKey(key) + .setValue(AnyValue.newBuilder().setIntValue(value)).build(); + } + + private static LogRecord parseFixture(String filename) throws IOException { + final var builder = CollectorJournal.Record.newBuilder(); + final var json = Resources.toString( + Resources.getResource(MacOSUnifiedLoggingRecordProcessorTest.class, filename), + StandardCharsets.UTF_8); + JsonFormat.parser().merge(json, builder); + return builder.build().getOtelRecord().getLog().getLogRecord(); + } +} diff --git a/graylog2-server/src/test/resources/org/graylog/collectors/input/processor/macos-unified-log-record.json b/graylog2-server/src/test/resources/org/graylog/collectors/input/processor/macos-unified-log-record.json new file mode 100644 index 000000000000..bd38a97a536c --- /dev/null +++ b/graylog2-server/src/test/resources/org/graylog/collectors/input/processor/macos-unified-log-record.json @@ -0,0 +1,34 @@ +{ + "otelRecord": { + "log": { + "resource": { + "attributes": [ + { "key": "collector.receiver.type", "value": { "stringValue": "macos_unified_logging" } } + ] + }, + "scope": {}, + "logRecord": { + "timeUnixNano": "1709000000000000000", + "observedTimeUnixNano": "1709000001000000000", + "severityText": "Default", + "body": { "stringValue": "Connection established to endpoint 10.0.1.50:443" }, + "attributes": [ + { "key": "macos.subsystem", "value": { "stringValue": "com.example.network" } }, + { "key": "macos.category", "value": { "stringValue": "connection" } }, + { "key": "macos.eventType", "value": { "stringValue": "logEvent" } }, + { "key": "macos.formatString", "value": { "stringValue": "%{public}s connection to %{public}s:%{public}d" } }, + { "key": "macos.processImagePath", "value": { "stringValue": "/usr/libexec/example-daemon" } }, + { "key": "macos.processID", "value": { "intValue": "1234" } }, + { "key": "macos.userID", "value": { "intValue": "501" } }, + { "key": "macos.threadID", "value": { "intValue": "7890" } }, + { "key": "macos.bootUUID", "value": { "stringValue": "FFEEDDCC-BBAA-9988-7766-554433221100" } }, + { "key": "macos.machTimestamp", "value": { "intValue": "1234567890" } }, + { "key": "macos.traceID", "value": { "intValue": "45473881108119556" } }, + { "key": "macos.senderImagePath", "value": { "stringValue": "/usr/lib/libexample.dylib" } } + ] + } + } + }, + "collectorInstanceUid": "test-agent-001", + "collectorReceiverType": "macos_unified_logging" +} From f5f6397d32a80ad12b5879cb8a7aa76107d6d810 Mon Sep 17 00:00:00 2001 From: Kay Roepke Date: Tue, 30 Jun 2026 18:38:19 +0200 Subject: [PATCH 04/20] feat(collectors): add macOS unified logging source form Co-Authored-By: Claude Opus 4.8 (1M context) --- .../collectors/sources/Constants.ts | 1 + .../sources/SourceFormModal.test.tsx | 9 +++++ .../collectors/sources/SourceFormModal.tsx | 35 +++++++++++++++++-- .../src/components/collectors/types.ts | 12 +++++-- 4 files changed, 53 insertions(+), 4 deletions(-) diff --git a/graylog2-web-interface/src/components/collectors/sources/Constants.ts b/graylog2-web-interface/src/components/collectors/sources/Constants.ts index 0084eeeb950f..b8d25a9433a5 100644 --- a/graylog2-web-interface/src/components/collectors/sources/Constants.ts +++ b/graylog2-web-interface/src/components/collectors/sources/Constants.ts @@ -32,4 +32,5 @@ export const SOURCE_TYPE_LABELS: Record = { file: 'File', journald: 'Journald', windows_event_log: 'Windows Event Log', + macos_unified_logging: 'macOS Unified Logging', }; diff --git a/graylog2-web-interface/src/components/collectors/sources/SourceFormModal.test.tsx b/graylog2-web-interface/src/components/collectors/sources/SourceFormModal.test.tsx index 9035e06a7e92..7e758d486245 100644 --- a/graylog2-web-interface/src/components/collectors/sources/SourceFormModal.test.tsx +++ b/graylog2-web-interface/src/components/collectors/sources/SourceFormModal.test.tsx @@ -62,6 +62,15 @@ describe('SourceFormModal', () => { await screen.findByLabelText(/file path/i); }); + + it('renders macOS predicate field when macos source type selected', async () => { + render(); + + await screen.findByText('macOS Unified Logging'); + await userEvent.selectOptions(screen.getByLabelText(/Source Type/i), 'macos_unified_logging'); + + await screen.findByLabelText(/Predicate/i); + }); }); describe('splitToList', () => { diff --git a/graylog2-web-interface/src/components/collectors/sources/SourceFormModal.tsx b/graylog2-web-interface/src/components/collectors/sources/SourceFormModal.tsx index d6e60ff938c4..1d1310796feb 100644 --- a/graylog2-web-interface/src/components/collectors/sources/SourceFormModal.tsx +++ b/graylog2-web-interface/src/components/collectors/sources/SourceFormModal.tsx @@ -36,6 +36,7 @@ import type { JournaldSourceConfig, JournaldPriority, WindowsEventLogSourceConfig, + MacOSUnifiedLoggingSourceConfig, } from '../types'; type Props = { @@ -45,10 +46,14 @@ type Props = { onSave: (source: Omit) => Promise<{ id?: string } | void>; }; -const defaultConfigs: Record = { +const defaultConfigs: Record< + SourceType, + FileSourceConfig | JournaldSourceConfig | WindowsEventLogSourceConfig | MacOSUnifiedLoggingSourceConfig +> = { file: { paths: [''], read_mode: 'end' }, journald: { read_mode: 'end', priority: 'info' }, windows_event_log: { channels: [], include_default_channels: true, read_mode: 'end' }, + macos_unified_logging: {}, }; type FormValues = { @@ -56,7 +61,7 @@ type FormValues = { name: string; description: string; enabled: boolean; - config: FileSourceConfig | JournaldSourceConfig | WindowsEventLogSourceConfig; + config: FileSourceConfig | JournaldSourceConfig | WindowsEventLogSourceConfig | MacOSUnifiedLoggingSourceConfig; }; const validate = (values: FormValues) => { @@ -210,6 +215,23 @@ const WindowsEventLogConfigFields = ({ ); }; +const MacOSUnifiedLoggingConfigFields = ({ + config, + setFieldValue, +}: { + config: MacOSUnifiedLoggingSourceConfig; + setFieldValue: (field: string, value: unknown) => void; +}) => ( + setFieldValue('config', { ...config, predicate: e.target.value || undefined })} + /> +); + const SourceFormModal = ({ fleetId, source = undefined, onClose, onSave }: Props) => { const isEdit = !!source; const sendTelemetry = useSendCollectorsTelemetry(); @@ -344,6 +366,9 @@ const SourceFormModal = ({ fleetId, source = undefined, onClose, onSave }: Props {values.source_type === 'windows_event_log' && ( Collect events from Windows Event Viewer channels. )} + {values.source_type === 'macos_unified_logging' && ( + Collect from the macOS unified logging system via the `log` command (macOS only). + )} )} + {values.source_type === 'macos_unified_logging' && ( + + )} Date: Tue, 30 Jun 2026 18:52:19 +0200 Subject: [PATCH 05/20] fix(collectors): write macOS process_id/user_id as String for shared-index type consistency Co-Authored-By: Claude Opus 4.8 (1M context) --- .../input/processor/MacOSUnifiedLoggingRecordProcessor.java | 4 ++-- .../processor/MacOSUnifiedLoggingRecordProcessorTest.java | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/graylog2-server/src/main/java/org/graylog/collectors/input/processor/MacOSUnifiedLoggingRecordProcessor.java b/graylog2-server/src/main/java/org/graylog/collectors/input/processor/MacOSUnifiedLoggingRecordProcessor.java index 1ea592d9fa16..d085d0f8d2ce 100644 --- a/graylog2-server/src/main/java/org/graylog/collectors/input/processor/MacOSUnifiedLoggingRecordProcessor.java +++ b/graylog2-server/src/main/java/org/graylog/collectors/input/processor/MacOSUnifiedLoggingRecordProcessor.java @@ -58,8 +58,8 @@ public Map process(OTelJournal.Log log) { result.put(ProcessFields.PROCESS_PATH, path); } } - case "macos.processID" -> result.put(ProcessFields.PROCESS_ID, value.getIntValue()); - case "macos.userID" -> result.put(UserFields.USER_ID, value.getIntValue()); + case "macos.processID" -> result.put(ProcessFields.PROCESS_ID, Long.toString(value.getIntValue())); + case "macos.userID" -> result.put(UserFields.USER_ID, Long.toString(value.getIntValue())); // macOS-specific identifiers preserved under a macos_* prefix. case "macos.threadID" -> result.put("macos_thread_id", value.getIntValue()); case "macos.bootUUID" -> putStr(result, "macos_boot_uuid", value.getStringValue()); diff --git a/graylog2-server/src/test/java/org/graylog/collectors/input/processor/MacOSUnifiedLoggingRecordProcessorTest.java b/graylog2-server/src/test/java/org/graylog/collectors/input/processor/MacOSUnifiedLoggingRecordProcessorTest.java index 1ccb76cdde52..2acb3dbbe961 100644 --- a/graylog2-server/src/test/java/org/graylog/collectors/input/processor/MacOSUnifiedLoggingRecordProcessorTest.java +++ b/graylog2-server/src/test/java/org/graylog/collectors/input/processor/MacOSUnifiedLoggingRecordProcessorTest.java @@ -74,8 +74,8 @@ void mapsMacosAttributesToFields() { Map.entry("vendor_event_description", "fmt %@"), Map.entry("process_path", "/usr/sbin/bluetoothd"), Map.entry("process_name", "bluetoothd"), - Map.entry("process_id", 401L), - Map.entry("user_id", 205L), + Map.entry("process_id", "401"), + Map.entry("user_id", "205"), Map.entry("macos_thread_id", 11537025L), Map.entry("macos_boot_uuid", "BOOT-A"), Map.entry("macos_mach_timestamp", 12868010147176L), @@ -124,7 +124,7 @@ void mapsFieldsFromFixture() throws IOException { assertThat(result).containsEntry("event_source", "com.example.network"); assertThat(result).containsEntry("vendor_subtype", "connection"); assertThat(result).containsEntry("process_name", "example-daemon"); - assertThat(result).containsEntry("user_id", 501L); + assertThat(result).containsEntry("user_id", "501"); assertThat(result).containsEntry("macos_boot_uuid", "FFEEDDCC-BBAA-9988-7766-554433221100"); assertThat(result).containsEntry("macos_trace_id", 45473881108119556L); } From bfc4f29c65a5bd9f94d8b5c78d725650c0e8badb Mon Sep 17 00:00:00 2001 From: Kay Roepke Date: Fri, 10 Jul 2026 22:16:53 +0200 Subject: [PATCH 06/20] update macos_unified_logging configuration expose more settings to the ui but hide archive_path and end_time, as those would essentially be one-shot collection they would technically work but are not a good fit for our use case. the receiver has support, be graylog doesn't at this point --- .../MacOSUnifiedLoggingReceiverConfig.java | 17 ++--- .../db/MacOSUnifiedLoggingSourceConfig.java | 31 +++++++++ .../collectors/db/SourceConfigTest.java | 58 ++++++++++++++-- .../overview/onboarding/defaultSources.ts | 8 +++ .../sources/SourceFormModal.test.tsx | 28 ++++++++ .../collectors/sources/SourceFormModal.tsx | 69 ++++++++++++++++--- .../src/components/collectors/types.ts | 4 ++ 7 files changed, 187 insertions(+), 28 deletions(-) diff --git a/graylog2-server/src/main/java/org/graylog/collectors/config/receiver/MacOSUnifiedLoggingReceiverConfig.java b/graylog2-server/src/main/java/org/graylog/collectors/config/receiver/MacOSUnifiedLoggingReceiverConfig.java index 9f05e2b87bce..1f566be8d9b5 100644 --- a/graylog2-server/src/main/java/org/graylog/collectors/config/receiver/MacOSUnifiedLoggingReceiverConfig.java +++ b/graylog2-server/src/main/java/org/graylog/collectors/config/receiver/MacOSUnifiedLoggingReceiverConfig.java @@ -32,6 +32,11 @@ /** * OTel collector macOS Unified Logging Receiver configuration. + *

+ * Graylog only models the live-collection subset of the upstream receiver's options. The upstream + * {@code archive_path} (one-shot collection from a static {@code .logarchive}) and {@code end_time} + * (a bounded, finite collection window) options are intentionally omitted: the collector is meant + * for continuous live tailing, so those would only ever produce one-shot behavior. * * @see macOS Unified Logging Receiver */ @@ -49,10 +54,6 @@ public EnumSet osSupport() { return EnumSet.of(CollectorOSType.MACOS); } - @Nullable - @JsonProperty("archive_path") - public abstract String archivePath(); - @Nullable @JsonProperty("predicate") public abstract String predicate(); @@ -61,10 +62,6 @@ public EnumSet osSupport() { @JsonProperty("start_time") public abstract String startTime(); - @Nullable - @JsonProperty("end_time") - public abstract String endTime(); - @Nullable @JsonProperty("max_poll_interval") @JsonSerialize(using = GoDurationSerializer.class) @@ -113,14 +110,10 @@ public static Builder builder(String id) { public abstract static class Builder { public abstract Builder name(String name); - public abstract Builder archivePath(@Nullable String archivePath); - public abstract Builder predicate(@Nullable String predicate); public abstract Builder startTime(@Nullable String startTime); - public abstract Builder endTime(@Nullable String endTime); - public abstract Builder maxPollInterval(@Nullable Duration maxPollInterval); public abstract Builder maxLogAge(@Nullable Duration maxLogAge); diff --git a/graylog2-server/src/main/java/org/graylog/collectors/db/MacOSUnifiedLoggingSourceConfig.java b/graylog2-server/src/main/java/org/graylog/collectors/db/MacOSUnifiedLoggingSourceConfig.java index 26e9b50fdd0d..9c3cc396a3ad 100644 --- a/graylog2-server/src/main/java/org/graylog/collectors/db/MacOSUnifiedLoggingSourceConfig.java +++ b/graylog2-server/src/main/java/org/graylog/collectors/db/MacOSUnifiedLoggingSourceConfig.java @@ -25,6 +25,7 @@ import org.graylog.collectors.config.receiver.CollectorReceiverConfig; import org.graylog.collectors.config.receiver.MacOSUnifiedLoggingReceiverConfig; +import java.time.Duration; import java.util.Optional; @AutoValue @@ -41,6 +42,18 @@ public abstract class MacOSUnifiedLoggingSourceConfig implements SourceConfig { @JsonProperty("predicate") public abstract String predicate(); + @Nullable + @JsonProperty("start_time") + public abstract String startTime(); + + @Nullable + @JsonProperty("max_poll_interval") + public abstract Duration maxPollInterval(); + + @Nullable + @JsonProperty("max_log_age") + public abstract Duration maxLogAge(); + public static Builder builder() { return Builder.create(); } @@ -56,6 +69,15 @@ public Optional toReceiverConfig(String id) { if (predicate() != null) { builder.predicate(predicate()); } + if (startTime() != null) { + builder.startTime(startTime()); + } + if (maxPollInterval() != null) { + builder.maxPollInterval(maxPollInterval()); + } + if (maxLogAge() != null) { + builder.maxLogAge(maxLogAge()); + } return Optional.of(builder.build()); } @@ -73,6 +95,15 @@ public static Builder create() { @JsonProperty("predicate") public abstract Builder predicate(@Nullable String predicate); + @JsonProperty("start_time") + public abstract Builder startTime(@Nullable String startTime); + + @JsonProperty("max_poll_interval") + public abstract Builder maxPollInterval(@Nullable Duration maxPollInterval); + + @JsonProperty("max_log_age") + public abstract Builder maxLogAge(@Nullable Duration maxLogAge); + public abstract MacOSUnifiedLoggingSourceConfig build(); } } diff --git a/graylog2-server/src/test/java/org/graylog/collectors/db/SourceConfigTest.java b/graylog2-server/src/test/java/org/graylog/collectors/db/SourceConfigTest.java index 22df8b022650..62b36d044bce 100644 --- a/graylog2-server/src/test/java/org/graylog/collectors/db/SourceConfigTest.java +++ b/graylog2-server/src/test/java/org/graylog/collectors/db/SourceConfigTest.java @@ -19,10 +19,12 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.jsontype.NamedType; import org.graylog.collectors.CollectorReadMode; +import org.graylog.collectors.config.receiver.MacOSUnifiedLoggingReceiverConfig; import org.graylog2.shared.bindings.providers.ObjectMapperProvider; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import java.time.Duration; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -144,10 +146,8 @@ void macosRoundTripAndReceiverConfig() throws Exception { final var receiver = original.toReceiverConfig("src-1").orElseThrow(); assertThat(receiver.type()).isEqualTo("macos_unified_logging"); assertThat(receiver.name()).isEqualTo("macos_unified_logging/src-1"); - assertThat(receiver).isInstanceOf( - org.graylog.collectors.config.receiver.MacOSUnifiedLoggingReceiverConfig.class); - final var macReceiver = - (org.graylog.collectors.config.receiver.MacOSUnifiedLoggingReceiverConfig) receiver; + assertThat(receiver).isInstanceOf(MacOSUnifiedLoggingReceiverConfig.class); + final var macReceiver = (MacOSUnifiedLoggingReceiverConfig) receiver; assertThat(macReceiver.predicate()).isEqualTo("subsystem == 'com.apple.securityd'"); assertThat(macReceiver.storage()).isEqualTo("file_storage/default"); } @@ -156,9 +156,55 @@ void macosRoundTripAndReceiverConfig() throws Exception { void macosUsesDefaultPredicateWhenUnset() { final var config = MacOSUnifiedLoggingSourceConfig.builder().build(); final var macReceiver = - (org.graylog.collectors.config.receiver.MacOSUnifiedLoggingReceiverConfig) - config.toReceiverConfig("s").orElseThrow(); + (MacOSUnifiedLoggingReceiverConfig) config.toReceiverConfig("s").orElseThrow(); assertThat(macReceiver.predicate()).contains("subsystem"); } + @Test + void macosForwardsStartTimeAndDurationsToReceiverConfig() { + final var config = MacOSUnifiedLoggingSourceConfig.builder() + .startTime("2026-07-10 00:00:00") + .maxPollInterval(Duration.ofSeconds(15)) + .maxLogAge(Duration.ofHours(6)) + .build(); + + final var macReceiver = + (MacOSUnifiedLoggingReceiverConfig) config.toReceiverConfig("src-1").orElseThrow(); + + assertThat(macReceiver.startTime()).isEqualTo("2026-07-10 00:00:00"); + assertThat(macReceiver.maxPollInterval()).isEqualTo(Duration.ofSeconds(15)); + assertThat(macReceiver.maxLogAge()).isEqualTo(Duration.ofHours(6)); + } + + @Test + void macosLeavesReceiverDefaultsWhenOptionalFieldsUnset() { + final var config = MacOSUnifiedLoggingSourceConfig.builder().build(); + + final var macReceiver = + (MacOSUnifiedLoggingReceiverConfig) config.toReceiverConfig("s").orElseThrow(); + + assertThat(macReceiver.startTime()).isNull(); + assertThat(macReceiver.maxPollInterval()).isEqualTo(Duration.ofSeconds(30)); + assertThat(macReceiver.maxLogAge()).isEqualTo(Duration.ofHours(24)); + } + + @Test + void macosRoundTripSerializesNewFields() throws Exception { + final var original = MacOSUnifiedLoggingSourceConfig.builder() + .predicate("subsystem == 'com.apple.securityd'") + .startTime("2026-07-10 00:00:00") + .maxPollInterval(Duration.ofSeconds(30)) + .maxLogAge(Duration.ofHours(24)) + .build(); + + final var json = objectMapper.writeValueAsString(original); + final var tree = objectMapper.readTree(json); + assertThat(tree.get("start_time").asText()).isEqualTo("2026-07-10 00:00:00"); + assertThat(tree.get("max_poll_interval").asText()).isEqualTo("PT30S"); + assertThat(tree.get("max_log_age").asText()).isEqualTo("PT24H"); + + final var deserialized = objectMapper.readValue(json, SourceConfig.class); + assertThat(deserialized).isEqualTo(original); + } + } diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/defaultSources.ts b/graylog2-web-interface/src/components/collectors/overview/onboarding/defaultSources.ts index d62921b8345d..3ea333f22bb8 100644 --- a/graylog2-web-interface/src/components/collectors/overview/onboarding/defaultSources.ts +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/defaultSources.ts @@ -39,6 +39,14 @@ const DEFAULT_SOURCES: NewSource[] = [ read_mode: 'end', }, }, + { + name: 'macOS Unified Logs', + description: 'Collects common logs from macOS unified logs', + enabled: true, + type: 'macos_unified_logging', + config: { + }, + }, { name: 'Windows Event Log', description: 'Collects Windows event logs from default channels', diff --git a/graylog2-web-interface/src/components/collectors/sources/SourceFormModal.test.tsx b/graylog2-web-interface/src/components/collectors/sources/SourceFormModal.test.tsx index 7e758d486245..171dda15e595 100644 --- a/graylog2-web-interface/src/components/collectors/sources/SourceFormModal.test.tsx +++ b/graylog2-web-interface/src/components/collectors/sources/SourceFormModal.test.tsx @@ -71,6 +71,34 @@ describe('SourceFormModal', () => { await screen.findByLabelText(/Predicate/i); }); + + it('renders macOS start time, poll interval and log age fields when macos source type selected', async () => { + render(); + + await userEvent.selectOptions(screen.getByLabelText(/Source Type/i), 'macos_unified_logging'); + + await screen.findByLabelText(/Start time/i); + await screen.findByLabelText(/Max poll interval/i); + await screen.findByLabelText(/Max log age/i); + }); + + it('includes a typed macOS start time in the saved source config', async () => { + const onSave = jest.fn().mockResolvedValue({ id: 's-1' }); + render(); + + await userEvent.selectOptions(screen.getByLabelText(/Source Type/i), 'macos_unified_logging'); + await userEvent.type(screen.getByLabelText(/Name/i), 'mac-src'); + await userEvent.type(screen.getByLabelText(/Start time/i), '2026-07-10 00:00:00'); + fireEvent.click(screen.getByRole('button', { name: /Create source/i })); + await screen.findByRole('button', { name: /Create source/i }); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'macos_unified_logging', + config: expect.objectContaining({ start_time: '2026-07-10 00:00:00' }), + }), + ); + }); }); describe('splitToList', () => { diff --git a/graylog2-web-interface/src/components/collectors/sources/SourceFormModal.tsx b/graylog2-web-interface/src/components/collectors/sources/SourceFormModal.tsx index 1d1310796feb..dd9c7cd89db2 100644 --- a/graylog2-web-interface/src/components/collectors/sources/SourceFormModal.tsx +++ b/graylog2-web-interface/src/components/collectors/sources/SourceFormModal.tsx @@ -19,10 +19,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { FormikTouched } from 'formik'; import { Formik, Form } from 'formik'; import isEqual from 'lodash/isEqual'; +import moment from 'moment'; import { HelpBlock, Input } from 'components/bootstrap'; import Modal from 'components/bootstrap/Modal'; import { FormikInput } from 'components/common'; +import TimeUnitInput, { extractDurationAndUnit } from 'components/common/TimeUnitInput'; import ModalSubmit from 'components/common/ModalSubmit'; import { TELEMETRY_EVENT_TYPE } from 'logic/telemetry/Constants'; @@ -215,22 +217,69 @@ const WindowsEventLogConfigFields = ({ ); }; +// Ordered largest-first so extractDurationAndUnit renders the most readable unit (e.g. PT24H -> "1 days"). +const POLL_INTERVAL_UNITS = ['HOURS', 'MINUTES', 'SECONDS']; +const LOG_AGE_UNITS = ['DAYS', 'HOURS', 'MINUTES']; + const MacOSUnifiedLoggingConfigFields = ({ config, setFieldValue, }: { config: MacOSUnifiedLoggingSourceConfig; setFieldValue: (field: string, value: unknown) => void; -}) => ( - setFieldValue('config', { ...config, predicate: e.target.value || undefined })} - /> -); +}) => { + const pollInterval = extractDurationAndUnit(config.max_poll_interval, POLL_INTERVAL_UNITS); + const logAge = extractDurationAndUnit(config.max_log_age, LOG_AGE_UNITS); + + // TimeUnitInput reports (value, unit, checked). Unchecked clears the field so the backend default applies. + const updateDuration = + (field: 'max_poll_interval' | 'max_log_age') => + (value: number, unit: string, checked: boolean) => { + setFieldValue('config', { + ...config, + [field]: checked ? moment.duration(value, unit as moment.unitOfTime.DurationConstructor).toISOString() : undefined, + }); + }; + + return ( + <> + setFieldValue('config', { ...config, predicate: e.target.value || undefined })} + /> + setFieldValue('config', { ...config, start_time: e.target.value || undefined })} + /> + + + + ); +}; const SourceFormModal = ({ fleetId, source = undefined, onClose, onSave }: Props) => { const isEdit = !!source; diff --git a/graylog2-web-interface/src/components/collectors/types.ts b/graylog2-web-interface/src/components/collectors/types.ts index 76a060f07876..1bf96880a68b 100644 --- a/graylog2-web-interface/src/components/collectors/types.ts +++ b/graylog2-web-interface/src/components/collectors/types.ts @@ -78,6 +78,10 @@ export type WindowsEventLogSourceConfig = { export type MacOSUnifiedLoggingSourceConfig = { predicate?: string; + start_time?: string; + // ISO-8601 durations (e.g. 'PT30S', 'PT24H'); omitted values fall back to the backend defaults. + max_poll_interval?: string; + max_log_age?: string; }; export type FileSource = SourceBase & { type: 'file'; config: FileSourceConfig }; From 113058da574212cb27d36f71a7d63ce0ee74fdbd Mon Sep 17 00:00:00 2001 From: Kay Roepke Date: Mon, 13 Jul 2026 12:32:14 +0200 Subject: [PATCH 07/20] we now have 4 sources instead of 3 --- .../components/collectors/overview/FirstOnboarding.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/graylog2-web-interface/src/components/collectors/overview/FirstOnboarding.test.tsx b/graylog2-web-interface/src/components/collectors/overview/FirstOnboarding.test.tsx index 83eba592b500..26ac108951c8 100644 --- a/graylog2-web-interface/src/components/collectors/overview/FirstOnboarding.test.tsx +++ b/graylog2-web-interface/src/components/collectors/overview/FirstOnboarding.test.tsx @@ -200,7 +200,7 @@ describe('FirstOnboarding', () => { ); }); - expect(createSource).toHaveBeenCalledTimes(3); + expect(createSource).toHaveBeenCalledTimes(4); await waitFor(() => { expect(createEnrollmentToken).toHaveBeenCalledWith({ @@ -249,7 +249,7 @@ describe('FirstOnboarding', () => { ); }); - expect(createSource).toHaveBeenCalledTimes(3); + expect(createSource).toHaveBeenCalledTimes(4); await waitFor(() => { expect(createEnrollmentToken).toHaveBeenCalledWith({ From d7d2c991fe46365892af9353a8b7b92231068001 Mon Sep 17 00:00:00 2001 From: Kay Roepke Date: Mon, 13 Jul 2026 17:25:08 +0200 Subject: [PATCH 08/20] give us a bit more space for longer titles in the fleet grid --- .../src/components/collectors/overview/FleetCardsGrid.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graylog2-web-interface/src/components/collectors/overview/FleetCardsGrid.tsx b/graylog2-web-interface/src/components/collectors/overview/FleetCardsGrid.tsx index 0fa156c87330..b317745a78de 100644 --- a/graylog2-web-interface/src/components/collectors/overview/FleetCardsGrid.tsx +++ b/graylog2-web-interface/src/components/collectors/overview/FleetCardsGrid.tsx @@ -32,7 +32,7 @@ import type { FleetStatsSummary } from '../types'; const Grid = styled.div( ({ theme }) => css` display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(200px, 300px)); gap: ${theme.spacings.sm}; `, ); From 15c737ce1812749f08790c93b68e411ba0f6149f Mon Sep 17 00:00:00 2001 From: Kay Roepke Date: Tue, 14 Jul 2026 17:04:09 +0200 Subject: [PATCH 09/20] give macOS Unified Logging badge enough space in the column --- .../src/components/collectors/sources/ColumnRenderers.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graylog2-web-interface/src/components/collectors/sources/ColumnRenderers.tsx b/graylog2-web-interface/src/components/collectors/sources/ColumnRenderers.tsx index be5d4ab7bacc..df6dd00f4640 100644 --- a/graylog2-web-interface/src/components/collectors/sources/ColumnRenderers.tsx +++ b/graylog2-web-interface/src/components/collectors/sources/ColumnRenderers.tsx @@ -31,7 +31,7 @@ const customColumnRenderers = (): ColumnRenderers => ({ }, type: { renderCell: (type: string) => , - staticWidth: 140, + staticWidth: 190, }, enabled: { renderCell: (_enabled: boolean, source: Source) => ( From c3f274ccf3a67380a33a2b17b868dafd676909a7 Mon Sep 17 00:00:00 2001 From: Kay Roepke Date: Tue, 14 Jul 2026 17:07:23 +0200 Subject: [PATCH 10/20] add changelog --- changelog/unreleased/pr-26542.toml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 changelog/unreleased/pr-26542.toml diff --git a/changelog/unreleased/pr-26542.toml b/changelog/unreleased/pr-26542.toml new file mode 100644 index 000000000000..76382d5de7d1 --- /dev/null +++ b/changelog/unreleased/pr-26542.toml @@ -0,0 +1,6 @@ +type = "a" +message = "Add macOS Unified Logging collector source" + +issues = [""] +pulls = ["26542"] + From 36b5f201ca03244b4f8e7aa5bf78d7dedf578aaf Mon Sep 17 00:00:00 2001 From: Kay Roepke Date: Wed, 22 Jul 2026 11:33:49 +0200 Subject: [PATCH 11/20] Update changelog/unreleased/pr-26542.toml Co-authored-by: Bernd Ahlers --- changelog/unreleased/pr-26542.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/unreleased/pr-26542.toml b/changelog/unreleased/pr-26542.toml index 76382d5de7d1..e008a3b32fa1 100644 --- a/changelog/unreleased/pr-26542.toml +++ b/changelog/unreleased/pr-26542.toml @@ -1,5 +1,5 @@ type = "a" -message = "Add macOS Unified Logging collector source" +message = "Add macOS Unified Logging Collector source" issues = [""] pulls = ["26542"] From a7cfb2c4c452a746212bbbd621c8c1d56eb8214b Mon Sep 17 00:00:00 2001 From: Kay Roepke Date: Wed, 22 Jul 2026 11:34:44 +0200 Subject: [PATCH 12/20] Update changelog/unreleased/pr-26542.toml Co-authored-by: Bernd Ahlers --- changelog/unreleased/pr-26542.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/changelog/unreleased/pr-26542.toml b/changelog/unreleased/pr-26542.toml index e008a3b32fa1..4a1763e8ce95 100644 --- a/changelog/unreleased/pr-26542.toml +++ b/changelog/unreleased/pr-26542.toml @@ -1,6 +1,5 @@ type = "a" message = "Add macOS Unified Logging Collector source" -issues = [""] pulls = ["26542"] From efab43bbfc0fe8f6002ace69783a9a71bb219fad Mon Sep 17 00:00:00 2001 From: Kay Roepke Date: Wed, 22 Jul 2026 16:51:37 +0200 Subject: [PATCH 13/20] remove start_date from config, max_poll_interval, max_log_age are now required start_date is no longer set, we only need max_log_age as the relative starting point and calculate `--start` values in the collector the predicate default now lives in the frontend only, the backend allows empty predicates (valid but probably impractical settings) improved validation for max_poll_interval and max_log_age in both frontend and backend --- .../MacOSUnifiedLoggingReceiverConfig.java | 30 +----- .../db/MacOSUnifiedLoggingSourceConfig.java | 37 +++----- ...MacOSUnifiedLoggingReceiverConfigTest.java | 2 +- .../MacOSUnifiedLoggingSourceConfigTest.java | 61 ++++++++++++ .../collectors/db/SourceConfigTest.java | 5 - .../collectors/sources/SourceFormModal.tsx | 93 +++++++++++++------ .../src/components/collectors/types.ts | 5 +- 7 files changed, 146 insertions(+), 87 deletions(-) create mode 100644 graylog2-server/src/test/java/org/graylog/collectors/db/MacOSUnifiedLoggingSourceConfigTest.java diff --git a/graylog2-server/src/main/java/org/graylog/collectors/config/receiver/MacOSUnifiedLoggingReceiverConfig.java b/graylog2-server/src/main/java/org/graylog/collectors/config/receiver/MacOSUnifiedLoggingReceiverConfig.java index 1f566be8d9b5..04bd2974b9fa 100644 --- a/graylog2-server/src/main/java/org/graylog/collectors/config/receiver/MacOSUnifiedLoggingReceiverConfig.java +++ b/graylog2-server/src/main/java/org/graylog/collectors/config/receiver/MacOSUnifiedLoggingReceiverConfig.java @@ -38,7 +38,7 @@ * (a bounded, finite collection window) options are intentionally omitted: the collector is meant * for continuous live tailing, so those would only ever produce one-shot behavior. * - * @see macOS Unified Logging Receiver + * @see macOS Unified Logging Receiver */ @AutoValue @JsonInclude(JsonInclude.Include.NON_NULL) @@ -58,16 +58,10 @@ public EnumSet osSupport() { @JsonProperty("predicate") public abstract String predicate(); - @Nullable - @JsonProperty("start_time") - public abstract String startTime(); - - @Nullable @JsonProperty("max_poll_interval") @JsonSerialize(using = GoDurationSerializer.class) public abstract Duration maxPollInterval(); - @Nullable @JsonProperty("max_log_age") @JsonSerialize(using = GoDurationSerializer.class) public abstract Duration maxLogAge(); @@ -81,26 +75,10 @@ public String format() { @JsonProperty("storage") public abstract String storage(); - // Default predicate: security-relevant subsystems, error severity and above. - static final String DEFAULT_PREDICATE = "subsystem IN {" - + "'com.apple.opendirectoryd'," - + "'com.apple.authorization'," - + "'com.apple.loginwindow'," - + "'com.apple.securityd'," - + "'com.apple.TCC'," - + "'com.apple.alf'," - + "'com.apple.networkextension'," - + "'com.apple.DiskManagement'," - + "'com.apple.CoreStorage'," - + "'com.apple.endpointsecurity'," - + "'com.apple.syspolicyd'," - + "'com.apple.launchd'" - + "} AND messageType >= error"; public static Builder builder(String id) { return new AutoValue_MacOSUnifiedLoggingReceiverConfig.Builder() .name(f("macos_unified_logging/%s", id)) - .predicate(DEFAULT_PREDICATE) .maxPollInterval(Duration.ofSeconds(30)) .maxLogAge(Duration.ofHours(24)) .storage(FileStorageExtensionConfig.defaultInstance().name()); @@ -112,11 +90,9 @@ public abstract static class Builder { public abstract Builder predicate(@Nullable String predicate); - public abstract Builder startTime(@Nullable String startTime); - - public abstract Builder maxPollInterval(@Nullable Duration maxPollInterval); + public abstract Builder maxPollInterval(Duration maxPollInterval); - public abstract Builder maxLogAge(@Nullable Duration maxLogAge); + public abstract Builder maxLogAge(Duration maxLogAge); public abstract Builder storage(String storage); diff --git a/graylog2-server/src/main/java/org/graylog/collectors/db/MacOSUnifiedLoggingSourceConfig.java b/graylog2-server/src/main/java/org/graylog/collectors/db/MacOSUnifiedLoggingSourceConfig.java index 9c3cc396a3ad..3412d968440b 100644 --- a/graylog2-server/src/main/java/org/graylog/collectors/db/MacOSUnifiedLoggingSourceConfig.java +++ b/graylog2-server/src/main/java/org/graylog/collectors/db/MacOSUnifiedLoggingSourceConfig.java @@ -21,6 +21,7 @@ import com.fasterxml.jackson.annotation.JsonTypeName; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.google.auto.value.AutoValue; +import com.google.common.base.Strings; import jakarta.annotation.Nullable; import org.graylog.collectors.config.receiver.CollectorReceiverConfig; import org.graylog.collectors.config.receiver.MacOSUnifiedLoggingReceiverConfig; @@ -42,15 +43,9 @@ public abstract class MacOSUnifiedLoggingSourceConfig implements SourceConfig { @JsonProperty("predicate") public abstract String predicate(); - @Nullable - @JsonProperty("start_time") - public abstract String startTime(); - - @Nullable @JsonProperty("max_poll_interval") public abstract Duration maxPollInterval(); - @Nullable @JsonProperty("max_log_age") public abstract Duration maxLogAge(); @@ -60,23 +55,22 @@ public static Builder builder() { @Override public void validate() { - // No required fields — predicate is optional, defaults are sensible. + // empty predicates are valid + if (maxPollInterval().isZero() || maxPollInterval().isNegative()) { + throw new IllegalArgumentException("max_poll_interval must be positive"); + } + if (maxLogAge().isNegative()) { + throw new IllegalArgumentException("max_log_age must be zero or positive"); + } } @Override public Optional toReceiverConfig(String id) { - final var builder = MacOSUnifiedLoggingReceiverConfig.builder(id); + final var builder = MacOSUnifiedLoggingReceiverConfig.builder(id) + .maxLogAge(maxLogAge()) + .maxPollInterval(maxPollInterval()); if (predicate() != null) { - builder.predicate(predicate()); - } - if (startTime() != null) { - builder.startTime(startTime()); - } - if (maxPollInterval() != null) { - builder.maxPollInterval(maxPollInterval()); - } - if (maxLogAge() != null) { - builder.maxLogAge(maxLogAge()); + builder.predicate(Strings.emptyToNull(predicate())); } return Optional.of(builder.build()); } @@ -95,14 +89,11 @@ public static Builder create() { @JsonProperty("predicate") public abstract Builder predicate(@Nullable String predicate); - @JsonProperty("start_time") - public abstract Builder startTime(@Nullable String startTime); - @JsonProperty("max_poll_interval") - public abstract Builder maxPollInterval(@Nullable Duration maxPollInterval); + public abstract Builder maxPollInterval(Duration maxPollInterval); @JsonProperty("max_log_age") - public abstract Builder maxLogAge(@Nullable Duration maxLogAge); + public abstract Builder maxLogAge(Duration maxLogAge); public abstract MacOSUnifiedLoggingSourceConfig build(); } diff --git a/graylog2-server/src/test/java/org/graylog/collectors/config/receiver/MacOSUnifiedLoggingReceiverConfigTest.java b/graylog2-server/src/test/java/org/graylog/collectors/config/receiver/MacOSUnifiedLoggingReceiverConfigTest.java index 3ee07f3b855b..665af888c81b 100644 --- a/graylog2-server/src/test/java/org/graylog/collectors/config/receiver/MacOSUnifiedLoggingReceiverConfigTest.java +++ b/graylog2-server/src/test/java/org/graylog/collectors/config/receiver/MacOSUnifiedLoggingReceiverConfigTest.java @@ -43,7 +43,7 @@ void defaultsAndSerialization() throws Exception { final var tree = objectMapper.readTree(objectMapper.writeValueAsString(config)); assertThat(tree.get("storage").asText()).isEqualTo("file_storage/default"); assertThat(tree.get("format").asText()).isEqualTo("ndjson"); - assertThat(tree.get("predicate").asText()).contains("subsystem"); + assertThat(tree.has("predicate")).isFalse(); assertThat(tree.has("max_poll_interval")).isTrue(); assertThat(tree.has("max_log_age")).isTrue(); // @JsonIgnore fields must not leak into the rendered receiver block diff --git a/graylog2-server/src/test/java/org/graylog/collectors/db/MacOSUnifiedLoggingSourceConfigTest.java b/graylog2-server/src/test/java/org/graylog/collectors/db/MacOSUnifiedLoggingSourceConfigTest.java new file mode 100644 index 000000000000..df38eedc8bc2 --- /dev/null +++ b/graylog2-server/src/test/java/org/graylog/collectors/db/MacOSUnifiedLoggingSourceConfigTest.java @@ -0,0 +1,61 @@ +package org.graylog.collectors.db; + +import org.graylog.collectors.config.receiver.CollectorReceiverConfig; +import org.graylog.collectors.config.receiver.MacOSUnifiedLoggingReceiverConfig; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class MacOSUnifiedLoggingSourceConfigTest { + + @Test + void validate() { + final MacOSUnifiedLoggingSourceConfig zeroPollInterval = MacOSUnifiedLoggingSourceConfig.builder() + .maxPollInterval(Duration.ZERO) + .maxLogAge(Duration.ZERO) + .build(); + assertThatThrownBy(zeroPollInterval::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("max_poll_interval"); + + final MacOSUnifiedLoggingSourceConfig negativePollInterval = MacOSUnifiedLoggingSourceConfig.builder() + .maxPollInterval(Duration.ofSeconds(-1)) + .maxLogAge(Duration.ZERO) + .build(); + assertThatThrownBy(negativePollInterval::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("max_poll_interval"); + + + final MacOSUnifiedLoggingSourceConfig negativeLogAge = MacOSUnifiedLoggingSourceConfig.builder() + .maxPollInterval(Duration.ofSeconds(1)) + .maxLogAge(Duration.ofSeconds(-1)) + .build(); + assertThatThrownBy(negativeLogAge::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("max_log_age"); + + } + + @Test + void toReceiverConfig() { + final Optional receiverConfigOpt = MacOSUnifiedLoggingSourceConfig.builder() + .predicate("") + .maxLogAge(Duration.ZERO) + .maxPollInterval(Duration.ofSeconds(1)) + .build() + .toReceiverConfig("test-1"); + + assertThat(receiverConfigOpt).isNotEmpty(); + final CollectorReceiverConfig receiverConfig = receiverConfigOpt.get(); + assertThat(receiverConfig).isInstanceOf(MacOSUnifiedLoggingReceiverConfig.class); + final MacOSUnifiedLoggingReceiverConfig macConfig = (MacOSUnifiedLoggingReceiverConfig) receiverConfig; + assertThat(macConfig.maxLogAge()).isEqualTo(Duration.ZERO); + assertThat(macConfig.maxPollInterval()).isEqualTo(Duration.ofSeconds(1)); + assertThat(macConfig.predicate()).isNull(); + } +} diff --git a/graylog2-server/src/test/java/org/graylog/collectors/db/SourceConfigTest.java b/graylog2-server/src/test/java/org/graylog/collectors/db/SourceConfigTest.java index 62b36d044bce..f3ced648b7f8 100644 --- a/graylog2-server/src/test/java/org/graylog/collectors/db/SourceConfigTest.java +++ b/graylog2-server/src/test/java/org/graylog/collectors/db/SourceConfigTest.java @@ -163,7 +163,6 @@ void macosUsesDefaultPredicateWhenUnset() { @Test void macosForwardsStartTimeAndDurationsToReceiverConfig() { final var config = MacOSUnifiedLoggingSourceConfig.builder() - .startTime("2026-07-10 00:00:00") .maxPollInterval(Duration.ofSeconds(15)) .maxLogAge(Duration.ofHours(6)) .build(); @@ -171,7 +170,6 @@ void macosForwardsStartTimeAndDurationsToReceiverConfig() { final var macReceiver = (MacOSUnifiedLoggingReceiverConfig) config.toReceiverConfig("src-1").orElseThrow(); - assertThat(macReceiver.startTime()).isEqualTo("2026-07-10 00:00:00"); assertThat(macReceiver.maxPollInterval()).isEqualTo(Duration.ofSeconds(15)); assertThat(macReceiver.maxLogAge()).isEqualTo(Duration.ofHours(6)); } @@ -183,7 +181,6 @@ void macosLeavesReceiverDefaultsWhenOptionalFieldsUnset() { final var macReceiver = (MacOSUnifiedLoggingReceiverConfig) config.toReceiverConfig("s").orElseThrow(); - assertThat(macReceiver.startTime()).isNull(); assertThat(macReceiver.maxPollInterval()).isEqualTo(Duration.ofSeconds(30)); assertThat(macReceiver.maxLogAge()).isEqualTo(Duration.ofHours(24)); } @@ -192,14 +189,12 @@ void macosLeavesReceiverDefaultsWhenOptionalFieldsUnset() { void macosRoundTripSerializesNewFields() throws Exception { final var original = MacOSUnifiedLoggingSourceConfig.builder() .predicate("subsystem == 'com.apple.securityd'") - .startTime("2026-07-10 00:00:00") .maxPollInterval(Duration.ofSeconds(30)) .maxLogAge(Duration.ofHours(24)) .build(); final var json = objectMapper.writeValueAsString(original); final var tree = objectMapper.readTree(json); - assertThat(tree.get("start_time").asText()).isEqualTo("2026-07-10 00:00:00"); assertThat(tree.get("max_poll_interval").asText()).isEqualTo("PT30S"); assertThat(tree.get("max_log_age").asText()).isEqualTo("PT24H"); diff --git a/graylog2-web-interface/src/components/collectors/sources/SourceFormModal.tsx b/graylog2-web-interface/src/components/collectors/sources/SourceFormModal.tsx index dd9c7cd89db2..6de6aadc7664 100644 --- a/graylog2-web-interface/src/components/collectors/sources/SourceFormModal.tsx +++ b/graylog2-web-interface/src/components/collectors/sources/SourceFormModal.tsx @@ -15,20 +15,20 @@ * . */ import * as React from 'react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { FormikTouched } from 'formik'; -import { Formik, Form } from 'formik'; +import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import type {FormikTouched, FormikErrors} from 'formik'; +import {Formik, Form} from 'formik'; import isEqual from 'lodash/isEqual'; import moment from 'moment'; -import { HelpBlock, Input } from 'components/bootstrap'; +import {HelpBlock, Input} from 'components/bootstrap'; import Modal from 'components/bootstrap/Modal'; -import { FormikInput } from 'components/common'; -import TimeUnitInput, { extractDurationAndUnit } from 'components/common/TimeUnitInput'; +import {FormikInput, InputDescription} from 'components/common'; +import TimeUnitInput, {extractDurationAndUnit} from 'components/common/TimeUnitInput'; import ModalSubmit from 'components/common/ModalSubmit'; -import { TELEMETRY_EVENT_TYPE } from 'logic/telemetry/Constants'; +import {TELEMETRY_EVENT_TYPE} from 'logic/telemetry/Constants'; -import { SOURCE_TYPE_LABELS } from './Constants'; +import {SOURCE_TYPE_LABELS} from './Constants'; import useSendCollectorsTelemetry from '../hooks/useSendCollectorsTelemetry'; import type { @@ -55,7 +55,24 @@ const defaultConfigs: Record< file: { paths: [''], read_mode: 'end' }, journald: { read_mode: 'end', priority: 'info' }, windows_event_log: { channels: [], include_default_channels: true, read_mode: 'end' }, - macos_unified_logging: {}, + macos_unified_logging: { + predicate: 'subsystem IN {\n' + + '\'com.apple.opendirectoryd\',\n' + + '\'com.apple.authorization\',\n' + + '\'com.apple.loginwindow\',\n' + + '\'com.apple.securityd\',\n' + + '\'com.apple.TCC\',\n' + + '\'com.apple.alf\',\n' + + '\'com.apple.networkextension\',\n' + + '\'com.apple.DiskManagement\',\n' + + '\'com.apple.CoreStorage\',\n' + + '\'com.apple.endpointsecurity\',\n' + + '\'com.apple.syspolicyd\',\n' + + '\'com.apple.launchd\'\n' + + '} AND messageType >= error', + max_log_age: 'PT24H', + max_poll_interval: 'PT30S', + }, }; type FormValues = { @@ -66,13 +83,38 @@ type FormValues = { config: FileSourceConfig | JournaldSourceConfig | WindowsEventLogSourceConfig | MacOSUnifiedLoggingSourceConfig; }; +// TimeUnitInput is not a formik field, so we need to validate against the entire form +const validateMacOSUnifiedLogging = ( + config: MacOSUnifiedLoggingSourceConfig, +): FormikErrors => { + const errors: FormikErrors = {}; + + if (moment.duration(config.max_poll_interval).asSeconds() <= 0) { + errors.max_poll_interval = 'Max poll interval must be at least 1 seconds.'; + } + + if (moment.duration(config.max_log_age).asSeconds() < 0) { + errors.max_log_age = 'Max log age must be 0 or a positive value.'; + } + + return errors; +}; + const validate = (values: FormValues) => { - const errors: Partial> = {}; + const errors: FormikErrors = {}; if (!values.name) { errors.name = 'Name is required'; } + if (values.source_type === 'macos_unified_logging') { + const configErrors = validateMacOSUnifiedLogging(values.config as MacOSUnifiedLoggingSourceConfig); + + if (Object.keys(configErrors).length > 0) { + errors.config = configErrors; + } + } + return errors; }; @@ -219,14 +261,16 @@ const WindowsEventLogConfigFields = ({ // Ordered largest-first so extractDurationAndUnit renders the most readable unit (e.g. PT24H -> "1 days"). const POLL_INTERVAL_UNITS = ['HOURS', 'MINUTES', 'SECONDS']; -const LOG_AGE_UNITS = ['DAYS', 'HOURS', 'MINUTES']; +const LOG_AGE_UNITS = ['DAYS', 'HOURS', 'MINUTES', 'SECONDS']; const MacOSUnifiedLoggingConfigFields = ({ config, setFieldValue, + errors, }: { config: MacOSUnifiedLoggingSourceConfig; setFieldValue: (field: string, value: unknown) => void; + errors?: FormikErrors; }) => { const pollInterval = extractDurationAndUnit(config.max_poll_interval, POLL_INTERVAL_UNITS); const logAge = extractDurationAndUnit(config.max_log_age, LOG_AGE_UNITS); @@ -234,10 +278,10 @@ const MacOSUnifiedLoggingConfigFields = ({ // TimeUnitInput reports (value, unit, checked). Unchecked clears the field so the backend default applies. const updateDuration = (field: 'max_poll_interval' | 'max_log_age') => - (value: number, unit: string, checked: boolean) => { - setFieldValue('config', { + (value: number, unit: string) => { + setFieldValue('config', { ...config, - [field]: checked ? moment.duration(value, unit as moment.unitOfTime.DurationConstructor).toISOString() : undefined, + [field]: moment.duration(value, unit as moment.unitOfTime.DurationConstructor).toISOString(), }); }; @@ -251,32 +295,24 @@ const MacOSUnifiedLoggingConfigFields = ({ value={config.predicate || ''} onChange={(e) => setFieldValue('config', { ...config, predicate: e.target.value || undefined })} /> - setFieldValue('config', { ...config, start_time: e.target.value || undefined })} - /> + + ); }; @@ -373,7 +409,7 @@ const SourceFormModal = ({ fleetId, source = undefined, onClose, onSave }: Props return ( initialValues={initialValues} onSubmit={handleSubmit} validate={validate}> - {({ isSubmitting, isValidating, values, setFieldValue, dirty, touched }) => { + {({ isSubmitting, isValidating, isValid, values, setFieldValue, dirty, touched, errors }) => { formStateRef.current = { dirty, touched, values }; return ( @@ -443,6 +479,7 @@ const SourceFormModal = ({ fleetId, source = undefined, onClose, onSave }: Props } /> )} @@ -451,7 +488,7 @@ const SourceFormModal = ({ fleetId, source = undefined, onClose, onSave }: Props submitButtonText={isEdit ? 'Update source' : 'Create source'} submitLoadingText={isEdit ? 'Updating...' : 'Creating...'} onCancel={handleClose} - disabledSubmit={isValidating} + disabledSubmit={isValidating || !isValid} isSubmitting={isSubmitting} /> diff --git a/graylog2-web-interface/src/components/collectors/types.ts b/graylog2-web-interface/src/components/collectors/types.ts index 1bf96880a68b..f438404e59bd 100644 --- a/graylog2-web-interface/src/components/collectors/types.ts +++ b/graylog2-web-interface/src/components/collectors/types.ts @@ -78,10 +78,9 @@ export type WindowsEventLogSourceConfig = { export type MacOSUnifiedLoggingSourceConfig = { predicate?: string; - start_time?: string; // ISO-8601 durations (e.g. 'PT30S', 'PT24H'); omitted values fall back to the backend defaults. - max_poll_interval?: string; - max_log_age?: string; + max_poll_interval: string; + max_log_age: string; }; export type FileSource = SourceBase & { type: 'file'; config: FileSourceConfig }; From 6f558a151e875f1077e4874fb747d55cb2bed3ce Mon Sep 17 00:00:00 2001 From: Kay Roepke Date: Wed, 22 Jul 2026 17:40:42 +0200 Subject: [PATCH 14/20] prefer library functions over local reimplementations --- .../MacOSUnifiedLoggingRecordProcessor.java | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/graylog2-server/src/main/java/org/graylog/collectors/input/processor/MacOSUnifiedLoggingRecordProcessor.java b/graylog2-server/src/main/java/org/graylog/collectors/input/processor/MacOSUnifiedLoggingRecordProcessor.java index d085d0f8d2ce..e5091deb92c0 100644 --- a/graylog2-server/src/main/java/org/graylog/collectors/input/processor/MacOSUnifiedLoggingRecordProcessor.java +++ b/graylog2-server/src/main/java/org/graylog/collectors/input/processor/MacOSUnifiedLoggingRecordProcessor.java @@ -16,12 +16,16 @@ */ package org.graylog.collectors.input.processor; +import com.google.common.base.Strings; +import org.apache.commons.lang.StringUtils; import org.graylog.inputs.otel.OTelJournal; import org.graylog.schema.EventFields; import org.graylog.schema.ProcessFields; import org.graylog.schema.UserFields; import org.graylog.schema.VendorFields; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.HashMap; import java.util.Map; @@ -52,7 +56,7 @@ public Map process(OTelJournal.Log log) { case "macos.eventType" -> putStr(result, VendorFields.VENDOR_EVENT_TYPE, value.getStringValue()); case "macos.formatString" -> putStr(result, VendorFields.VENDOR_EVENT_DESCRIPTION, value.getStringValue()); case "macos.processImagePath" -> { - final var path = emptyToNull(value.getStringValue()); + final var path = Strings.emptyToNull(value.getStringValue()); if (path != null) { processImagePath = path; result.put(ProcessFields.PROCESS_PATH, path); @@ -80,10 +84,10 @@ public Map process(OTelJournal.Log log) { // Derive the process name from the executable path. if (processImagePath != null) { - final var lastSlash = processImagePath.lastIndexOf('/'); - final var name = lastSlash >= 0 ? processImagePath.substring(lastSlash + 1) : processImagePath; - if (!name.isEmpty()) { - result.put(ProcessFields.PROCESS_NAME, name); + Path path = Paths.get(processImagePath); + final var fileName = path.getFileName(); + if (fileName != null) { + result.put(ProcessFields.PROCESS_NAME, fileName.toString()); } } @@ -91,12 +95,8 @@ public Map process(OTelJournal.Log log) { } private static void putStr(Map target, String field, String value) { - if (value != null && !value.isEmpty()) { + if (StringUtils.isNotBlank(value)) { target.put(field, value); } } - - private static String emptyToNull(String s) { - return (s == null || s.isEmpty()) ? null : s; - } } From 1f271b3dbee8327099daa0556e2571c6e1af8497 Mon Sep 17 00:00:00 2001 From: Kay Roepke Date: Wed, 22 Jul 2026 17:44:39 +0200 Subject: [PATCH 15/20] need to restate defaults for onboarding --- .../overview/onboarding/defaultSources.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/defaultSources.ts b/graylog2-web-interface/src/components/collectors/overview/onboarding/defaultSources.ts index 3ea333f22bb8..1a9fb1a41d71 100644 --- a/graylog2-web-interface/src/components/collectors/overview/onboarding/defaultSources.ts +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/defaultSources.ts @@ -45,6 +45,22 @@ const DEFAULT_SOURCES: NewSource[] = [ enabled: true, type: 'macos_unified_logging', config: { + predicate: 'subsystem IN {\n' + + '\'com.apple.opendirectoryd\',\n' + + '\'com.apple.authorization\',\n' + + '\'com.apple.loginwindow\',\n' + + '\'com.apple.securityd\',\n' + + '\'com.apple.TCC\',\n' + + '\'com.apple.alf\',\n' + + '\'com.apple.networkextension\',\n' + + '\'com.apple.DiskManagement\',\n' + + '\'com.apple.CoreStorage\',\n' + + '\'com.apple.endpointsecurity\',\n' + + '\'com.apple.syspolicyd\',\n' + + '\'com.apple.launchd\'\n' + + '} AND messageType >= error', + max_log_age: 'PT24H', + max_poll_interval: 'PT30S', }, }, { From db0e77d8831c45486e3297e25f07760a9b380b13 Mon Sep 17 00:00:00 2001 From: Kay Roepke Date: Wed, 22 Jul 2026 17:47:02 +0200 Subject: [PATCH 16/20] update comment --- graylog2-web-interface/src/components/collectors/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graylog2-web-interface/src/components/collectors/types.ts b/graylog2-web-interface/src/components/collectors/types.ts index f438404e59bd..fa59ad6268f3 100644 --- a/graylog2-web-interface/src/components/collectors/types.ts +++ b/graylog2-web-interface/src/components/collectors/types.ts @@ -78,7 +78,7 @@ export type WindowsEventLogSourceConfig = { export type MacOSUnifiedLoggingSourceConfig = { predicate?: string; - // ISO-8601 durations (e.g. 'PT30S', 'PT24H'); omitted values fall back to the backend defaults. + // ISO-8601 durations (e.g. 'PT30S', 'PT24H') max_poll_interval: string; max_log_age: string; }; From 9e13a3eaa2b13f39b070dbd4368ffae8c22188d6 Mon Sep 17 00:00:00 2001 From: Kay Roepke Date: Wed, 22 Jul 2026 17:50:30 +0200 Subject: [PATCH 17/20] add license header --- .../db/MacOSUnifiedLoggingSourceConfigTest.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/graylog2-server/src/test/java/org/graylog/collectors/db/MacOSUnifiedLoggingSourceConfigTest.java b/graylog2-server/src/test/java/org/graylog/collectors/db/MacOSUnifiedLoggingSourceConfigTest.java index df38eedc8bc2..5216ace7a993 100644 --- a/graylog2-server/src/test/java/org/graylog/collectors/db/MacOSUnifiedLoggingSourceConfigTest.java +++ b/graylog2-server/src/test/java/org/graylog/collectors/db/MacOSUnifiedLoggingSourceConfigTest.java @@ -1,3 +1,19 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ package org.graylog.collectors.db; import org.graylog.collectors.config.receiver.CollectorReceiverConfig; From fd812710e22a053047ba57841e8d44489e800570 Mon Sep 17 00:00:00 2001 From: Kay Roepke Date: Wed, 22 Jul 2026 18:10:27 +0200 Subject: [PATCH 18/20] address linter error --- .../src/components/collectors/sources/SourceFormModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graylog2-web-interface/src/components/collectors/sources/SourceFormModal.tsx b/graylog2-web-interface/src/components/collectors/sources/SourceFormModal.tsx index 6de6aadc7664..6ec44a0f402f 100644 --- a/graylog2-web-interface/src/components/collectors/sources/SourceFormModal.tsx +++ b/graylog2-web-interface/src/components/collectors/sources/SourceFormModal.tsx @@ -266,7 +266,7 @@ const LOG_AGE_UNITS = ['DAYS', 'HOURS', 'MINUTES', 'SECONDS']; const MacOSUnifiedLoggingConfigFields = ({ config, setFieldValue, - errors, + errors = undefined, }: { config: MacOSUnifiedLoggingSourceConfig; setFieldValue: (field: string, value: unknown) => void; From 79acb8922326b1cdd1c37696f6f64fe84d097381 Mon Sep 17 00:00:00 2001 From: Kay Roepke Date: Thu, 23 Jul 2026 12:09:41 +0200 Subject: [PATCH 19/20] adjusted tests since we no longer use start_time --- .../sources/SourceFormModal.test.tsx | 20 +------------------ 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/graylog2-web-interface/src/components/collectors/sources/SourceFormModal.test.tsx b/graylog2-web-interface/src/components/collectors/sources/SourceFormModal.test.tsx index 171dda15e595..862e561587ac 100644 --- a/graylog2-web-interface/src/components/collectors/sources/SourceFormModal.test.tsx +++ b/graylog2-web-interface/src/components/collectors/sources/SourceFormModal.test.tsx @@ -72,33 +72,15 @@ describe('SourceFormModal', () => { await screen.findByLabelText(/Predicate/i); }); - it('renders macOS start time, poll interval and log age fields when macos source type selected', async () => { + it('renders macOS poll interval and log age fields when macos source type selected', async () => { render(); await userEvent.selectOptions(screen.getByLabelText(/Source Type/i), 'macos_unified_logging'); - await screen.findByLabelText(/Start time/i); await screen.findByLabelText(/Max poll interval/i); await screen.findByLabelText(/Max log age/i); }); - it('includes a typed macOS start time in the saved source config', async () => { - const onSave = jest.fn().mockResolvedValue({ id: 's-1' }); - render(); - - await userEvent.selectOptions(screen.getByLabelText(/Source Type/i), 'macos_unified_logging'); - await userEvent.type(screen.getByLabelText(/Name/i), 'mac-src'); - await userEvent.type(screen.getByLabelText(/Start time/i), '2026-07-10 00:00:00'); - fireEvent.click(screen.getByRole('button', { name: /Create source/i })); - await screen.findByRole('button', { name: /Create source/i }); - - expect(onSave).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'macos_unified_logging', - config: expect.objectContaining({ start_time: '2026-07-10 00:00:00' }), - }), - ); - }); }); describe('splitToList', () => { From 9c13c85eacfd4e1b0b4251478d193b32024af9ec Mon Sep 17 00:00:00 2001 From: Kay Roepke Date: Thu, 23 Jul 2026 12:13:33 +0200 Subject: [PATCH 20/20] missed a test with new macos source config defaults --- .../graylog/collectors/db/SourceConfigTest.java | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/graylog2-server/src/test/java/org/graylog/collectors/db/SourceConfigTest.java b/graylog2-server/src/test/java/org/graylog/collectors/db/SourceConfigTest.java index f3ced648b7f8..15871941eb83 100644 --- a/graylog2-server/src/test/java/org/graylog/collectors/db/SourceConfigTest.java +++ b/graylog2-server/src/test/java/org/graylog/collectors/db/SourceConfigTest.java @@ -137,6 +137,8 @@ void serializationIncludesTypeField() throws Exception { void macosRoundTripAndReceiverConfig() throws Exception { final var original = MacOSUnifiedLoggingSourceConfig.builder() .predicate("subsystem == 'com.apple.securityd'") + .maxPollInterval(Duration.ofSeconds(1)) + .maxLogAge(Duration.ofSeconds(1)) .build(); final var json = objectMapper.writeValueAsString(original); @@ -153,11 +155,14 @@ void macosRoundTripAndReceiverConfig() throws Exception { } @Test - void macosUsesDefaultPredicateWhenUnset() { - final var config = MacOSUnifiedLoggingSourceConfig.builder().build(); + void macosAllowsEmptyPredicate() { + final var config = MacOSUnifiedLoggingSourceConfig.builder() + .maxPollInterval(Duration.ofSeconds(1)) + .maxLogAge(Duration.ofSeconds(1)) + .build(); final var macReceiver = (MacOSUnifiedLoggingReceiverConfig) config.toReceiverConfig("s").orElseThrow(); - assertThat(macReceiver.predicate()).contains("subsystem"); + assertThat(macReceiver.predicate()).isNull(); } @Test @@ -176,7 +181,10 @@ void macosForwardsStartTimeAndDurationsToReceiverConfig() { @Test void macosLeavesReceiverDefaultsWhenOptionalFieldsUnset() { - final var config = MacOSUnifiedLoggingSourceConfig.builder().build(); + final var config = MacOSUnifiedLoggingSourceConfig.builder() + .maxPollInterval(Duration.ofSeconds(30)) + .maxLogAge(Duration.ofHours(24)) + .build(); final var macReceiver = (MacOSUnifiedLoggingReceiverConfig) config.toReceiverConfig("s").orElseThrow();