chore: import upstream snapshot with attribution
MCP Bash Server CI / Test MCP Bash Server (dev) (push) Has been cancelled
MCP Bash Server CI / Test MCP Bash Server (release) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:11:39 +08:00
commit c8cebdfeee
4654 changed files with 626756 additions and 0 deletions
@@ -0,0 +1,82 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.log.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
import org.apache.hertzbeat.common.constants.CommonConstants;
import org.apache.hertzbeat.common.entity.dto.Message;
import org.apache.hertzbeat.log.service.LogProtocolAdapter;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Generic Log Ingestion Controller
* Provides a fallback endpoint for log protocols that don't have dedicated controllers.
* For OTLP protocol, use OtlpLogController instead.
*/
@Tag(name = "Log Ingestion Controller")
@RestController
@RequestMapping(path = "/api/logs", produces = MediaType.APPLICATION_JSON_VALUE)
@Slf4j
public class LogIngestionController {
private final List<LogProtocolAdapter> protocolAdapters;
public LogIngestionController(List<LogProtocolAdapter> protocolAdapters) {
this.protocolAdapters = protocolAdapters;
}
/**
* Receive log payload pushed from external system specifying the log protocol.
*
* @param protocol log protocol identifier (e.g., "vector", "loki")
* @param content raw request body
*/
@Operation(summary = "Ingest logs by protocol name")
@PostMapping(value = "/ingest/{protocol}", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Message<Void>> ingestLog(@PathVariable("protocol") String protocol,
@RequestBody String content) {
log.debug("Receive log from protocol: {}, content length: {}", protocol, content == null ? 0 : content.length());
for (LogProtocolAdapter adapter : protocolAdapters) {
if (adapter.supportProtocol().equalsIgnoreCase(protocol)) {
try {
adapter.ingest(content);
return ResponseEntity.ok(Message.success("Add extern log success"));
} catch (Exception e) {
log.error("Add log failed: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Message.fail(CommonConstants.FAIL_CODE, "Add extern log failed: " + e.getMessage()));
}
}
}
log.warn("Not support extern log from protocol: {}", protocol);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Message.fail(CommonConstants.FAIL_CODE, "Not support the " + protocol + " protocol log"));
}
}
@@ -0,0 +1,64 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.log.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.common.entity.dto.Message;
import org.apache.hertzbeat.warehouse.store.history.tsdb.HistoryDataWriter;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import static org.apache.hertzbeat.common.constants.CommonConstants.FAIL_CODE;
/**
* Controller for managing log entries in HertzBeat.
*/
@RestController
@RequestMapping(path = "/api/logs", produces = "application/json")
@Tag(name = "Log Management Controller")
@Slf4j
public class LogManagerController {
private final HistoryDataWriter historyDataWriter;
public LogManagerController(HistoryDataWriter historyDataWriter) {
this.historyDataWriter = historyDataWriter;
}
@DeleteMapping
@Operation(summary = "Batch delete logs",
description = "Batch delete logs by time timestamps. Deletes multiple log entries based on their Unix nanosecond timestamps.")
public ResponseEntity<Message<String>> batchDelete(
@Parameter(description = "List of Unix nanosecond timestamps for logs to delete", example = "1640995200000000000")
@RequestParam(required = false) List<Long> timeUnixNanos) {
boolean result = historyDataWriter.batchDeleteLogs(timeUnixNanos);
if (result) {
return ResponseEntity.ok(Message.success("Logs deleted successfully"));
} else {
return ResponseEntity.ok(Message.fail(FAIL_CODE, "Failed to delete logs"));
}
}
}
@@ -0,0 +1,233 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.log.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.apache.hertzbeat.warehouse.store.history.tsdb.HistoryDataReader;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.common.entity.dto.Message;
import org.apache.hertzbeat.common.entity.log.LogEntry;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* Log query and statistics APIs for UI consumption
*/
@RestController
@RequestMapping(path = "/api/logs", produces = "application/json")
@Tag(name = "Log Query Controller")
@Slf4j
public class LogQueryController {
private final HistoryDataReader historyDataReader;
public LogQueryController(HistoryDataReader historyDataReader) {
this.historyDataReader = historyDataReader;
}
@GetMapping("/list")
@Operation(summary = "Query logs by time range with optional filters",
description = "Query logs by [start,end] in ms and optional filters with pagination. Returns paginated log entries sorted by timestamp in descending order.")
public ResponseEntity<Message<Page<LogEntry>>> list(
@Parameter(description = "Start timestamp in milliseconds (Unix timestamp)", example = "1640995200000")
@RequestParam(value = "start", required = false) Long start,
@Parameter(description = "End timestamp in milliseconds (Unix timestamp)", example = "1641081600000")
@RequestParam(value = "end", required = false) Long end,
@Parameter(description = "Trace ID for distributed tracing", example = "1234567890abcdef")
@RequestParam(value = "traceId", required = false) String traceId,
@Parameter(description = "Span ID for distributed tracing", example = "abcdef1234567890")
@RequestParam(value = "spanId", required = false) String spanId,
@Parameter(description = "Log severity number (1-24 according to OpenTelemetry standard)", example = "9")
@RequestParam(value = "severityNumber", required = false) Integer severityNumber,
@Parameter(description = "Log severity text (TRACE, DEBUG, INFO, WARN, ERROR, FATAL)", example = "INFO")
@RequestParam(value = "severityText", required = false) String severityText,
@Parameter(description = "Log content search keyword", example = "error")
@RequestParam(value = "search", required = false) String search,
@Parameter(description = "Page index starting from 0", example = "0")
@RequestParam(value = "pageIndex", required = false, defaultValue = "0") Integer pageIndex,
@Parameter(description = "Number of items per page", example = "20")
@RequestParam(value = "pageSize", required = false, defaultValue = "20") Integer pageSize) {
Page<LogEntry> result = getPagedLogs(start, end, traceId, spanId, severityNumber, severityText, search, pageIndex, pageSize);
return ResponseEntity.ok(Message.success(result));
}
@GetMapping("/stats/overview")
@Operation(summary = "Log overview statistics",
description = "Overall counts and basic statistics with filters. Provides counts by severity levels according to OpenTelemetry standard.")
public ResponseEntity<Message<Map<String, Object>>> overviewStats(
@Parameter(description = "Start timestamp in milliseconds (Unix timestamp)", example = "1640995200000")
@RequestParam(value = "start", required = false) Long start,
@Parameter(description = "End timestamp in milliseconds (Unix timestamp)", example = "1641081600000")
@RequestParam(value = "end", required = false) Long end,
@Parameter(description = "Trace ID for distributed tracing", example = "1234567890abcdef")
@RequestParam(value = "traceId", required = false) String traceId,
@Parameter(description = "Span ID for distributed tracing", example = "abcdef1234567890")
@RequestParam(value = "spanId", required = false) String spanId,
@Parameter(description = "Log severity number (1-24 according to OpenTelemetry standard)", example = "9")
@RequestParam(value = "severityNumber", required = false) Integer severityNumber,
@Parameter(description = "Log severity text (TRACE, DEBUG, INFO, WARN, ERROR, FATAL)", example = "INFO")
@RequestParam(value = "severityText", required = false) String severityText,
@Parameter(description = "Log content search keyword", example = "error")
@RequestParam(value = "search", required = false) String search) {
List<LogEntry> logs = getFilteredLogs(start, end, traceId, spanId, severityNumber, severityText, search);
Map<String, Object> overview = new HashMap<>();
overview.put("totalCount", logs.size());
// Count by severity levels according to OpenTelemetry standard
// TRACE: 1-4, DEBUG: 5-8, INFO: 9-12, WARN: 13-16, ERROR: 17-20, FATAL: 21-24
long fatalCount = logs.stream().filter(log -> log.getSeverityNumber() != null && log.getSeverityNumber() >= 21 && log.getSeverityNumber() <= 24).count();
long errorCount = logs.stream().filter(log -> log.getSeverityNumber() != null && log.getSeverityNumber() >= 17 && log.getSeverityNumber() <= 20).count();
long warnCount = logs.stream().filter(log -> log.getSeverityNumber() != null && log.getSeverityNumber() >= 13 && log.getSeverityNumber() <= 16).count();
long infoCount = logs.stream().filter(log -> log.getSeverityNumber() != null && log.getSeverityNumber() >= 9 && log.getSeverityNumber() <= 12).count();
long debugCount = logs.stream().filter(log -> log.getSeverityNumber() != null && log.getSeverityNumber() >= 5 && log.getSeverityNumber() <= 8).count();
long traceCount = logs.stream().filter(log -> log.getSeverityNumber() != null && log.getSeverityNumber() >= 1 && log.getSeverityNumber() <= 4).count();
overview.put("fatalCount", fatalCount);
overview.put("errorCount", errorCount);
overview.put("warnCount", warnCount);
overview.put("infoCount", infoCount);
overview.put("debugCount", debugCount);
overview.put("traceCount", traceCount);
return ResponseEntity.ok(Message.success(overview));
}
@GetMapping("/stats/trace-coverage")
@Operation(summary = "Trace coverage statistics",
description = "Statistics about trace information availability. Shows how many logs have trace IDs, span IDs, or both for distributed tracing analysis.")
public ResponseEntity<Message<Map<String, Object>>> traceCoverageStats(
@Parameter(description = "Start timestamp in milliseconds (Unix timestamp)", example = "1640995200000")
@RequestParam(value = "start", required = false) Long start,
@Parameter(description = "End timestamp in milliseconds (Unix timestamp)", example = "1641081600000")
@RequestParam(value = "end", required = false) Long end,
@Parameter(description = "Trace ID for distributed tracing", example = "1234567890abcdef")
@RequestParam(value = "traceId", required = false) String traceId,
@Parameter(description = "Span ID for distributed tracing", example = "abcdef1234567890")
@RequestParam(value = "spanId", required = false) String spanId,
@Parameter(description = "Log severity number (1-24 according to OpenTelemetry standard)", example = "9")
@RequestParam(value = "severityNumber", required = false) Integer severityNumber,
@Parameter(description = "Log severity text (TRACE, DEBUG, INFO, WARN, ERROR, FATAL)", example = "INFO")
@RequestParam(value = "severityText", required = false) String severityText,
@Parameter(description = "Log content search keyword", example = "error")
@RequestParam(value = "search", required = false) String search) {
List<LogEntry> logs = getFilteredLogs(start, end, traceId, spanId, severityNumber, severityText, search);
Map<String, Object> result = new HashMap<>();
// Trace coverage statistics
long withTraceId = logs.stream().filter(log -> log.getTraceId() != null && !log.getTraceId().isEmpty()).count();
long withSpanId = logs.stream().filter(log -> log.getSpanId() != null && !log.getSpanId().isEmpty()).count();
long withBothTraceAndSpan = logs.stream().filter(log ->
log.getTraceId() != null && !log.getTraceId().isEmpty()
&& log.getSpanId() != null && !log.getSpanId().isEmpty()).count();
long withoutTrace = logs.size() - withTraceId;
Map<String, Long> traceCoverage = new HashMap<>();
traceCoverage.put("withTrace", withTraceId);
traceCoverage.put("withoutTrace", withoutTrace);
traceCoverage.put("withSpan", withSpanId);
traceCoverage.put("withBothTraceAndSpan", withBothTraceAndSpan);
result.put("traceCoverage", traceCoverage);
return ResponseEntity.ok(Message.success(result));
}
@GetMapping("/stats/trend")
@Operation(summary = "Log trend over time",
description = "Count logs by hour intervals with filters. Groups logs by hour and provides time-series data for trend analysis.")
public ResponseEntity<Message<Map<String, Object>>> trendStats(
@Parameter(description = "Start timestamp in milliseconds (Unix timestamp)", example = "1640995200000")
@RequestParam(value = "start", required = false) Long start,
@Parameter(description = "End timestamp in milliseconds (Unix timestamp)", example = "1641081600000")
@RequestParam(value = "end", required = false) Long end,
@Parameter(description = "Trace ID for distributed tracing", example = "1234567890abcdef")
@RequestParam(value = "traceId", required = false) String traceId,
@Parameter(description = "Span ID for distributed tracing", example = "abcdef1234567890")
@RequestParam(value = "spanId", required = false) String spanId,
@Parameter(description = "Log severity number (1-24 according to OpenTelemetry standard)", example = "9")
@RequestParam(value = "severityNumber", required = false) Integer severityNumber,
@Parameter(description = "Log severity text (TRACE, DEBUG, INFO, WARN, ERROR, FATAL)", example = "INFO")
@RequestParam(value = "severityText", required = false) String severityText,
@Parameter(description = "Log content search keyword", example = "error")
@RequestParam(value = "search", required = false) String search) {
List<LogEntry> logs = getFilteredLogs(start, end, traceId, spanId, severityNumber, severityText, search);
// Group by hour
Map<String, Long> hourlyStats = logs.stream()
.filter(log -> log.getTimeUnixNano() != null)
.collect(Collectors.groupingBy(
log -> {
long timestampMs = log.getTimeUnixNano() / 1_000_000L;
LocalDateTime dateTime = LocalDateTime.ofInstant(
Instant.ofEpochMilli(timestampMs),
ZoneId.systemDefault());
return dateTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:00"));
},
Collectors.counting()));
Map<String, Object> result = new HashMap<>();
result.put("hourlyStats", hourlyStats);
return ResponseEntity.ok(Message.success(result));
}
private List<LogEntry> getFilteredLogs(Long start, Long end, String traceId, String spanId,
Integer severityNumber, String severityText, String search) {
// Use the new multi-condition query method
return historyDataReader.queryLogsByMultipleConditions(start, end, traceId, spanId, severityNumber, severityText, search);
}
private Page<LogEntry> getPagedLogs(Long start, Long end, String traceId, String spanId,
Integer severityNumber, String severityText, String search,
Integer pageIndex, Integer pageSize) {
// Calculate pagination parameters
int offset = pageIndex * pageSize;
// Get total count and paginated data
long totalElements = historyDataReader.countLogsByMultipleConditions(start, end, traceId, spanId, severityNumber, severityText, search);
List<LogEntry> pagedLogs = historyDataReader.queryLogsByMultipleConditionsWithPagination(
start, end, traceId, spanId, severityNumber, severityText, search, offset, pageSize);
// Create PageRequest (sorted by timestamp descending)
Sort sort = Sort.by(Sort.Direction.DESC, "timeUnixNano");
PageRequest pageRequest = PageRequest.of(pageIndex, pageSize, sort);
// Return Spring Data Page object
return new PageImpl<>(pagedLogs, pageRequest, totalElements);
}
}
@@ -0,0 +1,57 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.hertzbeat.log.controller;
import static org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE;
import org.apache.hertzbeat.common.util.SnowFlakeIdGenerator;
import org.apache.hertzbeat.log.notice.LogSseFilterCriteria;
import org.apache.hertzbeat.log.notice.LogSseManager;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import io.swagger.v3.oas.annotations.Operation;
/**
* SSE controller for log streaming with filtering support
*/
@RestController
@RequestMapping(path = "/api/logs/sse", produces = {TEXT_EVENT_STREAM_VALUE})
public class LogSseController {
private final LogSseManager emitterManager;
public LogSseController(LogSseManager emitterManager) {
this.emitterManager = emitterManager;
}
/**
* Subscribe to log events with optional filtering
* @param filterCriteria Filter criteria for log events (all parameters are optional)
* @return SSE emitter for streaming log events
*/
@GetMapping(path = "/subscribe")
@Operation(summary = "Subscribe to log events with optional filtering", description = "Subscribe to log events with optional filtering")
public SseEmitter subscribe(@ModelAttribute LogSseFilterCriteria filterCriteria) {
Long clientId = SnowFlakeIdGenerator.generateId();
return emitterManager.createEmitter(clientId, filterCriteria);
}
}
@@ -0,0 +1,160 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.log.controller;
import com.fasterxml.jackson.core.io.JsonStringEncoder;
import com.google.protobuf.InvalidProtocolBufferException;
import com.google.protobuf.util.JsonFormat;
import com.google.rpc.Status;
import io.opentelemetry.proto.collector.logs.v1.ExportLogsServiceResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.log.service.impl.OtlpLogProtocolAdapter;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* OTLP Log Ingestion Controller
* Implements OTLP/HTTP specification for log ingestion.
* Supports both binary-encoded Protobuf (application/x-protobuf) and JSON-encoded Protobuf (application/json).
*
* @see <a href="https://opentelemetry.io/docs/specs/otlp/#otlphttp">OTLP/HTTP Specification</a>
*/
@Tag(name = "OTLP Log Controller")
@RestController
@RequestMapping(path = "/api/logs/otlp")
@Slf4j
public class OtlpLogController {
private static final String CONTENT_TYPE_PROTOBUF = "application/x-protobuf";
private static final ExportLogsServiceResponse EMPTY_RESPONSE = ExportLogsServiceResponse.newBuilder().build();
private final OtlpLogProtocolAdapter otlpLogProtocolAdapter;
public OtlpLogController(OtlpLogProtocolAdapter otlpLogProtocolAdapter) {
this.otlpLogProtocolAdapter = otlpLogProtocolAdapter;
}
/**
* OTLP/HTTP standard endpoint for logs with JSON-encoded Protobuf payload.
* Content-Type: application/json
*
* Response follows OTLP specification:
* - Success: HTTP 200 with ExportLogsServiceResponse (JSON encoded)
* - Failure: HTTP 400 with google.rpc.Status (JSON encoded)
*
* @param content JSON-encoded ExportLogsServiceRequest
* @return ExportLogsServiceResponse on success, Status on failure
*/
@Operation(summary = "Ingest OTLP logs (JSON format)")
@PostMapping(value = "/v1/logs", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> ingestJsonLogs(@RequestBody String content) {
log.debug("Receive OTLP JSON logs, content length: {}", content == null ? 0 : content.length());
try {
otlpLogProtocolAdapter.ingest(content);
return ResponseEntity.ok(toJsonResponse(EMPTY_RESPONSE));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(toJsonErrorResponse(e.getMessage()));
} catch (Exception e) {
// Server-side errors - unexpected failure
log.error("Unexpected error ingesting OTLP JSON logs: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(toJsonErrorResponse(e.getMessage()));
}
}
/**
* OTLP/HTTP standard endpoint for logs with binary-encoded Protobuf payload.
* Content-Type: application/x-protobuf
*
* Response follows OTLP specification:
* - Success: HTTP 200 with ExportLogsServiceResponse (binary encoded)
* - Failure: HTTP 400 with google.rpc.Status (binary encoded)
*
* @param content binary-encoded ExportLogsServiceRequest
* @return ExportLogsServiceResponse on success, Status on failure
*/
@Operation(summary = "Ingest OTLP logs (binary Protobuf format)")
@PostMapping(value = "/v1/logs", consumes = CONTENT_TYPE_PROTOBUF, produces = CONTENT_TYPE_PROTOBUF)
public ResponseEntity<byte[]> ingestBinaryLogs(@RequestBody byte[] content) {
log.debug("Receive OTLP binary logs, content length: {}", content == null ? 0 : content.length);
try {
otlpLogProtocolAdapter.ingestBinary(content);
return ResponseEntity.ok(EMPTY_RESPONSE.toByteArray());
} catch (IllegalArgumentException e) {
// Client-side validation errors - malformed request
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(createBinaryErrorResponse(e.getMessage()));
} catch (Exception e) {
// Server-side errors - unexpected failure
log.error("Unexpected error ingesting OTLP binary logs: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(createBinaryErrorResponse(e.getMessage()));
}
}
private String toJsonResponse(ExportLogsServiceResponse response) {
try {
return JsonFormat.printer().print(response);
} catch (InvalidProtocolBufferException e) {
log.error("Failed to convert ExportLogsServiceResponse to JSON: {}", e.getMessage(), e);
return "{}";
}
}
private String toJsonErrorResponse(String message) {
Status status = Status.newBuilder()
.setMessage(message != null ? message : "Unknown error")
.build();
try {
return JsonFormat.printer().print(status);
} catch (InvalidProtocolBufferException e) {
return "{\"message\":\"" + escapeJson(message) + "\"}";
}
}
/**
* Escapes a string value for safe inclusion in JSON.
*
* @param message the string to escape
* @return the escaped string, or empty string if message is null
*/
private String escapeJson(String message) {
if (message == null) {
return "";
}
char[] escaped = JsonStringEncoder.getInstance().quoteAsString(message);
return new String(escaped);
}
private byte[] createBinaryErrorResponse(String message) {
return Status.newBuilder()
.setMessage(message != null ? message : "Unknown error")
.build()
.toByteArray();
}
}
@@ -0,0 +1,116 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.hertzbeat.log.notice;
import lombok.Data;
import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;
import org.apache.hertzbeat.common.entity.log.LogEntry;
import org.springframework.util.StringUtils;
import io.swagger.v3.oas.annotations.media.Schema;
import static io.swagger.v3.oas.annotations.media.Schema.AccessMode.READ_WRITE;
/**
* Log filtering criteria for SSE (Server-Sent Events) log streaming
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@Schema(description = "Log filtering criteria for SSE (Server-Sent Events) log streaming")
public class LogSseFilterCriteria {
/**
* Numerical value of the severity.
* Smaller numerical values correspond to less severe events (such as debug events),
* larger numerical values correspond to more severe events (such as errors and critical events).
*/
@Schema(description = "Numerical value of the severity.", example = "1", accessMode = READ_WRITE)
private Integer severityNumber;
/**
* The severity text (also known as log level).
* This is the original string representation of the severity as it is known at the source.
*/
@Schema(description = "The severity text (also known as log level).", example = "INFO", accessMode = READ_WRITE)
private String severityText;
/**
* Log content text filtering (case-insensitive contains match).
*/
@Schema(description = "Log content text filtering", example = "error occurred", accessMode = READ_WRITE)
private String logContent;
/**
* A unique identifier for a trace.
* All spans from the same trace share the same trace_id.
* The ID is a 16-byte array represented as a hex string.
*/
@Schema(description = "A unique identifier for a trace.", example = "1234567890", accessMode = READ_WRITE)
private String traceId;
/**
* A unique identifier for a span within a trace.
* The ID is an 8-byte array represented as a hex string.
*/
@Schema(description = "A unique identifier for a span.", example = "1234567890", accessMode = READ_WRITE)
private String spanId;
/**
* Core filtering logic to determine if a log entry matches the criteria
* @param log Log entry to be checked
* @return boolean Whether the log entry matches the filter criteria
*/
public boolean matches(LogEntry log) {
if (log == null) return false;
// Check severity text match
if (StringUtils.hasText(severityText) && !severityText.equalsIgnoreCase(log.getSeverityText())) {
return false;
}
// Check severity number match (if both are present)
if (severityNumber != null && log.getSeverityNumber() != null
&& !severityNumber.equals(log.getSeverityNumber())) {
return false;
}
// Check log content match
if (StringUtils.hasText(logContent)) {
Object body = log.getBody();
if (body == null) {
return false;
}
String bodyStr = body.toString();
if (!StringUtils.hasText(bodyStr) || !bodyStr.toLowerCase().contains(logContent.toLowerCase())) {
return false;
}
}
// Check trace ID match
if (StringUtils.hasText(traceId) && !traceId.equalsIgnoreCase(log.getTraceId())) {
return false;
}
// Check span ID match
if (StringUtils.hasText(spanId) && !spanId.equalsIgnoreCase(log.getSpanId())) {
return false;
}
return true;
}
}
@@ -0,0 +1,209 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.hertzbeat.log.notice;
import jakarta.annotation.PreDestroy;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.common.entity.log.LogEntry;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
/**
* SSE manager for log with batch processing support for high TPS scenarios
*/
@Component
@Slf4j
@Getter
public class LogSseManager {
private static final long BATCH_INTERVAL_MS = 200;
private static final int MAX_BATCH_SIZE = 1000;
private static final int MAX_QUEUE_SIZE = 10000;
private final Map<Long, SseSubscriber> emitters = new ConcurrentHashMap<>();
private final Queue<LogEntry> logQueue = new ConcurrentLinkedQueue<>();
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "sse-batch-scheduler");
t.setDaemon(true);
return t;
});
private final ExecutorService senderPool = Executors.newThreadPerTaskExecutor(Thread.ofVirtual()
.name("sse-sender-", 0)
.uncaughtExceptionHandler((thread, throwable) ->
log.error("SSE sender has uncaughtException.", throwable))
.factory());
private final AtomicLong queueSize = new AtomicLong(0);
public LogSseManager() {
scheduler.scheduleAtFixedRate(this::flushBatch, BATCH_INTERVAL_MS, BATCH_INTERVAL_MS, TimeUnit.MILLISECONDS);
}
@PreDestroy
public void shutdown() {
scheduler.shutdown();
senderPool.shutdown();
try {
scheduler.awaitTermination(2, TimeUnit.SECONDS);
senderPool.awaitTermination(2, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
scheduler.shutdownNow();
senderPool.shutdownNow();
}
/**
* Create a new SSE emitter for a client with specified filters
*/
public SseEmitter createEmitter(Long clientId, LogSseFilterCriteria filters) {
SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);
emitter.onCompletion(() -> removeEmitter(clientId));
emitter.onTimeout(() -> removeEmitter(clientId));
emitter.onError((ex) -> removeEmitter(clientId));
emitters.put(clientId, new SseSubscriber(emitter, filters));
return emitter;
}
/**
* Queue log entry for batch processing
*/
public void broadcast(LogEntry logEntry) {
if (queueSize.incrementAndGet() > MAX_QUEUE_SIZE) {
queueSize.decrementAndGet();
return;
}
boolean offered = logQueue.offer(logEntry);
if (!offered) {
queueSize.decrementAndGet();
log.warn("Failed to enqueue log entry: {}", logEntry);
}
}
/**
* Flush queued logs to all subscribers in batch
*/
private void flushBatch() {
try {
if (logQueue.isEmpty() || emitters.isEmpty()) {
return;
}
List<LogEntry> batch = new ArrayList<>(MAX_BATCH_SIZE);
LogEntry entry;
while (batch.size() < MAX_BATCH_SIZE && (entry = logQueue.poll()) != null) {
batch.add(entry);
queueSize.decrementAndGet();
}
if (batch.isEmpty()) {
return;
}
// Send to each subscriber in parallel
for (Map.Entry<Long, SseSubscriber> e : emitters.entrySet()) {
Long clientId = e.getKey();
SseSubscriber subscriber = e.getValue();
List<LogEntry> filtered = filterLogs(batch, subscriber.filters);
if (!filtered.isEmpty()) {
senderPool.submit(() -> sendToSubscriber(clientId, subscriber.emitter, filtered));
}
}
} catch (Exception e) {
log.error("Error in flushBatch: {}", e.getMessage(), e);
}
}
private void sendToSubscriber(Long clientId, SseEmitter emitter, List<LogEntry> logs) {
try {
long batchTimestamp = System.currentTimeMillis();
int sequenceNumber = 0;
for (LogEntry logEntry : logs) {
String eventId = batchTimestamp + "-" + sequenceNumber++;
emitter.send(SseEmitter.event()
.id(eventId)
.name("LOG_EVENT")
.data(logEntry));
}
} catch (IOException | IllegalStateException e) {
safeComplete(clientId, emitter);
} catch (Exception e) {
log.error("Failed to send to client {}: {}", clientId, e.getMessage());
safeComplete(clientId, emitter);
}
}
private void safeComplete(Long clientId, SseEmitter emitter) {
try {
emitter.complete();
} catch (Exception ignored) {
}
removeEmitter(clientId);
}
private List<LogEntry> filterLogs(List<LogEntry> logs, LogSseFilterCriteria filters) {
if (filters == null) {
return logs;
}
List<LogEntry> filtered = new ArrayList<>();
for (LogEntry log : logs) {
if (filters.matches(log)) {
filtered.add(log);
}
}
return filtered;
}
private void removeEmitter(Long clientId) {
emitters.remove(clientId);
}
public long getQueueSize() {
return queueSize.get();
}
/**
* SseSubscriber for SseEmitter and LogSseFilterCriteria
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class SseSubscriber {
private SseEmitter emitter;
private LogSseFilterCriteria filters;
}
}
@@ -0,0 +1,41 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.log.service;
/**
* Adapter interface for ingesting logs pushed via different protocols
* (e.g. OTLP, Loki, Filebeat, Vector).
* Implementations should:
* 1. Parse raw HTTP payload of their protocol.
* 2. Convert data to LogEntry.
* 3. Forward / persist it to downstream pipeline.
*/
public interface LogProtocolAdapter {
/**
* Ingest log payload pushed from external system.
*
* @param content raw request body string
*/
void ingest(String content);
/**
* Identifier of the protocol this adapter supports ("otlp", "vector", etc.)
*/
String supportProtocol();
}
@@ -0,0 +1,281 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.log.service.impl;
import com.google.protobuf.InvalidProtocolBufferException;
import com.google.protobuf.util.JsonFormat;
import io.opentelemetry.proto.collector.logs.v1.ExportLogsServiceRequest;
import io.opentelemetry.proto.common.v1.AnyValue;
import io.opentelemetry.proto.common.v1.KeyValue;
import io.opentelemetry.proto.common.v1.KeyValueList;
import io.opentelemetry.proto.logs.v1.LogRecord;
import io.opentelemetry.proto.logs.v1.ResourceLogs;
import io.opentelemetry.proto.logs.v1.ScopeLogs;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.common.entity.log.LogEntry;
import org.apache.hertzbeat.common.queue.CommonDataQueue;
import org.apache.hertzbeat.log.notice.LogSseManager;
import org.apache.hertzbeat.log.service.LogProtocolAdapter;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Adapter for OpenTelemetry OTLP/HTTP log ingestion.
* Supports both JSON-encoded and binary-encoded Protobuf formats.
*
* @see <a href="https://opentelemetry.io/docs/specs/otlp/#otlphttp">OTLP/HTTP Specification</a>
*/
@Slf4j
@Service
public class OtlpLogProtocolAdapter implements LogProtocolAdapter {
private static final String PROTOCOL_NAME = "otlp";
private final CommonDataQueue commonDataQueue;
private final LogSseManager logSseManager;
public OtlpLogProtocolAdapter(CommonDataQueue commonDataQueue, LogSseManager logSseManager) {
this.commonDataQueue = commonDataQueue;
this.logSseManager = logSseManager;
}
@Override
public void ingest(String content) {
if (content == null || content.isEmpty()) {
log.warn("Received empty OTLP JSON log payload - skip processing.");
return;
}
ExportLogsServiceRequest.Builder builder = ExportLogsServiceRequest.newBuilder();
try {
JsonFormat.parser().ignoringUnknownFields().merge(content, builder);
ExportLogsServiceRequest request = builder.build();
processLogsRequest(request, "JSON");
} catch (InvalidProtocolBufferException e) {
log.error("Failed to parse OTLP JSON log payload: {}", e.getMessage());
throw new IllegalArgumentException("Invalid OTLP JSON log content", e);
}
}
/**
* Ingest binary-encoded Protobuf log payload (OTLP-specific).
*
* @param content binary-encoded ExportLogsServiceRequest
*/
public void ingestBinary(byte[] content) {
if (content == null || content.length == 0) {
log.warn("Received empty OTLP binary log payload - skip processing.");
return;
}
try {
ExportLogsServiceRequest request = ExportLogsServiceRequest.parseFrom(content);
processLogsRequest(request, "binary");
} catch (InvalidProtocolBufferException e) {
log.error("Failed to parse OTLP binary log payload: {}", e.getMessage());
throw new IllegalArgumentException("Invalid OTLP binary log content", e);
}
}
private void processLogsRequest(ExportLogsServiceRequest request, String format) {
List<LogEntry> logEntries = extractLogEntries(request);
log.debug("Successfully extracted {} log entries from OTLP {} payload", logEntries.size(), format);
commonDataQueue.sendLogEntryToStorageBatch(logEntries);
commonDataQueue.sendLogEntryToAlertBatch(logEntries);
logEntries.forEach(logSseManager::broadcast);
}
/**
* Extract LogEntry instances from ExportLogsServiceRequest.
*
* @param request the OTLP export logs service request
* @return list of extracted log entries
*/
private List<LogEntry> extractLogEntries(ExportLogsServiceRequest request) {
List<LogEntry> logEntries = new ArrayList<>();
for (ResourceLogs resourceLogs : request.getResourceLogsList()) {
// Extract resource attributes
Map<String, Object> resourceAttributes = extractAttributes(
resourceLogs.getResource().getAttributesList()
);
for (ScopeLogs scopeLogs : resourceLogs.getScopeLogsList()) {
// Extract instrumentation scope information
LogEntry.InstrumentationScope instrumentationScope = extractInstrumentationScope(scopeLogs);
for (LogRecord logRecord : scopeLogs.getLogRecordsList()) {
LogEntry logEntry = convertLogRecordToLogEntry(
logRecord,
resourceAttributes,
instrumentationScope
);
logEntries.add(logEntry);
}
}
}
return logEntries;
}
/**
* Convert OpenTelemetry LogRecord to LogEntry.
*/
private LogEntry convertLogRecordToLogEntry(
LogRecord logRecord,
Map<String, Object> resourceAttributes,
LogEntry.InstrumentationScope instrumentationScope) {
return LogEntry.builder()
.timeUnixNano(logRecord.getTimeUnixNano())
.observedTimeUnixNano(logRecord.getObservedTimeUnixNano())
.severityNumber(logRecord.getSeverityNumberValue())
.severityText(logRecord.getSeverityText())
.body(extractBody(logRecord.getBody()))
.attributes(extractAttributes(logRecord.getAttributesList()))
.droppedAttributesCount(logRecord.getDroppedAttributesCount())
.traceId(bytesToHex(logRecord.getTraceId().toByteArray()))
.spanId(bytesToHex(logRecord.getSpanId().toByteArray()))
.traceFlags(logRecord.getFlags())
.resource(resourceAttributes)
.instrumentationScope(instrumentationScope)
.build();
}
/**
* Extract instrumentation scope information from ScopeLogs.
*/
private LogEntry.InstrumentationScope extractInstrumentationScope(ScopeLogs scopeLogs) {
if (!scopeLogs.hasScope()) {
return null;
}
var scope = scopeLogs.getScope();
return LogEntry.InstrumentationScope.builder()
.name(scope.getName())
.version(scope.getVersion())
.attributes(extractAttributes(scope.getAttributesList()))
.droppedAttributesCount(scope.getDroppedAttributesCount())
.build();
}
/**
* Extract attributes from a list of KeyValue pairs.
*/
private Map<String, Object> extractAttributes(List<KeyValue> keyValueList) {
if (keyValueList == null || keyValueList.isEmpty()) {
return new HashMap<>();
}
AnyValue anyValue = AnyValue.newBuilder()
.setKvlistValue(KeyValueList.newBuilder()
.addAllValues(keyValueList)
.build())
.build();
Object extractedAnyValue = extractAnyValue(anyValue);
if (extractedAnyValue instanceof Map<?, ?> genericMap) {
Map<String, Object> resultMap = new HashMap<>();
for (Map.Entry<?, ?> entry : genericMap.entrySet()) {
if (entry.getKey() instanceof String) {
resultMap.put((String) entry.getKey(), entry.getValue());
}
}
return resultMap;
} else {
return new HashMap<>();
}
}
/**
* Extract body content from AnyValue.
*/
private Object extractBody(AnyValue body) {
return extractAnyValue(body);
}
/**
* Extract value from OpenTelemetry AnyValue.
*/
private Object extractAnyValue(AnyValue anyValue) {
switch (anyValue.getValueCase()) {
case STRING_VALUE:
return anyValue.getStringValue();
case BOOL_VALUE:
return anyValue.getBoolValue();
case INT_VALUE:
return anyValue.getIntValue();
case DOUBLE_VALUE:
return anyValue.getDoubleValue();
case ARRAY_VALUE:
List<Object> arrayList = new ArrayList<>();
for (AnyValue item : anyValue.getArrayValue().getValuesList()) {
arrayList.add(extractAnyValue(item));
}
return arrayList;
case KVLIST_VALUE:
Map<String, Object> kvMap = new HashMap<>();
for (KeyValue kv : anyValue.getKvlistValue().getValuesList()) {
kvMap.put(normalizeKey(kv.getKey()), extractAnyValue(kv.getValue()));
}
return kvMap;
case BYTES_VALUE:
return anyValue.getBytesValue().toByteArray();
case VALUE_NOT_SET:
default:
return null;
}
}
/**
* Normalize key by replacing dots and spaces with underscores.
*
* @param key the original key
* @return normalized key with dots and spaces replaced by underscores
*/
private String normalizeKey(String key) {
if (key == null) {
return null;
}
return key.replace(".", "_").replace(" ", "_");
}
/**
* Convert byte array to hex string.
*/
private String bytesToHex(byte[] bytes) {
if (bytes == null || bytes.length == 0) {
return null;
}
StringBuilder hexString = new StringBuilder();
for (byte b : bytes) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) {
hexString.append('0');
}
hexString.append(hex);
}
return hexString.toString();
}
@Override
public String supportProtocol() {
return PROTOCOL_NAME;
}
}
@@ -0,0 +1,117 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.log.controller;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import java.util.Arrays;
import java.util.List;
import org.apache.hertzbeat.common.constants.CommonConstants;
import org.apache.hertzbeat.log.service.LogProtocolAdapter;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
/**
* Unit test for {@link LogIngestionController}
*/
@ExtendWith(MockitoExtension.class)
class LogIngestionControllerTest {
private MockMvc mockMvc;
@Mock
private LogProtocolAdapter vectorAdapter;
private LogIngestionController logIngestionController;
@BeforeEach
void setUp() {
List<LogProtocolAdapter> adapters = Arrays.asList(vectorAdapter);
this.logIngestionController = new LogIngestionController(adapters);
this.mockMvc = MockMvcBuilders.standaloneSetup(logIngestionController).build();
}
@Test
void testIngestLogWithKnownProtocol() throws Exception {
String logContent = "{\"message\":\"Test log message\"}";
when(vectorAdapter.supportProtocol()).thenReturn("vector");
doNothing().when(vectorAdapter).ingest(anyString());
mockMvc.perform(
MockMvcRequestBuilders
.post("/api/logs/ingest/vector")
.contentType(MediaType.APPLICATION_JSON)
.content(logContent)
)
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE))
.andExpect(jsonPath("$.msg").value("Add extern log success"))
.andReturn();
}
@Test
void testIngestLogWithUnsupportedProtocol() throws Exception {
String logContent = "{\"message\":\"Unsupported protocol log\"}";
when(vectorAdapter.supportProtocol()).thenReturn("vector");
mockMvc.perform(
MockMvcRequestBuilders
.post("/api/logs/ingest/unsupported")
.contentType(MediaType.APPLICATION_JSON)
.content(logContent)
)
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value((int) CommonConstants.FAIL_CODE))
.andExpect(jsonPath("$.msg").value("Not support the unsupported protocol log"));
}
@Test
void testIngestLogWithAdapterException() throws Exception {
String logContent = "{\"message\":\"Log message that will cause exception\"}";
when(vectorAdapter.supportProtocol()).thenReturn("vector");
doThrow(new IllegalArgumentException("Invalid log format")).when(vectorAdapter).ingest(anyString());
mockMvc.perform(
MockMvcRequestBuilders
.post("/api/logs/ingest/vector")
.contentType(MediaType.APPLICATION_JSON)
.content(logContent)
)
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value((int) CommonConstants.FAIL_CODE))
.andExpect(jsonPath("$.msg").value("Add extern log failed: Invalid log format"));
}
}
@@ -0,0 +1,101 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.log.controller;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import java.util.Arrays;
import org.apache.hertzbeat.common.constants.CommonConstants;
import org.apache.hertzbeat.warehouse.store.history.tsdb.HistoryDataWriter;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
/**
* Unit test for {@link LogManagerController}
*/
@ExtendWith(MockitoExtension.class)
class LogManagerControllerTest {
private MockMvc mockMvc;
@Mock
private HistoryDataWriter historyDataWriter;
private LogManagerController logManagerController;
@BeforeEach
void setUp() {
this.logManagerController = new LogManagerController(historyDataWriter);
this.mockMvc = MockMvcBuilders.standaloneSetup(logManagerController).build();
}
@Test
void testBatchDeleteLogsSuccess() throws Exception {
when(historyDataWriter.batchDeleteLogs(anyList())).thenReturn(true);
mockMvc.perform(
MockMvcRequestBuilders
.delete("/api/logs")
.param("timeUnixNanos", "1734005477630000000", "1734005477640000000")
)
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE))
.andExpect(jsonPath("$.msg").value("Logs deleted successfully"))
.andReturn();
}
@Test
void testBatchDeleteLogsFailure() throws Exception {
when(historyDataWriter.batchDeleteLogs(anyList())).thenReturn(false);
mockMvc.perform(
MockMvcRequestBuilders
.delete("/api/logs")
.param("timeUnixNanos", "1734005477630000000", "1734005477640000000")
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value((int) CommonConstants.FAIL_CODE))
.andExpect(jsonPath("$.msg").value("Failed to delete logs"));
}
@Test
void testBatchDeleteLogsWithSingleTimestamp() throws Exception {
when(historyDataWriter.batchDeleteLogs(Arrays.asList(1734005477630000000L))).thenReturn(true);
mockMvc.perform(
MockMvcRequestBuilders
.delete("/api/logs")
.param("timeUnixNanos", "1734005477630000000")
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE))
.andExpect(jsonPath("$.msg").value("Logs deleted successfully"));
}
}
@@ -0,0 +1,277 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.log.controller;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import org.apache.hertzbeat.common.constants.CommonConstants;
import org.apache.hertzbeat.common.entity.log.LogEntry;
import org.apache.hertzbeat.warehouse.store.history.tsdb.HistoryDataReader;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
/**
* Unit test for {@link LogQueryController}
*/
@ExtendWith(MockitoExtension.class)
class LogQueryControllerTest {
private MockMvc mockMvc;
@Mock
private HistoryDataReader historyDataReader;
private LogQueryController logQueryController;
@BeforeEach
void setUp() {
this.logQueryController = new LogQueryController(historyDataReader);
this.mockMvc = MockMvcBuilders.standaloneSetup(logQueryController).build();
}
@Test
void testListLogsWithAllFilters() throws Exception {
// Mock data
LogEntry logEntry1 = LogEntry.builder()
.timeUnixNano(1734005477630000000L)
.severityNumber(9)
.severityText("INFO")
.body("Test log message 1")
.traceId("trace123")
.spanId("span456")
.attributes(new HashMap<>())
.build();
LogEntry logEntry2 = LogEntry.builder()
.timeUnixNano(1734005477640000000L)
.severityNumber(17)
.severityText("ERROR")
.body("Test log message 2")
.traceId("trace123")
.spanId("span789")
.attributes(new HashMap<>())
.build();
List<LogEntry> mockLogs = Arrays.asList(logEntry1, logEntry2);
when(historyDataReader.countLogsByMultipleConditions(anyLong(), anyLong(), any(),
any(), any(), any(), any())).thenReturn(2L);
when(historyDataReader.queryLogsByMultipleConditionsWithPagination(anyLong(), anyLong(),
any(), any(), any(), any(), any(), anyInt(), anyInt()))
.thenReturn(mockLogs);
mockMvc.perform(
MockMvcRequestBuilders
.get("/api/logs/list")
.param("start", "1734005477000")
.param("end", "1734005478000")
.param("traceId", "trace123")
.param("spanId", "span456")
.param("severityNumber", "9")
.param("severityText", "INFO")
.param("pageIndex", "0")
.param("pageSize", "20")
)
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE))
.andExpect(jsonPath("$.data.content").isArray())
.andExpect(jsonPath("$.data.content.length()").value(2))
.andExpect(jsonPath("$.data.totalElements").value(2))
.andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.number").value(0));
}
@Test
void testListLogsWithoutFilters() throws Exception {
List<LogEntry> mockLogs = Arrays.asList(
LogEntry.builder()
.timeUnixNano(1734005477630000000L)
.severityNumber(9)
.severityText("INFO")
.body("Test log message")
.attributes(new HashMap<>())
.build()
);
when(historyDataReader.countLogsByMultipleConditions(any(), any(), any(),
any(), any(), any(), any())).thenReturn(1L);
when(historyDataReader.queryLogsByMultipleConditionsWithPagination(any(), any(),
any(), any(), any(), any(), any(), eq(0), eq(20)))
.thenReturn(mockLogs);
mockMvc.perform(
MockMvcRequestBuilders
.get("/api/logs/list")
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE))
.andExpect(jsonPath("$.data.content").isArray())
.andExpect(jsonPath("$.data.content.length()").value(1));
}
@Test
void testOverviewStatsWithMixedSeverityLogs() throws Exception {
// Create logs with different severity levels according to OpenTelemetry standard
List<LogEntry> mockLogs = Arrays.asList(
// TRACE (1-4)
LogEntry.builder().severityNumber(2).build(),
// DEBUG (5-8)
LogEntry.builder().severityNumber(6).build(),
// INFO (9-12)
LogEntry.builder().severityNumber(9).build(),
LogEntry.builder().severityNumber(10).build(),
// WARN (13-16)
LogEntry.builder().severityNumber(14).build(),
// ERROR (17-20)
LogEntry.builder().severityNumber(17).build(),
LogEntry.builder().severityNumber(18).build(),
// FATAL (21-24)
LogEntry.builder().severityNumber(21).build()
);
when(historyDataReader.queryLogsByMultipleConditions(any(), any(), any(),
any(), any(), any(), any())).thenReturn(mockLogs);
mockMvc.perform(
MockMvcRequestBuilders
.get("/api/logs/stats/overview")
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE))
.andExpect(jsonPath("$.data.totalCount").value(8))
.andExpect(jsonPath("$.data.traceCount").value(1))
.andExpect(jsonPath("$.data.debugCount").value(1))
.andExpect(jsonPath("$.data.infoCount").value(2))
.andExpect(jsonPath("$.data.warnCount").value(1))
.andExpect(jsonPath("$.data.errorCount").value(2))
.andExpect(jsonPath("$.data.fatalCount").value(1));
}
@Test
void testOverviewStatsWithTimeRange() throws Exception {
List<LogEntry> mockLogs = Arrays.asList(
LogEntry.builder().severityNumber(9).build(),
LogEntry.builder().severityNumber(17).build()
);
when(historyDataReader.queryLogsByMultipleConditions(eq(1734005477000L), eq(1734005478000L),
any(), any(), any(), any(), any())).thenReturn(mockLogs);
mockMvc.perform(
MockMvcRequestBuilders
.get("/api/logs/stats/overview")
.param("start", "1734005477000")
.param("end", "1734005478000")
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE))
.andExpect(jsonPath("$.data.totalCount").value(2));
}
@Test
void testTraceCoverageStats() throws Exception {
List<LogEntry> mockLogs = Arrays.asList(
// With both trace and span
LogEntry.builder().traceId("trace1").spanId("span1").build(),
LogEntry.builder().traceId("trace2").spanId("span2").build(),
// With trace only
LogEntry.builder().traceId("trace3").spanId("").build(),
// With span only
LogEntry.builder().traceId("").spanId("span4").build(),
// Without trace info
LogEntry.builder().traceId("").spanId("").build(),
LogEntry.builder().build() // null values
);
when(historyDataReader.queryLogsByMultipleConditions(any(), any(), any(),
any(), any(), any(), any())).thenReturn(mockLogs);
mockMvc.perform(
MockMvcRequestBuilders
.get("/api/logs/stats/trace-coverage")
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE))
.andExpect(jsonPath("$.data.traceCoverage.withTrace").value(3))
.andExpect(jsonPath("$.data.traceCoverage.withoutTrace").value(3))
.andExpect(jsonPath("$.data.traceCoverage.withSpan").value(3))
.andExpect(jsonPath("$.data.traceCoverage.withBothTraceAndSpan").value(2));
}
@Test
void testTrendStats() throws Exception {
// Create logs with timestamps that fall into different hours
List<LogEntry> mockLogs = Arrays.asList(
// 2023-12-12 10:00 (1734005477630000000L nano = 1734005477630L ms)
LogEntry.builder().timeUnixNano(1734005477630000000L).build(),
// Same hour
LogEntry.builder().timeUnixNano(1734005477640000000L).build(),
// Next hour: 2023-12-12 11:00 (1734009077630000000L nano = 1734009077630L ms)
LogEntry.builder().timeUnixNano(1734009077630000000L).build()
);
when(historyDataReader.queryLogsByMultipleConditions(any(), any(), any(),
any(), any(), any(), any())).thenReturn(mockLogs);
mockMvc.perform(
MockMvcRequestBuilders
.get("/api/logs/stats/trend")
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE))
.andExpect(jsonPath("$.data.hourlyStats").isMap());
}
@Test
void testTrendStatsWithNullTimestamp() throws Exception {
List<LogEntry> mockLogs = Arrays.asList(
LogEntry.builder().timeUnixNano(1734005477630000000L).build(),
LogEntry.builder().timeUnixNano(null).build() // This should be filtered out
);
when(historyDataReader.queryLogsByMultipleConditions(any(), any(), any(),
any(), any(), any(), any())).thenReturn(mockLogs);
mockMvc.perform(
MockMvcRequestBuilders
.get("/api/logs/stats/trend")
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE))
.andExpect(jsonPath("$.data.hourlyStats").isMap());
}
}
@@ -0,0 +1,113 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.log.controller;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.apache.hertzbeat.log.notice.LogSseFilterCriteria;
import org.apache.hertzbeat.log.notice.LogSseManager;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
/**
* Unit tests for the {@link LogSseController}.
*/
@ExtendWith(MockitoExtension.class)
class LogSseControllerTest {
private MockMvc mockMvc;
@Mock
private LogSseManager emitterManager;
@Captor
private ArgumentCaptor<LogSseFilterCriteria> filterCriteriaCaptor;
@BeforeEach
void setUp() {
// Initialize the controller and MockMvc instance before each test
LogSseController logSseController = new LogSseController(emitterManager);
this.mockMvc = MockMvcBuilders.standaloneSetup(logSseController).build();
}
@Test
void testSubscribeWithoutFilters() throws Exception {
// When: A request is made to the subscribe endpoint without any parameters
mockMvc.perform(get("/api/logs/sse/subscribe")
.accept(MediaType.TEXT_EVENT_STREAM_VALUE))
.andExpect(status().isOk());
// Then: The emitter manager is called with an empty filter criteria
verify(emitterManager).createEmitter(anyLong(), filterCriteriaCaptor.capture());
LogSseFilterCriteria capturedCriteria = filterCriteriaCaptor.getValue();
Assertions.assertNull(capturedCriteria.getSeverityText());
Assertions.assertNull(capturedCriteria.getSeverityNumber());
Assertions.assertNull(capturedCriteria.getTraceId());
Assertions.assertNull(capturedCriteria.getSpanId());
}
@Test
void testSubscribeWithMultipleFilters() throws Exception {
// Given: Multiple filter parameters
String severityText = "ERROR";
String severityNumber = "17";
String traceId = "abcdef1234567890abcdef1234567890";
String spanId = "abcdef1234567890";
// When: A request is made with all filter parameters
mockMvc.perform(get("/api/logs/sse/subscribe")
.param("severityText", severityText)
.param("severityNumber", severityNumber)
.param("traceId", traceId)
.param("spanId", spanId)
.accept(MediaType.TEXT_EVENT_STREAM_VALUE))
.andExpect(status().isOk());
// Then: The emitter manager is called with a criteria object containing all filter values
verify(emitterManager).createEmitter(anyLong(), filterCriteriaCaptor.capture());
LogSseFilterCriteria capturedCriteria = filterCriteriaCaptor.getValue();
Assertions.assertEquals(capturedCriteria.getSeverityText(), severityText);
Assertions.assertEquals(capturedCriteria.getSeverityNumber(), Integer.parseInt(severityNumber));
Assertions.assertEquals(capturedCriteria.getTraceId(), traceId);
Assertions.assertEquals(capturedCriteria.getSpanId(), spanId);
}
@Test
void testSubscribeWithInvalidSeverityNumber() throws Exception {
// When: A request is made with a non-integer value for severityNumber
mockMvc.perform(get("/api/logs/sse/subscribe")
.param("severityNumber", "not-a-number")
.accept(MediaType.TEXT_EVENT_STREAM_VALUE))
.andExpect(status().is4xxClientError());
}
}
@@ -0,0 +1,131 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.log.controller;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import org.apache.hertzbeat.log.service.impl.OtlpLogProtocolAdapter;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
/**
* Unit test for {@link OtlpLogController}
*/
@ExtendWith(MockitoExtension.class)
class OtlpLogControllerTest {
private static final String CONTENT_TYPE_PROTOBUF = "application/x-protobuf";
private MockMvc mockMvc;
@Mock
private OtlpLogProtocolAdapter otlpLogProtocolAdapter;
private OtlpLogController otlpLogController;
@BeforeEach
void setUp() {
this.otlpLogController = new OtlpLogController(otlpLogProtocolAdapter);
this.mockMvc = MockMvcBuilders.standaloneSetup(otlpLogController).build();
}
@Test
void testIngestJsonLogsSuccess() throws Exception {
String jsonContent = "{\"resourceLogs\":[]}";
doNothing().when(otlpLogProtocolAdapter).ingest(anyString());
mockMvc.perform(
MockMvcRequestBuilders
.post("/api/logs/otlp/v1/logs")
.contentType(MediaType.APPLICATION_JSON)
.content(jsonContent)
)
.andDo(print())
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andReturn();
}
@Test
void testIngestJsonLogsFailure() throws Exception {
String jsonContent = "{\"invalid\":\"content\"}";
doThrow(new IllegalArgumentException("Invalid OTLP JSON log content"))
.when(otlpLogProtocolAdapter).ingest(anyString());
mockMvc.perform(
MockMvcRequestBuilders
.post("/api/logs/otlp/v1/logs")
.contentType(MediaType.APPLICATION_JSON)
.content(jsonContent)
)
.andExpect(status().isBadRequest())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andReturn();
}
@Test
void testIngestBinaryLogsSuccess() throws Exception {
byte[] binaryContent = new byte[]{0x0a, 0x0b, 0x0c};
doNothing().when(otlpLogProtocolAdapter).ingestBinary(any(byte[].class));
mockMvc.perform(
MockMvcRequestBuilders
.post("/api/logs/otlp/v1/logs")
.contentType(CONTENT_TYPE_PROTOBUF)
.content(binaryContent)
)
.andDo(print())
.andExpect(status().isOk())
.andExpect(content().contentType(CONTENT_TYPE_PROTOBUF))
.andReturn();
}
@Test
void testIngestBinaryLogsFailure() throws Exception {
byte[] binaryContent = new byte[]{0x0a, 0x0b, 0x0c};
doThrow(new IllegalArgumentException("Invalid OTLP binary log content"))
.when(otlpLogProtocolAdapter).ingestBinary(any(byte[].class));
mockMvc.perform(
MockMvcRequestBuilders
.post("/api/logs/otlp/v1/logs")
.contentType(CONTENT_TYPE_PROTOBUF)
.content(binaryContent)
)
.andExpect(status().isBadRequest())
.andExpect(content().contentType(CONTENT_TYPE_PROTOBUF))
.andReturn();
}
}
@@ -0,0 +1,227 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.log.notice;
import org.apache.hertzbeat.common.entity.log.LogEntry;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Unit tests for LogSseFilterCriteria.
*/
class LogSseFilterCriteriaTest {
private LogEntry testLogEntry;
private LogSseFilterCriteria filterCriteria;
@BeforeEach
void setUp() {
// Create test LogEntry
testLogEntry = LogEntry.builder()
.severityNumber(9)
.severityText("INFO")
.traceId("1234567890abcdef1234567890abcdef")
.spanId("1234567890abcdef")
.body("Test log message")
.timeUnixNano(System.currentTimeMillis() * 1_000_000L)
.build();
filterCriteria = new LogSseFilterCriteria();
}
@Test
void testMatchesWithNoFilters() {
// Should match all logs when no filters are set
assertTrue(filterCriteria.matches(testLogEntry));
}
@Test
void testMatchesWithSeverityTextFilter() {
// Test severity text filter - match
filterCriteria.setSeverityText("INFO");
assertTrue(filterCriteria.matches(testLogEntry));
// Test severity text filter - no match
filterCriteria.setSeverityText("ERROR");
assertFalse(filterCriteria.matches(testLogEntry));
// Test severity text filter - case insensitive
filterCriteria.setSeverityText("info");
assertTrue(filterCriteria.matches(testLogEntry));
}
@Test
void testMatchesWithSeverityNumberFilter() {
// Test severity number filter - match
filterCriteria.setSeverityNumber(9);
assertTrue(filterCriteria.matches(testLogEntry));
// Test severity number filter - no match
filterCriteria.setSeverityNumber(1);
assertFalse(filterCriteria.matches(testLogEntry));
// Test severity number filter - null value
filterCriteria.setSeverityNumber(null);
assertTrue(filterCriteria.matches(testLogEntry));
}
@Test
void testMatchesWithTraceIdFilter() {
// Test Trace ID filter - match
filterCriteria.setTraceId("1234567890abcdef1234567890abcdef");
assertTrue(filterCriteria.matches(testLogEntry));
// Test Trace ID filter - no match
filterCriteria.setTraceId("abcdef1234567890abcdef1234567890");
assertFalse(filterCriteria.matches(testLogEntry));
// Test Trace ID filter - case insensitive
filterCriteria.setTraceId("1234567890ABCDEF1234567890ABCDEF");
assertTrue(filterCriteria.matches(testLogEntry));
}
@Test
void testMatchesWithSpanIdFilter() {
// Test Span ID filter - match
filterCriteria.setSpanId("1234567890abcdef");
assertTrue(filterCriteria.matches(testLogEntry));
// Test Span ID filter - no match
filterCriteria.setSpanId("abcdef1234567890");
assertFalse(filterCriteria.matches(testLogEntry));
// Test Span ID filter - case insensitive
filterCriteria.setSpanId("1234567890ABCDEF");
assertTrue(filterCriteria.matches(testLogEntry));
}
@Test
void testMatchesWithLogContentFilter() {
// Test log content filter - match
filterCriteria.setLogContent("Test log");
assertTrue(filterCriteria.matches(testLogEntry));
// Test log content filter - no match
filterCriteria.setLogContent("Error message");
assertFalse(filterCriteria.matches(testLogEntry));
// Test log content filter - case insensitive
filterCriteria.setLogContent("test log");
assertTrue(filterCriteria.matches(testLogEntry));
// Test log content filter - partial match
filterCriteria.setLogContent("message");
assertTrue(filterCriteria.matches(testLogEntry));
// Test log content filter with null body
LogEntry nullBodyLog = LogEntry.builder()
.severityNumber(9)
.severityText("INFO")
.body(null)
.build();
filterCriteria.setLogContent("test");
assertFalse(filterCriteria.matches(nullBodyLog));
}
@Test
void testMatchesWithMultipleFilters() {
// Test multiple filter combinations - all match
filterCriteria.setSeverityText("INFO");
filterCriteria.setSeverityNumber(9);
filterCriteria.setTraceId("1234567890abcdef1234567890abcdef");
filterCriteria.setSpanId("1234567890abcdef");
assertTrue(filterCriteria.matches(testLogEntry));
// Test multiple filter combinations - partial no match
filterCriteria.setSeverityText("ERROR");
assertFalse(filterCriteria.matches(testLogEntry));
}
@Test
void testMatchesWithEmptyStringFilters() {
// Test empty string filters
filterCriteria.setSeverityText("");
filterCriteria.setTraceId("");
filterCriteria.setSpanId("");
assertTrue(filterCriteria.matches(testLogEntry));
}
@Test
void testMatchesWithLogEntryHavingNullValues() {
// Create log entry with null values
LogEntry logWithNulls = LogEntry.builder()
.severityNumber(null)
.severityText(null)
.traceId(null)
.spanId(null)
.body("Test log with nulls")
.build();
// Set filter criteria
filterCriteria.setSeverityNumber(9);
filterCriteria.setSeverityText("INFO");
filterCriteria.setTraceId("1234567890abcdef1234567890abcdef");
filterCriteria.setSpanId("1234567890abcdef");
// Should not match because log entry values are null
assertFalse(filterCriteria.matches(logWithNulls));
}
@Test
void testConstructorWithAllParameters() {
// Test constructor with all parameters
LogSseFilterCriteria criteria = new LogSseFilterCriteria(
9, "INFO", null, "1234567890abcdef1234567890abcdef", "1234567890abcdef"
);
assertEquals(9, criteria.getSeverityNumber());
assertEquals("INFO", criteria.getSeverityText());
assertEquals(null, criteria.getLogContent());
assertEquals("1234567890abcdef1234567890abcdef", criteria.getTraceId());
assertEquals("1234567890abcdef", criteria.getSpanId());
// Test matching
assertTrue(criteria.matches(testLogEntry));
}
@Test
void testNoArgsConstructorAndSetters() {
// Test no-args constructor and setter methods
LogSseFilterCriteria criteria = new LogSseFilterCriteria();
criteria.setSeverityNumber(9);
criteria.setSeverityText("INFO");
criteria.setLogContent("Test log");
criteria.setTraceId("1234567890abcdef1234567890abcdef");
criteria.setSpanId("1234567890abcdef");
assertEquals(9, criteria.getSeverityNumber());
assertEquals("INFO", criteria.getSeverityText());
assertEquals("Test log", criteria.getLogContent());
assertEquals("1234567890abcdef1234567890abcdef", criteria.getTraceId());
assertEquals("1234567890abcdef", criteria.getSpanId());
// Test matching
assertTrue(criteria.matches(testLogEntry));
}
}
@@ -0,0 +1,224 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.hertzbeat.log.notice;
import org.apache.hertzbeat.common.entity.log.LogEntry;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.awaitility.Awaitility.await;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
/**
* Unit tests for {@link LogSseManager}.
*/
class LogSseManagerTest {
private LogSseManager logSseManager;
private static final Long CLIENT_ID = 1L;
@BeforeEach
void setUp() {
logSseManager = new LogSseManager();
}
@AfterEach
void tearDown() {
logSseManager.shutdown();
}
@Test
void shouldCreateAndStoreEmitter() {
// When: Creating a new emitter for a client
SseEmitter emitter = logSseManager.createEmitter(CLIENT_ID, new LogSseFilterCriteria());
// Then: The emitter should be created and stored
assertNotNull(emitter);
assertEquals(Long.MAX_VALUE, emitter.getTimeout());
assertTrue(logSseManager.getEmitters().containsKey(CLIENT_ID));
}
@Test
void shouldBroadcastLogWhenFilterMatches() throws IOException {
// Given: A client with a filter for "INFO" logs
LogSseFilterCriteria filters = new LogSseFilterCriteria();
filters.setSeverityText("INFO");
SseEmitter mockEmitter = mock(SseEmitter.class);
subscribeClient(CLIENT_ID, filters, mockEmitter);
LogEntry infoLog = createLogEntry("INFO", "An informational message");
// When: An "INFO" log is broadcast
logSseManager.broadcast(infoLog);
// Then: The log should be sent to the client (wait for batch processing)
await().atMost(500, TimeUnit.MILLISECONDS).untilAsserted(() ->
verify(mockEmitter, atLeastOnce()).send(any(SseEmitter.SseEventBuilder.class))
);
}
@Test
void shouldSendBatchOnVirtualThread() throws IOException, InterruptedException {
SseEmitter mockEmitter = mock(SseEmitter.class);
AtomicBoolean virtualThread = new AtomicBoolean(false);
CountDownLatch latch = new CountDownLatch(1);
doAnswer(invocation -> {
virtualThread.set(Thread.currentThread().isVirtual());
latch.countDown();
return null;
}).when(mockEmitter).send(any(SseEmitter.SseEventBuilder.class));
subscribeClient(CLIENT_ID, null, mockEmitter);
logSseManager.broadcast(createLogEntry("INFO", "virtual-thread-send"));
assertTrue(latch.await(1, TimeUnit.SECONDS));
assertTrue(virtualThread.get());
}
@Test
void shouldNotBroadcastLogWhenFilterDoesNotMatch() throws IOException, InterruptedException {
// Given: A client with a filter for "ERROR" logs
LogSseFilterCriteria filters = new LogSseFilterCriteria();
filters.setSeverityText("ERROR");
SseEmitter mockEmitter = mock(SseEmitter.class);
subscribeClient(CLIENT_ID, filters, mockEmitter);
LogEntry infoLog = createLogEntry("INFO", "An informational message");
// When: An "INFO" log is broadcast
logSseManager.broadcast(infoLog);
// Wait for batch processing
Thread.sleep(300);
// Then: The log should NOT be sent to the client
verify(mockEmitter, never()).send(any(SseEmitter.SseEventBuilder.class));
}
@Test
void shouldBroadcastLogWhenSubscriberHasNoFilters() throws IOException {
// Given: A client subscribed with no filters (null)
SseEmitter mockEmitter = mock(SseEmitter.class);
subscribeClient(CLIENT_ID, null, mockEmitter);
LogEntry anyLog = createLogEntry("DEBUG", "A debug message");
// When: Any log is broadcast
logSseManager.broadcast(anyLog);
// Then: The log should be sent to the client (wait for batch processing)
await().atMost(500, TimeUnit.MILLISECONDS).untilAsserted(() ->
verify(mockEmitter, atLeastOnce()).send(any(SseEmitter.SseEventBuilder.class))
);
}
@Test
void shouldBroadcastOnlyToMatchingSubscribers() throws IOException, InterruptedException {
// Given: Two clients with different filters
LogSseFilterCriteria infoFilter = new LogSseFilterCriteria();
infoFilter.setSeverityText("INFO");
SseEmitter infoEmitter = mock(SseEmitter.class);
subscribeClient(1L, infoFilter, infoEmitter);
LogSseFilterCriteria errorFilter = new LogSseFilterCriteria();
errorFilter.setSeverityText("ERROR");
SseEmitter errorEmitter = mock(SseEmitter.class);
subscribeClient(2L, errorFilter, errorEmitter);
LogEntry infoLog = createLogEntry("INFO", "An informational message");
// When: An "INFO" log is broadcast
logSseManager.broadcast(infoLog);
// Wait for batch processing
await().atMost(500, TimeUnit.MILLISECONDS).untilAsserted(() ->
verify(infoEmitter, atLeastOnce()).send(any(SseEmitter.SseEventBuilder.class))
);
// Then: The log is sent only to the client subscribed to "INFO" logs
verify(errorEmitter, never()).send(any(SseEmitter.SseEventBuilder.class));
}
@Test
void shouldRemoveEmitterWhenBroadcastFails() throws IOException {
// Given: A client whose emitter will throw an exception on send
SseEmitter mockEmitter = mock(SseEmitter.class);
doThrow(new IOException("Connection closed")).when(mockEmitter).send(any(SseEmitter.SseEventBuilder.class));
subscribeClient(CLIENT_ID, null, mockEmitter);
assertTrue(logSseManager.getEmitters().containsKey(CLIENT_ID));
LogEntry log = createLogEntry("ERROR", "An error occurred");
// When: A log is broadcast, causing an exception
logSseManager.broadcast(log);
// Then: The failing emitter should be completed and removed
await().atMost(500, TimeUnit.MILLISECONDS).untilAsserted(() -> {
verify(mockEmitter).complete();
assertFalse(logSseManager.getEmitters().containsKey(CLIENT_ID));
});
}
@Test
void shouldDropLogsWhenQueueSizeLimitReached() {
for (int i = 0; i < 10_001; i++) {
logSseManager.broadcast(createLogEntry("INFO", "log-" + i));
}
assertEquals(10_000, logSseManager.getQueueSize());
assertEquals(10_000, logSseManager.getLogQueue().size());
}
/**
* Helper method to create a subscriber and inject a mock emitter for testing
*/
private void subscribeClient(Long clientId, LogSseFilterCriteria filters, SseEmitter mockEmitter) {
logSseManager.createEmitter(clientId, filters);
LogSseManager.SseSubscriber subscriber = logSseManager.getEmitters().get(clientId);
subscriber.setEmitter(mockEmitter);
}
/**
* Helper method to create LogEntry instances
*/
private LogEntry createLogEntry(String severityText, String body) {
return LogEntry.builder()
.severityText(severityText)
.body(body)
.build();
}
}
@@ -0,0 +1,387 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.log.service.impl;
import com.google.protobuf.ByteString;
import com.google.protobuf.util.JsonFormat;
import io.opentelemetry.proto.collector.logs.v1.ExportLogsServiceRequest;
import io.opentelemetry.proto.common.v1.AnyValue;
import io.opentelemetry.proto.common.v1.KeyValue;
import io.opentelemetry.proto.logs.v1.LogRecord;
import io.opentelemetry.proto.logs.v1.ResourceLogs;
import io.opentelemetry.proto.logs.v1.ScopeLogs;
import io.opentelemetry.proto.resource.v1.Resource;
import io.opentelemetry.proto.common.v1.InstrumentationScope;
import org.apache.hertzbeat.common.entity.log.LogEntry;
import org.apache.hertzbeat.common.queue.CommonDataQueue;
import org.apache.hertzbeat.log.notice.LogSseManager;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
/**
* Unit tests for OtlpLogProtocolAdapter.
*/
@ExtendWith(MockitoExtension.class)
class OtlpLogProtocolAdapterTest {
@Mock
private CommonDataQueue commonDataQueue;
@Mock
private LogSseManager logSseManager;
private OtlpLogProtocolAdapter adapter;
@BeforeEach
void setUp() {
adapter = new OtlpLogProtocolAdapter(commonDataQueue, logSseManager);
}
@Test
void testIngestWithNullContent() {
adapter.ingest(null);
verifyNoInteractions(commonDataQueue, logSseManager);
}
@Test
void testIngestWithEmptyContent() {
adapter.ingest("");
verifyNoInteractions(commonDataQueue, logSseManager);
}
@Test
void testIngestBinaryWithNullContent() {
adapter.ingestBinary(null);
verifyNoInteractions(commonDataQueue, logSseManager);
}
@Test
void testIngestBinaryWithEmptyContent() {
adapter.ingestBinary(new byte[0]);
verifyNoInteractions(commonDataQueue, logSseManager);
}
@Test
void testIngestBinaryWithValidOtlpLogData() throws Exception {
byte[] binaryPayload = createValidOtlpLogBinaryPayload();
adapter.ingestBinary(binaryPayload);
ArgumentCaptor<List<LogEntry>> listCaptor = ArgumentCaptor.forClass(List.class);
verify(commonDataQueue, times(1)).sendLogEntryToStorageBatch(listCaptor.capture());
verify(commonDataQueue, times(1)).sendLogEntryToAlertBatch(anyList());
verify(logSseManager, times(1)).broadcast(any(LogEntry.class));
List<LogEntry> capturedList = listCaptor.getValue();
assertNotNull(capturedList);
assertEquals(1, capturedList.size());
LogEntry capturedEntry = capturedList.get(0);
assertEquals("binary-test-service", capturedEntry.getResource().get("service_name"));
assertEquals("binary log message", capturedEntry.getBody());
assertEquals("INFO", capturedEntry.getSeverityText());
}
@Test
void testIngestBinaryWithInvalidContent() {
byte[] invalidBinary = "not a valid protobuf".getBytes();
assertThrows(IllegalArgumentException.class, () -> adapter.ingestBinary(invalidBinary));
verifyNoInteractions(commonDataQueue, logSseManager);
}
@Test
void testIngestWithValidOtlpLogData() throws Exception {
String otlpPayload = createValidOtlpLogPayload();
adapter.ingest(otlpPayload);
ArgumentCaptor<List<LogEntry>> listCaptor = ArgumentCaptor.forClass(List.class);
verify(commonDataQueue, times(1)).sendLogEntryToStorageBatch(listCaptor.capture());
verify(commonDataQueue, times(1)).sendLogEntryToAlertBatch(anyList());
verify(logSseManager, times(1)).broadcast(any(LogEntry.class));
List<LogEntry> capturedList = listCaptor.getValue();
assertNotNull(capturedList);
assertEquals(1, capturedList.size());
LogEntry capturedEntry = capturedList.get(0);
assertEquals("test-service", capturedEntry.getResource().get("service_name"));
assertEquals("test-version", capturedEntry.getResource().get("service_version"));
assertEquals("test-scope", capturedEntry.getInstrumentationScope().getName());
assertEquals("1.0.0", capturedEntry.getInstrumentationScope().getVersion());
assertEquals("test log message", capturedEntry.getBody());
assertEquals("INFO", capturedEntry.getSeverityText());
assertEquals(9, capturedEntry.getSeverityNumber());
}
@Test
void testIngestWithMultipleLogRecords() throws Exception {
String otlpPayload = createOtlpPayloadWithMultipleLogs();
adapter.ingest(otlpPayload);
ArgumentCaptor<List<LogEntry>> listCaptor = ArgumentCaptor.forClass(List.class);
verify(commonDataQueue, times(1)).sendLogEntryToStorageBatch(listCaptor.capture());
verify(commonDataQueue, times(1)).sendLogEntryToAlertBatch(anyList());
verify(logSseManager, times(2)).broadcast(any(LogEntry.class));
List<LogEntry> capturedList = listCaptor.getValue();
assertEquals(2, capturedList.size());
}
@Test
void testIngestWithComplexAttributes() throws Exception {
String otlpPayload = createOtlpPayloadWithComplexAttributes();
adapter.ingest(otlpPayload);
ArgumentCaptor<List<LogEntry>> listCaptor = ArgumentCaptor.forClass(List.class);
verify(commonDataQueue, times(1)).sendLogEntryToStorageBatch(listCaptor.capture());
verify(commonDataQueue, times(1)).sendLogEntryToAlertBatch(anyList());
verify(logSseManager, times(1)).broadcast(any(LogEntry.class));
List<LogEntry> capturedList = listCaptor.getValue();
assertNotNull(capturedList);
assertEquals(1, capturedList.size());
LogEntry capturedEntry = capturedList.get(0);
Map<String, Object> attributes = capturedEntry.getAttributes();
assertEquals("string_value", attributes.get("string_attr"));
assertEquals(true, attributes.get("bool_attr"));
assertEquals(42L, attributes.get("int_attr"));
assertEquals(3.14, attributes.get("double_attr"));
List<Object> arrayAttr = (List<Object>) attributes.get("array_attr");
assertNotNull(arrayAttr);
assertEquals(3, arrayAttr.size());
assertEquals("item1", arrayAttr.get(0));
assertEquals("item2", arrayAttr.get(1));
assertEquals("item3", arrayAttr.get(2));
}
@Test
void testIngestWithTraceAndSpanIds() throws Exception {
String otlpPayload = createOtlpPayloadWithTraceSpanIds();
adapter.ingest(otlpPayload);
ArgumentCaptor<List<LogEntry>> listCaptor = ArgumentCaptor.forClass(List.class);
verify(commonDataQueue, times(1)).sendLogEntryToStorageBatch(listCaptor.capture());
verify(commonDataQueue, times(1)).sendLogEntryToAlertBatch(anyList());
verify(logSseManager, times(1)).broadcast(any(LogEntry.class));
List<LogEntry> capturedList = listCaptor.getValue();
assertEquals(1, capturedList.size());
LogEntry capturedEntry = capturedList.get(0);
assertEquals("1234567890abcdef1234567890abcdef", capturedEntry.getTraceId());
assertEquals("1234567890abcdef", capturedEntry.getSpanId());
assertEquals(1, capturedEntry.getTraceFlags());
}
@Test
void testIngestWithInvalidJsonContent() {
String invalidJson = "{ invalid json content }";
assertThrows(IllegalArgumentException.class, () -> adapter.ingest(invalidJson));
verifyNoInteractions(commonDataQueue, logSseManager);
}
@Test
void testIngestWithEmptyResourceLogs() throws Exception {
String otlpPayload = createEmptyResourceLogsPayload();
adapter.ingest(otlpPayload);
ArgumentCaptor<List<LogEntry>> listCaptor = ArgumentCaptor.forClass(List.class);
verify(commonDataQueue, times(1)).sendLogEntryToStorageBatch(listCaptor.capture());
verify(commonDataQueue, times(1)).sendLogEntryToAlertBatch(anyList());
List<LogEntry> capturedList = listCaptor.getValue();
assertNotNull(capturedList);
assertEquals(0, capturedList.size());
verifyNoInteractions(logSseManager);
}
private String createValidOtlpLogPayload() throws Exception {
ExportLogsServiceRequest request = ExportLogsServiceRequest.newBuilder()
.addResourceLogs(ResourceLogs.newBuilder()
.setResource(Resource.newBuilder()
.addAttributes(KeyValue.newBuilder()
.setKey("service.name")
.setValue(AnyValue.newBuilder().setStringValue("test-service").build())
.build())
.addAttributes(KeyValue.newBuilder()
.setKey("service.version")
.setValue(AnyValue.newBuilder().setStringValue("test-version").build())
.build())
.build())
.addScopeLogs(ScopeLogs.newBuilder()
.setScope(InstrumentationScope.newBuilder()
.setName("test-scope")
.setVersion("1.0.0")
.build())
.addLogRecords(LogRecord.newBuilder()
.setTimeUnixNano(System.currentTimeMillis() * 1_000_000)
.setObservedTimeUnixNano(System.currentTimeMillis() * 1_000_000)
.setSeverityNumberValue(9)
.setSeverityText("INFO")
.setBody(AnyValue.newBuilder().setStringValue("test log message").build())
.build())
.build())
.build())
.build();
return JsonFormat.printer().print(request);
}
private String createOtlpPayloadWithMultipleLogs() throws Exception {
ExportLogsServiceRequest request = ExportLogsServiceRequest.newBuilder()
.addResourceLogs(ResourceLogs.newBuilder()
.setResource(Resource.newBuilder().build())
.addScopeLogs(ScopeLogs.newBuilder()
.setScope(InstrumentationScope.newBuilder().build())
.addLogRecords(LogRecord.newBuilder()
.setTimeUnixNano(System.currentTimeMillis() * 1_000_000)
.setBody(AnyValue.newBuilder().setStringValue("first log").build())
.build())
.addLogRecords(LogRecord.newBuilder()
.setTimeUnixNano(System.currentTimeMillis() * 1_000_000)
.setBody(AnyValue.newBuilder().setStringValue("second log").build())
.build())
.build())
.build())
.build();
return JsonFormat.printer().print(request);
}
private String createOtlpPayloadWithComplexAttributes() throws Exception {
ExportLogsServiceRequest request = ExportLogsServiceRequest.newBuilder()
.addResourceLogs(ResourceLogs.newBuilder()
.setResource(Resource.newBuilder().build())
.addScopeLogs(ScopeLogs.newBuilder()
.setScope(InstrumentationScope.newBuilder().build())
.addLogRecords(LogRecord.newBuilder()
.setTimeUnixNano(System.currentTimeMillis() * 1_000_000)
.setBody(AnyValue.newBuilder().setStringValue("complex attributes test").build())
.addAttributes(KeyValue.newBuilder()
.setKey("string.attr")
.setValue(AnyValue.newBuilder().setStringValue("string_value").build())
.build())
.addAttributes(KeyValue.newBuilder()
.setKey("bool.attr")
.setValue(AnyValue.newBuilder().setBoolValue(true).build())
.build())
.addAttributes(KeyValue.newBuilder()
.setKey("int.attr")
.setValue(AnyValue.newBuilder().setIntValue(42).build())
.build())
.addAttributes(KeyValue.newBuilder()
.setKey("double.attr")
.setValue(AnyValue.newBuilder().setDoubleValue(3.14).build())
.build())
.addAttributes(KeyValue.newBuilder()
.setKey("array.attr")
.setValue(AnyValue.newBuilder()
.setArrayValue(io.opentelemetry.proto.common.v1.ArrayValue.newBuilder()
.addValues(AnyValue.newBuilder().setStringValue("item1").build())
.addValues(AnyValue.newBuilder().setStringValue("item2").build())
.addValues(AnyValue.newBuilder().setStringValue("item3").build())
.build())
.build())
.build())
.build())
.build())
.build())
.build();
return JsonFormat.printer().print(request);
}
private String createOtlpPayloadWithTraceSpanIds() throws Exception {
ExportLogsServiceRequest request = ExportLogsServiceRequest.newBuilder()
.addResourceLogs(ResourceLogs.newBuilder()
.setResource(Resource.newBuilder().build())
.addScopeLogs(ScopeLogs.newBuilder()
.setScope(InstrumentationScope.newBuilder().build())
.addLogRecords(LogRecord.newBuilder()
.setTimeUnixNano(System.currentTimeMillis() * 1_000_000)
.setBody(AnyValue.newBuilder().setStringValue("trace test").build())
.setTraceId(ByteString.fromHex("1234567890abcdef1234567890abcdef"))
.setSpanId(ByteString.fromHex("1234567890abcdef"))
.setFlags(1)
.build())
.build())
.build())
.build();
return JsonFormat.printer().print(request);
}
private String createEmptyResourceLogsPayload() throws Exception {
ExportLogsServiceRequest request = ExportLogsServiceRequest.newBuilder().build();
return JsonFormat.printer().print(request);
}
private byte[] createValidOtlpLogBinaryPayload() {
ExportLogsServiceRequest request = ExportLogsServiceRequest.newBuilder()
.addResourceLogs(ResourceLogs.newBuilder()
.setResource(Resource.newBuilder()
.addAttributes(KeyValue.newBuilder()
.setKey("service.name")
.setValue(AnyValue.newBuilder().setStringValue("binary-test-service").build())
.build())
.build())
.addScopeLogs(ScopeLogs.newBuilder()
.setScope(InstrumentationScope.newBuilder()
.setName("binary-test-scope")
.setVersion("1.0.0")
.build())
.addLogRecords(LogRecord.newBuilder()
.setTimeUnixNano(System.currentTimeMillis() * 1_000_000)
.setObservedTimeUnixNano(System.currentTimeMillis() * 1_000_000)
.setSeverityNumberValue(9)
.setSeverityText("INFO")
.setBody(AnyValue.newBuilder().setStringValue("binary log message").build())
.build())
.build())
.build())
.build();
return request.toByteArray();
}
}