diff --git a/README.md b/README.md index 84c64ebb8..c4fa80424 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,14 @@ To work with Jaeger, you can run the following command: `docker run --rm -e COLLECTOR_ZIPKIN_HOST_PORT=:9411 -p 16686:16686 -p 4317:4317 -p 4318:4318 -p 9411:9411 jaegertracing/all-in-one:latest` +You'll need to uncomment or add: + +``` + +``` + +In the testTool bean. + To choose between one of the collectors in the Ladybug application, there is a bean available to make your choice. You have to add the following and change the string value of this bean to the collector you want to use. For Zipkin, enter the endpoint in the string value. For Jaeger (which doesn't use a endpoint), you can just enter "jaeger": ``` diff --git a/ladybug-backend-jaxrs/pom.xml b/ladybug-backend-jaxrs/pom.xml index fc42717f0..f4c3bb1b3 100644 --- a/ladybug-backend-jaxrs/pom.xml +++ b/ladybug-backend-jaxrs/pom.xml @@ -45,5 +45,23 @@ org.projectlombok lombok + + com.google.protobuf + protobuf-java + 4.34.1 + compile + + + com.google.protobuf + protobuf-java-util + 4.34.0 + compile + + + io.opentelemetry.proto + opentelemetry-proto + 1.9.0-alpha + compile + diff --git a/ladybug-backend-jaxrs/src/main/java/org/wearefrank/ladybug/web/jaxrs/ApiAuthorizationFilter.java b/ladybug-backend-jaxrs/src/main/java/org/wearefrank/ladybug/web/jaxrs/ApiAuthorizationFilter.java index df634fd08..bdeebf70c 100644 --- a/ladybug-backend-jaxrs/src/main/java/org/wearefrank/ladybug/web/jaxrs/ApiAuthorizationFilter.java +++ b/ladybug-backend-jaxrs/src/main/java/org/wearefrank/ladybug/web/jaxrs/ApiAuthorizationFilter.java @@ -125,7 +125,7 @@ public void setTesterRoles(List testerRoles) { public void setWebServiceRoles(List webServiceRoles) { if (constructorDone) log.info("Set web service roles"); - addConfigurationPart("POST/" + Constants.LADYBUG_API_PATH + "/collector/.*$", webServiceRoles); + addConfigurationPart("POST/" + Constants.LADYBUG_API_PATH + "/traces/.*$", webServiceRoles); } public void setLadybugApiRoles(Map> ladybugApiRoles) { diff --git a/ladybug-backend-jaxrs/src/main/java/org/wearefrank/ladybug/web/jaxrs/api/CollectorApi.java b/ladybug-backend-jaxrs/src/main/java/org/wearefrank/ladybug/web/jaxrs/api/CollectorApi.java deleted file mode 100644 index 408df91f9..000000000 --- a/ladybug-backend-jaxrs/src/main/java/org/wearefrank/ladybug/web/jaxrs/api/CollectorApi.java +++ /dev/null @@ -1,45 +0,0 @@ -/* - Copyright 2021-2026 WeAreFrank! - - Licensed 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.wearefrank.ladybug.web.jaxrs.api; - -import jakarta.ws.rs.*; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; -import lombok.Setter; -import org.wearefrank.ladybug.Span; -import org.springframework.beans.factory.annotation.Autowired; - -import org.wearefrank.ladybug.web.common.CollectorApiImpl; -import org.wearefrank.ladybug.web.common.Constants; - -@Path("/" + Constants.LADYBUG_API_PATH + "/collector") -public class CollectorApi extends ApiBase { - @Autowired - private @Setter CollectorApiImpl delegate; - - @POST - public Response collectSpans(Span[] trace) { - delegate.processSpans(trace); - return Response.ok().build(); - } - - @POST - @Consumes(MediaType.APPLICATION_JSON) - public Response collectSpansJson(Span[] trace) { - delegate.processSpans(trace); - return Response.ok().build(); - } -} diff --git a/ladybug-backend-jaxrs/src/main/java/org/wearefrank/ladybug/web/jaxrs/api/TracingApi.java b/ladybug-backend-jaxrs/src/main/java/org/wearefrank/ladybug/web/jaxrs/api/TracingApi.java new file mode 100644 index 000000000..45b4bc703 --- /dev/null +++ b/ladybug-backend-jaxrs/src/main/java/org/wearefrank/ladybug/web/jaxrs/api/TracingApi.java @@ -0,0 +1,63 @@ +/* + Copyright 2021-2026 WeAreFrank! + + Licensed 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.wearefrank.ladybug.web.jaxrs.api; + +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.util.JsonFormat; +import io.opentelemetry.proto.collector.trace.v1.ExportTraceServiceResponse; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import lombok.Setter; +import org.springframework.beans.factory.annotation.Autowired; + +import org.wearefrank.ladybug.web.common.TracingApiImpl; +import org.wearefrank.ladybug.web.common.Constants; + +@Path("/" + Constants.LADYBUG_API_PATH + "/traces") +public class TracingApi extends ApiBase { + @Autowired + private @Setter TracingApiImpl delegate; + + @POST + @Consumes({"application/x-protobuf", "application/json"}) + public Response receiveSpans(@HeaderParam("Content-Type") String contentType, byte[] data) { + if (!contentType.startsWith("application/x-protobuf") && !contentType.startsWith("application/json")) { + return Response.status(Response.Status.UNSUPPORTED_MEDIA_TYPE) + .type(MediaType.APPLICATION_JSON) + .build(); + } else { + try { + delegate.addSpansToBuffer(contentType, data); + + ExportTraceServiceResponse response = ExportTraceServiceResponse.newBuilder().build(); + if (contentType.startsWith("application/x-protobuf")) { + return Response.ok(response.toByteArray()) + .type("application/x-protobuf") + .build(); + } else { + return Response.ok(JsonFormat.printer().print(response)) + .type(MediaType.APPLICATION_JSON) + .build(); + } + } catch (InvalidProtocolBufferException e) { + return Response.status(Response.Status.BAD_REQUEST) + .type(MediaType.APPLICATION_JSON) + .build(); + } + } + } +} diff --git a/ladybug-backend-springmvc/pom.xml b/ladybug-backend-springmvc/pom.xml index f05cb250f..ddd124d15 100644 --- a/ladybug-backend-springmvc/pom.xml +++ b/ladybug-backend-springmvc/pom.xml @@ -21,7 +21,6 @@ org.wearefrank ladybug-common - org.springframework.security spring-security-web @@ -50,5 +49,23 @@ org.projectlombok lombok + + com.google.protobuf + protobuf-java + 4.34.1 + compile + + + com.google.protobuf + protobuf-java-util + 4.34.0 + compile + + + io.opentelemetry.proto + opentelemetry-proto + 1.9.0-alpha + compile + diff --git a/ladybug-backend-springmvc/src/main/java/org/wearefrank/ladybug/web/springmvc/api/CollectorApi.java b/ladybug-backend-springmvc/src/main/java/org/wearefrank/ladybug/web/springmvc/api/CollectorApi.java deleted file mode 100644 index a2a252f77..000000000 --- a/ladybug-backend-springmvc/src/main/java/org/wearefrank/ladybug/web/springmvc/api/CollectorApi.java +++ /dev/null @@ -1,49 +0,0 @@ -/* - Copyright 2025 WeAreFrank! - - Licensed 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.wearefrank.ladybug.web.springmvc.api; - -import jakarta.annotation.security.RolesAllowed; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; -import org.wearefrank.ladybug.Span; -import org.wearefrank.ladybug.web.common.CollectorApiImpl; - -import lombok.Setter; - -@RestController -@RequestMapping("/collector") -@RolesAllowed("IbisWebService") -public class CollectorApi { - @Autowired - private @Setter CollectorApiImpl delegate; - - @PostMapping - public ResponseEntity collectSpans(Span[] trace) { - delegate.processSpans(trace); - return ResponseEntity.ok().build(); - } - - @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity collectSpansJson(Span[] trace) { - delegate.processSpans(trace); - return ResponseEntity.ok().build(); - } - -} \ No newline at end of file diff --git a/ladybug-backend-springmvc/src/main/java/org/wearefrank/ladybug/web/springmvc/api/TracingApi.java b/ladybug-backend-springmvc/src/main/java/org/wearefrank/ladybug/web/springmvc/api/TracingApi.java new file mode 100644 index 000000000..42e4ceab1 --- /dev/null +++ b/ladybug-backend-springmvc/src/main/java/org/wearefrank/ladybug/web/springmvc/api/TracingApi.java @@ -0,0 +1,60 @@ +/* + Copyright 2025, 2026 WeAreFrank! + + Licensed 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.wearefrank.ladybug.web.springmvc.api; + +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.util.JsonFormat; +import io.opentelemetry.proto.collector.trace.v1.ExportTraceServiceResponse; +import jakarta.annotation.security.RolesAllowed; +import lombok.Setter; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.wearefrank.ladybug.web.common.TracingApiImpl; + +@RestController +@RequestMapping("/traces") +@RolesAllowed("IbisWebService") +public class TracingApi { + @Autowired + private @Setter TracingApiImpl delegate; + + @PostMapping(consumes = {"application/x-protobuf", MediaType.APPLICATION_JSON_VALUE}) + public ResponseEntity receiveSpans(@RequestHeader("Content-Type") String contentType, @RequestBody byte[] data) { + if (!contentType.startsWith("application/x-protobuf") && !contentType.startsWith("application/json")) { + return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE).build(); + } else { + try { + delegate.addSpansToBuffer(contentType, data); + + ExportTraceServiceResponse response = ExportTraceServiceResponse.newBuilder().build(); + if (contentType.startsWith("application/x-protobuf")) { + return ResponseEntity.ok() + .contentType(org.springframework.http.MediaType.parseMediaType("application/x-protobuf")) + .body(response.toByteArray()); + } else { + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_JSON) + .body(JsonFormat.printer().print(response)); + } + } catch (InvalidProtocolBufferException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); + } + } + } +} \ No newline at end of file diff --git a/ladybug-common/pom.xml b/ladybug-common/pom.xml index d227cc6b2..9e350ba1c 100644 --- a/ladybug-common/pom.xml +++ b/ladybug-common/pom.xml @@ -129,6 +129,46 @@ io.opentelemetry opentelemetry-exporter-otlp + + io.opentelemetry.proto + opentelemetry-proto + 1.9.0-alpha + compile + + + commons-codec + commons-codec + 1.17.0 + + + com.github.ben-manes.caffeine + caffeine + 3.2.3 + + + com.google.protobuf + protobuf-java + 4.34.1 + compile + + + com.google.protobuf + protobuf-java-util + 4.34.1 + compile + + + org.mockito + mockito-core + 5.18.0 + test + + + org.mockito + mockito-junit-jupiter + 5.18.0 + test + diff --git a/ladybug-common/src/main/java/org/wearefrank/ladybug/Checkpoint.java b/ladybug-common/src/main/java/org/wearefrank/ladybug/Checkpoint.java index 74198571d..3a21ecf43 100644 --- a/ladybug-common/src/main/java/org/wearefrank/ladybug/Checkpoint.java +++ b/ladybug-common/src/main/java/org/wearefrank/ladybug/Checkpoint.java @@ -1,5 +1,5 @@ /* - Copyright 2019-2025 WeAreFrank!, 2018 Nationale-Nederlanden + Copyright 2019-2026 WeAreFrank!, 2018 Nationale-Nederlanden Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -34,6 +34,8 @@ import javax.json.bind.annotation.JsonbTransient; import javax.xml.xpath.XPathExpressionException; +import lombok.Getter; +import lombok.Setter; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -81,6 +83,9 @@ public class Checkpoint implements Serializable, Cloneable { private transient ByteArrayOutputStream messageCapturerOutputStream; private transient Map variablesPatternMap; private transient Span span = null; + private String parentId; + private String id; + private long startTime; public Checkpoint() { // Only for Java XML encoding/decoding! Use other constructor instead. @@ -95,6 +100,24 @@ public Checkpoint(Report report, String threadName, String sourceClassName, Stri this.level = level; } + @Transient + public String getId() { return this.id; } + + @Transient + public void setId(String id) { this.id = id; } + + @Transient + public String getParentId() { return this.parentId; } + + @Transient + public void setParentId(String parentId) { this.parentId = parentId; } + + @Transient + public long getStartTime() { return this.startTime; } + + @Transient + public void setStartTime(long startTime) { this.startTime = startTime; } + // JsonIgnore is used so that Jackson will not get into an infinite loop trying to reference report, // which already contains checkpoint. @JsonIgnore diff --git a/ladybug-common/src/main/java/org/wearefrank/ladybug/Report.java b/ladybug-common/src/main/java/org/wearefrank/ladybug/Report.java index 3cbba3924..142a4b008 100644 --- a/ladybug-common/src/main/java/org/wearefrank/ladybug/Report.java +++ b/ladybug-common/src/main/java/org/wearefrank/ladybug/Report.java @@ -18,18 +18,7 @@ import java.beans.Transient; import java.io.Serializable; import java.lang.invoke.MethodHandles; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Iterator; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Scanner; -import java.util.Set; +import java.util.*; import org.apache.commons.lang3.NotImplementedException; import org.apache.commons.lang3.StringUtils; @@ -134,6 +123,15 @@ public class Report implements Serializable { private transient boolean logMaxMemoryUsage = true; private transient Map> streamingMessageListeners = new HashMap<>(); private transient Map streamingMessageResults = new HashMap<>(); + private boolean beingUpdated; + + @Transient + @JsonIgnore + public boolean isBeingUpdated() { return beingUpdated; } + + @Transient + @JsonIgnore + public void setBeingUpdated(boolean beingUpdated) { this.beingUpdated = beingUpdated; } @Transient @JsonIgnore @@ -273,9 +271,62 @@ protected void init() { threadsActiveCount++; } + public void restoreRuntimeState() { + if (threads == null) { + threads = new ArrayList<>(); + } + if (threadsWithThreadCreatepoint == null) { + threadsWithThreadCreatepoint = new ArrayList<>(); + } + if (threadCheckpointIndex == null) { + threadCheckpointIndex = new HashMap<>(); + } + if (threadFirstLevel == null) { + threadFirstLevel = new HashMap<>(); + } + if (threadLevel == null) { + threadLevel = new HashMap<>(); + } + if (threadParent == null) { + threadParent = new HashMap<>(); + } + if (truncatedMessageMap == null) { + truncatedMessageMap = new RefCompareMap<>(); + } + if (streamingMessageListeners == null) { + streamingMessageListeners = new HashMap<>(); + } + if (streamingMessageResults == null) { + streamingMessageResults = new HashMap<>(); + } + + mainThread = Thread.currentThread().getName(); + + if (!threads.contains(mainThread)) { + threads.add(mainThread); + } + + int level = 0; + + if (!checkpoints.isEmpty()) { + level = checkpoints.get(checkpoints.size() - 1).getLevel(); + } + + threadCheckpointIndex.put(mainThread, checkpoints.size()); + threadFirstLevel.put(mainThread, level); + threadLevel.put(mainThread, level); + + threadsActiveCount = 1; + + reportFilterMatching = true; + logReportFilterMatching = true; + logMaxCheckpoints = true; + logMaxMemoryUsage = true; + } + protected T checkpoint(String childThreadId, String sourceClassName, String name, T message, Map messageContext, StubableCode stubableCode, StubableCodeThrowsException stubableCodeThrowsException, - Set matchingStubStrategies, int checkpointType, int levelChangeNextCheckpoint) { + Set matchingStubStrategies, int checkpointType, int levelChangeNextCheckpoint, String id, String parentId, long startTime) { if (checkpointType == CheckpointType.THREAD_CREATEPOINT.toInt()) { String parentThreadName = Thread.currentThread().getName(); if (!threads.contains(parentThreadName)) { @@ -317,8 +368,13 @@ protected T checkpoint(String childThreadId, String sourceClassName, String } } } + + if (checkpointType == CheckpointType.STARTPOINT.toInt() && parentId != null && parentId.isEmpty()) { + setName(name); + } + message = addCheckpoint(childThreadId, sourceClassName, name, message, messageContext, stubableCode, stubableCodeThrowsException, - matchingStubStrategies, checkpointType, levelChangeNextCheckpoint); + matchingStubStrategies, checkpointType, levelChangeNextCheckpoint, id, parentId, startTime); return message; } @@ -375,7 +431,7 @@ private void removeThreadCreatepoint(int index, String childThreadId) { private T addCheckpoint(String childThreadId, String sourceClassName, String name, T message, Map messageContext, StubableCode stubableCode, StubableCodeThrowsException stubableCodeThrowsException, - Set matchingStubStrategies, int checkpointType, int levelChangeNextCheckpoint) { + Set matchingStubStrategies, int checkpointType, int levelChangeNextCheckpoint, String id, String parentId, long startTime) { String threadName = Thread.currentThread().getName(); Integer index = threadCheckpointIndex.get(threadName); Integer level = threadLevel.get(threadName); @@ -441,7 +497,7 @@ private T addCheckpoint(String childThreadId, String sourceClassName, Strin } } else { message = addCheckpoint(threadName, sourceClassName, name, message, messageContext, stubableCode, - stubableCodeThrowsException, matchingStubStrategies, checkpointType, index, level); + stubableCodeThrowsException, matchingStubStrategies, checkpointType, index, level, id, parentId, startTime); } Integer newLevel = level + levelChangeNextCheckpoint; threadLevel.put(threadName, newLevel); @@ -457,9 +513,138 @@ private T addCheckpoint(String childThreadId, String sourceClassName, Strin @SneakyThrows private T addCheckpoint(String threadName, String sourceClassName, String name, T message, Map messageContext, StubableCode stubableCode, StubableCodeThrowsException stubableCodeThrowsException, - Set matchingStubStrategies, int checkpointType, Integer index, Integer level) { + Set matchingStubStrategies, int checkpointType, Integer index, Integer level, String id, String parentId, long startTime) { + if (beingUpdated && parentId != null) { + if (parentId.isEmpty()) { + if (checkpointType == CheckpointType.STARTPOINT.toInt()) { + level = 0; + index = 0; + } else if (checkpointType == CheckpointType.ENDPOINT.toInt()) { + level = 1; + index = checkpoints.size(); + } else if (checkpointType == CheckpointType.INFOPOINT.toInt()) { + level = 1; + index = 1; + } + } else if (!parentId.isEmpty()) { + if (checkpointType == CheckpointType.STARTPOINT.toInt()) { + Checkpoint parentCheckpoint = null; + + for (Checkpoint checkpoint : checkpoints) { + if (Objects.equals(checkpoint.getId(), parentId) && checkpoint.getType() == CheckpointType.STARTPOINT.toInt()) { + parentCheckpoint = checkpoint; + break; + } + } + + if (parentCheckpoint != null) { + level = parentCheckpoint.getLevel() + 1; + + if (checkpointType == CheckpointType.STARTPOINT.toInt()) { + int parentIndex = checkpoints.indexOf(parentCheckpoint); + + index = parentIndex + 1; + + if (startTime == -1) { + while (index < checkpoints.size() + && checkpoints.get(index).getLevel() > parentCheckpoint.getLevel()) { + index++; + } + } else if (parentCheckpoint.getStartTime() != -1){ + int childLevel = parentCheckpoint.getLevel() + 1; + int i = index; + + while (i < checkpoints.size()) { + Checkpoint current = checkpoints.get(i); + + if (current.getLevel() == parentCheckpoint.getLevel() + 1 + && current.getType() == CheckpointType.ENDPOINT.toInt()) { + break; + } + + if (current.getLevel() == childLevel + && current.getType() == CheckpointType.STARTPOINT.toInt()) { + if (current.getStartTime() > startTime) { + break; + } + i++; + while (i < checkpoints.size()) { + Checkpoint inner = checkpoints.get(i); + if (inner.getLevel() == childLevel + 1 + && inner.getType() == CheckpointType.ENDPOINT.toInt()) { + i++; + break; + } + i++; + } + } else { + i++; + } + } + index = i; + } + } + } else { + level = 0; + index = checkpoints.size(); + } + } else if (checkpointType == CheckpointType.ENDPOINT.toInt()) { + Checkpoint matchingStartpoint = null; + + for (Checkpoint checkpoint : checkpoints) { + if (Objects.equals(checkpoint.getId(), id) && checkpoint.getType() == CheckpointType.STARTPOINT.toInt()) { + matchingStartpoint = checkpoint; + break; + } + } + + if (matchingStartpoint != null) { + level = matchingStartpoint.getLevel() + 1; + + int parentLevel = matchingStartpoint.getLevel(); + + index = checkpoints.indexOf(matchingStartpoint) + 1; + + while (index < checkpoints.size() && checkpoints.get(index).getLevel() > parentLevel) { + index++; + } + } + } else if (checkpointType == CheckpointType.INFOPOINT.toInt()) { + Checkpoint parentCheckpoint = null; + + for (Checkpoint checkpoint : checkpoints) { + if (Objects.equals(checkpoint.getId(), parentId) + && checkpoint.getType() == CheckpointType.STARTPOINT.toInt()) { + parentCheckpoint = checkpoint; + break; + } + } + + if (parentCheckpoint != null) { + level = parentCheckpoint.getLevel() + 1; + index = checkpoints.indexOf(parentCheckpoint) + 1; + } else { + level = 0; + index = checkpoints.size(); + } + } + } + } + Checkpoint checkpoint = new Checkpoint(this, threadName, sourceClassName, name, checkpointType, level); + + if (id != null) { + checkpoint.setId(id); + } + + if (parentId != null && !parentId.isEmpty()) { + checkpoint.setParentId(parentId); + } + + checkpoint.setStartTime(startTime); + checkpoint.setMessageContext(messageContext); + if (testTool.getOpenTelemetryTracer() != null) { SpanBuilder checkpointSpanBuilder = testTool.getOpenTelemetryTracer().spanBuilder("checkpoint - " + name); for (Checkpoint checkpointInList: checkpoints) { @@ -539,6 +724,9 @@ private T addCheckpoint(String threadName, String sourceClassName, String n // Add checkpoint to the list after stubable code has been executed. Otherwise when a report in progress is // opened it might give the impression that the stubable code is already executed checkpoints.add(index, checkpoint); + + reparentOrphans(checkpoint); + for (int i = threads.indexOf(threadName); i < threads.size(); i++) { String key = threads.get(i); Integer value = threadCheckpointIndex.get(key); @@ -564,6 +752,74 @@ private T addCheckpoint(String threadName, String sourceClassName, String n return message; } + private void reparentOrphans(Checkpoint parentCheckpoint) { + if (parentCheckpoint.getId() == null) { + return; + } + + List orphans = new ArrayList<>(); + + for (Checkpoint checkpoint : checkpoints) { + if (checkpoint == parentCheckpoint) { + continue; + } + + if (Objects.equals(parentCheckpoint.getId(), checkpoint.getParentId()) + && checkpoint.getLevel() == 0) { + orphans.add(checkpoint); + } + } + + for (Checkpoint orphan : orphans) { + moveSubtree(parentCheckpoint, orphan); + } + } + + private void moveSubtree(Checkpoint newParent, Checkpoint orphanRoot) { + int orphanIndex = checkpoints.indexOf(orphanRoot); + + if (orphanIndex < 0) { + return; + } + + int subtreeEnd = orphanIndex + 1; + + while (subtreeEnd < checkpoints.size() + && checkpoints.get(subtreeEnd).getLevel() > orphanRoot.getLevel()) { + subtreeEnd++; + } + + List subtree = + new ArrayList<>(checkpoints.subList(orphanIndex, subtreeEnd)); + + checkpoints.subList(orphanIndex, subtreeEnd).clear(); + + int parentIndex = checkpoints.indexOf(newParent); + + int insertIndex = parentIndex + 1; + + while (insertIndex < checkpoints.size()) { + Checkpoint current = checkpoints.get(insertIndex); + + if (current.getLevel() < newParent.getLevel()) { + break; + } + if (current.getLevel() == newParent.getLevel() + && current.getType() == CheckpointType.ENDPOINT.toInt()) { + break; + } + insertIndex++; + } + + int levelDelta = (newParent.getLevel() + 1) - orphanRoot.getLevel(); + + for (Checkpoint checkpoint : subtree) { + checkpoint.setLevel(checkpoint.getLevel() + levelDelta); + } + + checkpoints.addAll(insertIndex, subtree); + } + public String getThreadInfo() { return "\nmainThread: " + mainThread + "\nmainThreadFinishedTime: " + mainThreadFinishedTime diff --git a/ladybug-common/src/main/java/org/wearefrank/ladybug/Span.java b/ladybug-common/src/main/java/org/wearefrank/ladybug/Span.java deleted file mode 100644 index 16db6c57a..000000000 --- a/ladybug-common/src/main/java/org/wearefrank/ladybug/Span.java +++ /dev/null @@ -1,110 +0,0 @@ -/* - Copyright 2024, 2025 WeAreFrank! - - Licensed 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.wearefrank.ladybug; - -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; -import java.util.HashMap; -import java.util.Map; -import io.opentelemetry.api.trace.SpanKind; - -/** - * Created a Span class to map incoming telemetry data from the endpoint. There is no library available with classes to catch such telemetry data in spans. - */ - -public class Span { - private String traceId; - private String parentId; - private String id; - private SpanKind kind; - private String name; - private long timestamp; - private long duration; - private Map localEndpoint; - private Map tags; - - public Span(String traceId, String parentId, String id, SpanKind kind, String name, long timestamp, long duration, Map localEndpoint, Map tags) { - this.traceId = traceId; - this.parentId = parentId; - this.id = id; - this.kind = kind; - this.name = name; - this.timestamp = timestamp; - this.duration = duration; - this.localEndpoint = localEndpoint; - this.tags = tags; - } - - public Span() { - } - - public String getTraceId() { - return traceId; - } - - public String getParentId() { - return parentId; - } - - public String getId() { - return id; - } - - public String getName() { - return name; - } - - public long getTimestamp() { - return timestamp; - } - - public long getDuration() { - return duration; - } - - public Map getLocalEndpoint() { - return localEndpoint; - } - - public Map getTags() { - return tags; - } - - public String getKind() { - if (kind == null) { - return null; - } - return kind.toString(); - } - - public Map toHashmap() { - String date = LocalDateTime.ofInstant(Instant.ofEpochMilli(this.timestamp / 1000), ZoneId.systemDefault()).format(DateTimeFormatter.ofPattern("dd-MM-yyyy HH:mm:ss")); - Map map = new HashMap<>(); - map.put("\"traceId\"", "\"" + this.traceId + "\""); - map.put("\"parentId\"", "\"" + this.parentId + "\""); - map.put("\"id\"", "\"" + this.id + "\""); - map.put("\"kind\"", "\"" + this.kind + "\""); - map.put("\"name\"", "\"" + this.name + "\""); - map.put("\"time\"", "\"" + date + "\""); - map.put("\"duration\"", "\"" + this.duration + "\""); - map.put("\"localEndpoint\"", "\"" + this.localEndpoint + "\""); - map.put("\"tags\"", "\"" + this.tags + "\""); - - return map; - } -} diff --git a/ladybug-common/src/main/java/org/wearefrank/ladybug/SpanBuffer.java b/ladybug-common/src/main/java/org/wearefrank/ladybug/SpanBuffer.java new file mode 100644 index 000000000..ce2ff44d3 --- /dev/null +++ b/ladybug-common/src/main/java/org/wearefrank/ladybug/SpanBuffer.java @@ -0,0 +1,60 @@ +/* + Copyright 2026 WeAreFrank! + + Licensed 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.wearefrank.ladybug; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.RemovalCause; +import com.github.benmanes.caffeine.cache.Scheduler; +import io.opentelemetry.proto.trace.v1.Span; +import org.springframework.stereotype.Component; +import org.wearefrank.ladybug.web.common.TracingApiImpl; + +import java.util.ArrayList; +import java.util.concurrent.TimeUnit; + +@Component +public class SpanBuffer { + private final Cache> cache; + + private TracingApiImpl delegate; + + public SpanBuffer(TracingApiImpl delegate) { + this.delegate = delegate; + this.cache = Caffeine.newBuilder() + .expireAfterWrite(30, TimeUnit.SECONDS) + .scheduler(Scheduler.systemScheduler()) + .removalListener((String traceId, ArrayList spans, RemovalCause cause) -> { + if (spans != null && cause == RemovalCause.EXPIRED) { + ArrayList spansCopy = new ArrayList<>(spans); + delegate.processSpans(spansCopy); + } + }) + .build(); + } + + public void addSpan(Span span) { + String traceId = this.delegate.byteStringToHex(span.getTraceId()); + + cache.asMap().compute(traceId, (key, existing) -> { + ArrayList updated = + existing == null ? new ArrayList<>() : new ArrayList<>(existing); + + updated.add(span); + return updated; + }); + } +} \ No newline at end of file diff --git a/ladybug-common/src/main/java/org/wearefrank/ladybug/TestTool.java b/ladybug-common/src/main/java/org/wearefrank/ladybug/TestTool.java index 444eedadf..9baf06cb7 100644 --- a/ladybug-common/src/main/java/org/wearefrank/ladybug/TestTool.java +++ b/ladybug-common/src/main/java/org/wearefrank/ladybug/TestTool.java @@ -29,7 +29,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Qualifier; import io.opentelemetry.api.trace.Tracer; import jakarta.annotation.PostConstruct; @@ -70,7 +69,7 @@ public class TestTool { private Map reportsInProgressByCorrelationId = new HashMap(); private long numberOfReportsInProgress = 0; private Map originalReports = new HashMap(); - private @Setter @Getter @Inject @Autowired LogStorage debugStorage; + private @Setter @Getter @Inject @Autowired Storage debugStorage; private @Setter @Getter @Inject @Autowired CrudStorage testStorage; private MessageEncoder messageEncoder = new MessageEncoderImpl(); private MessageCapturer messageCapturer = new MessageCapturerImpl(); @@ -102,8 +101,9 @@ public class TestTool { private @Setter @Getter @Inject @Autowired Views views; private @Setter @Getter int reportsInProgressThreshold = 300000; boolean devMode = false; // See testConcurrentLastEndpointAndFirstStartpointForSameCorrelationId() - private @Qualifier("openTelemetryEndpoint") String openTelemetryEndpoint; + private @Setter String openTelemetryEndpoint; private Tracer tracer; + private @Setter @Getter boolean updateReportsEnabled = false; @PostConstruct public void init() { @@ -117,7 +117,7 @@ public void reset() { reportGeneratorEnabled = defaultReportGeneratorEnabled; } - public void setSecurityLoggerName(String securityLoggerName) { + public void setSecurityLoggerName(String securityLoggerName) { securityLog = LoggerFactory.getLogger(securityLoggerName); } @@ -331,27 +331,45 @@ public Tracer getOpenTelemetryTracer() { return tracer; } + private T checkpoint(String correlationId, String childThreadId, String sourceClassName, String name, + T message, StubableCode stubableCode, StubableCodeThrowsException stubableCodeThrowsException, + Set matchingStubStrategies, int checkpointType, int levelChangeNextCheckpoint) { + return checkpoint(correlationId, childThreadId, sourceClassName, name, + message, null, stubableCode, stubableCodeThrowsException, + matchingStubStrategies, checkpointType, levelChangeNextCheckpoint, null, null, -1); + } + + private T checkpoint(String correlationId, String childThreadId, String sourceClassName, String name, + T message, Map messageContext, StubableCode stubableCode, StubableCodeThrowsException stubableCodeThrowsException, + Set matchingStubStrategies, int checkpointType, int levelChangeNextCheckpoint) { + return checkpoint(correlationId, childThreadId, sourceClassName, name, + message, messageContext, stubableCode, stubableCodeThrowsException, + matchingStubStrategies, checkpointType, levelChangeNextCheckpoint, null, null, -1); + } + private T checkpoint(String correlationId, String childThreadId, String sourceClassName, String name, T message, StubableCode stubableCode, StubableCodeThrowsException stubableCodeThrowsException, - Set matchingStubStrategies, int checkpointType, int levelChangeNextCheckpoint) { + Set matchingStubStrategies, int checkpointType, int levelChangeNextCheckpoint, String id, String parentId) { + return checkpoint(correlationId, childThreadId, sourceClassName, name, + message, null, stubableCode, stubableCodeThrowsException, + matchingStubStrategies, checkpointType, levelChangeNextCheckpoint, id, parentId, -1); + } + + private T checkpoint(String correlationId, String childThreadId, String sourceClassName, String name, + T message, StubableCode stubableCode, StubableCodeThrowsException stubableCodeThrowsException, + Set matchingStubStrategies, int checkpointType, int levelChangeNextCheckpoint, String id, String parentId, long startTime) { return checkpoint(correlationId, childThreadId, sourceClassName, name, message, null, stubableCode, stubableCodeThrowsException, - matchingStubStrategies, checkpointType, levelChangeNextCheckpoint); + matchingStubStrategies, checkpointType, levelChangeNextCheckpoint, id, parentId, startTime); } private T checkpoint(String correlationId, String childThreadId, String sourceClassName, String name, T message, Map messageContext, StubableCode stubableCode, StubableCodeThrowsException stubableCodeThrowsException, - Set matchingStubStrategies, int checkpointType, int levelChangeNextCheckpoint) { + Set matchingStubStrategies, int checkpointType, int levelChangeNextCheckpoint, String id, String parentId, long startTime) { boolean executeStubableCode = true; if (reportGeneratorEnabled) { - Report report; - // Blocking for all threads for all reports - synchronized(reportsInProgress) { - report = getReportInProgress(correlationId); - if (report == null) { - report = createReport(correlationId, name, checkpointType); - } - } + Report report = getOrCreateReport(correlationId, name, checkpointType); + if (devMode) randomSleep(); while (report != null) { // "synchronized(report)" is only blocking for threads writing to the same report (which is only the @@ -378,7 +396,7 @@ private T checkpoint(String correlationId, String childThreadId, String sour executeStubableCode = false; message = report.checkpoint(childThreadId, sourceClassName, name, message, messageContext, stubableCode, stubableCodeThrowsException, matchingStubStrategies, checkpointType, - levelChangeNextCheckpoint); + levelChangeNextCheckpoint, id, parentId, startTime); closeReportIfFinished(report); } report = null; @@ -390,6 +408,51 @@ private T checkpoint(String correlationId, String childThreadId, String sour return message; } + private Report getOrCreateReport(String correlationId, String name, int checkpointType) { + synchronized (reportsInProgress) { + Report report = getReportInProgress(correlationId); + if (report != null) { + return report; + } + + if (updateReportsEnabled) { + report = tryLoadReportFromStorage(correlationId); + if (report != null) { + return report; + } + } + + return createReport(correlationId, name, checkpointType); + } + } + + private Report tryLoadReportFromStorage(String correlationId) { + if (!debugStorage.isCrudStorage()) { + log.error("Can't update report because storage does not support CRUD operations"); + return null; + } + try { + for (Integer storageId : debugStorage.getStorageIds()) { + Report stored = debugStorage.getReport(storageId); + if (stored.getCorrelationId().equals(correlationId)) { + stored.restoreRuntimeState(); + stored.setClosed(false); + stored.setTestTool(this); + + reportsInProgress.add(0, stored); + reportsInProgressByCorrelationId.put(correlationId, stored); + numberOfReportsInProgress++; + + stored.setBeingUpdated(true); + return stored; + } + } + } catch (StorageException e) { + log.error("Failed to find report in storage", e); + } + return null; + } + private Report createReport(String correlationId, String name, int checkpointType) { Report report = null; if (checkpointType == CheckpointType.STARTPOINT.toInt()) { @@ -472,7 +535,22 @@ protected void closeReportIfFinished(Report report) { numberOfReportsInProgress--; } if (report.isReportFilterMatching()) { - debugStorage.storeWithoutException(report); + if (report.isBeingUpdated()) { + if (debugStorage.isCrudStorage()) { + try { + ((CrudStorage) debugStorage).update(report); + report.setBeingUpdated(false); + } catch (StorageException e) { + log.error("Failed to store report", e); + } + } + } else { + try { + debugStorage.store(report); + } catch (StorageException e) { + + } + } } } } @@ -546,6 +624,16 @@ public T startpoint(String correlationId, String sourceClassName, String nam CheckpointType.STARTPOINT.toInt(), 1); } + public T startpoint(String correlationId, String sourceClassName, String name, T message, String id, String parentId) { + return checkpoint(correlationId, null, sourceClassName, name, message, null, null, null, + CheckpointType.STARTPOINT.toInt(), 1, id, parentId); + } + + public T startpoint(String correlationId, String sourceClassName, String name, T message, String id, String parentId, long startTime) { + return checkpoint(correlationId, null, sourceClassName, name, message, null, null, null, + CheckpointType.STARTPOINT.toInt(), 1, id, parentId, startTime); + } + /** * Parameter throwsException determines the type of exception thrown. E.g. when set to (IOException)null the * compiler will report this method to throw an IOException which needs to be handled. When set to null the compiler @@ -572,7 +660,17 @@ public T endpoint(String correlationId, String sourceClassName, String name, return checkpoint(correlationId, null, sourceClassName, name, message, null, null, null, CheckpointType.ENDPOINT.toInt(), -1); } - + + public T endpoint(String correlationId, String sourceClassName, String name, T message, String id, String parentId) { + return checkpoint(correlationId, null, sourceClassName, name, message, null, null, null, + CheckpointType.ENDPOINT.toInt(), -1, id, parentId); + } + + public T endpoint(String correlationId, String sourceClassName, String name, T message, String id, String parentId, long startTime) { + return checkpoint(correlationId, null, sourceClassName, name, message, null, null, null, + CheckpointType.ENDPOINT.toInt(), -1, id, parentId, startTime); + } + public T endpoint(String correlationId, String sourceClassName, String name, T message, Map messageContext) { return checkpoint(correlationId, null, sourceClassName, name, message, messageContext, null, null, null, CheckpointType.ENDPOINT.toInt(), -1); @@ -793,11 +891,17 @@ public T infopoint(String correlationId, String sourceClassName, String name return checkpoint(correlationId, null, sourceClassName, name, message, null, null, null, CheckpointType.INFOPOINT.toInt(), 0); } + public T infopoint(String correlationId, String sourceClassName, String name, T message, Map messageContext) { return checkpoint(correlationId, null, sourceClassName, name, message, messageContext, null, null, null, CheckpointType.INFOPOINT.toInt(), 0); } + public T infopoint(String correlationId, String sourceClassName, String name, T message, String id, String parentId) { + return checkpoint(correlationId, null, sourceClassName, name, message, null, null, null, + CheckpointType.INFOPOINT.toInt(), 0, id, parentId); + } + /** * Use abortpoint instead of endpoint in case an exception is thrown after a startpoint. The exception object can * be passed as the message parameter. diff --git a/ladybug-common/src/main/java/org/wearefrank/ladybug/storage/Storage.java b/ladybug-common/src/main/java/org/wearefrank/ladybug/storage/Storage.java index 5240d2d67..7aac1eac2 100644 --- a/ladybug-common/src/main/java/org/wearefrank/ladybug/storage/Storage.java +++ b/ladybug-common/src/main/java/org/wearefrank/ladybug/storage/Storage.java @@ -1,5 +1,5 @@ /* - Copyright 2020-2022, 2024-2025 WeAreFrank!, 2018 Nationale-Nederlanden + Copyright 2020-2022, 2024-2026 WeAreFrank!, 2018 Nationale-Nederlanden Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -64,6 +64,8 @@ public List> getMetadata(int maxNumberOfRecords, List metad public void close(); + public void store(Report report) throws StorageException; + public int getFilterType(String column); public List getFilterValues(String column) throws StorageException; diff --git a/ladybug-common/src/main/java/org/wearefrank/ladybug/storage/file/Storage.java b/ladybug-common/src/main/java/org/wearefrank/ladybug/storage/file/Storage.java index 6d12ec34b..cd271814b 100644 --- a/ladybug-common/src/main/java/org/wearefrank/ladybug/storage/file/Storage.java +++ b/ladybug-common/src/main/java/org/wearefrank/ladybug/storage/file/Storage.java @@ -1,5 +1,5 @@ /* - Copyright 2020-2022, 2024-2025 WeAreFrank!, 2018 Nationale-Nederlanden + Copyright 2020-2022, 2024-2026 WeAreFrank!, 2018 Nationale-Nederlanden Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -172,6 +172,11 @@ public void close() { writer.close(); } + @Override + public void store(Report report) throws StorageException { + storeWithoutException(report); + } + @Override public int getFilterType(String column) { return FILTER_RESET; @@ -186,4 +191,9 @@ public List getFilterValues(String column) throws StorageException { public String getUserHelp(String column) { return SearchUtil.getUserHelp(); } + + @Override + public boolean isCrudStorage() { + return LogStorage.super.isCrudStorage(); + } } diff --git a/ladybug-common/src/main/java/org/wearefrank/ladybug/storage/proofofmigration/ProofOfMigrationErrorsView.java b/ladybug-common/src/main/java/org/wearefrank/ladybug/storage/proofofmigration/ProofOfMigrationErrorsView.java index 5bda89349..7689f9063 100644 --- a/ladybug-common/src/main/java/org/wearefrank/ladybug/storage/proofofmigration/ProofOfMigrationErrorsView.java +++ b/ladybug-common/src/main/java/org/wearefrank/ladybug/storage/proofofmigration/ProofOfMigrationErrorsView.java @@ -1,5 +1,5 @@ /* - Copyright 2022, 2024, 2025 WeAreFrank! + Copyright 2022, 2024, 2025, 2026 WeAreFrank! Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -22,7 +22,7 @@ import jakarta.inject.Inject; import lombok.Setter; import org.wearefrank.ladybug.filter.View; -import org.wearefrank.ladybug.storage.LogStorage; +import org.wearefrank.ladybug.storage.Storage; //@Dependent disabled for Quarkus for now because of the use of JdbcTemplate public class ProofOfMigrationErrorsView extends View { @@ -38,7 +38,7 @@ public String getName() { } @Override - public LogStorage getDebugStorage() { + public Storage getDebugStorage() { return proofOfMigrationErrorsStorage; } diff --git a/ladybug-common/src/main/java/org/wearefrank/ladybug/storage/proofofmigration/ProofOfMigrationView.java b/ladybug-common/src/main/java/org/wearefrank/ladybug/storage/proofofmigration/ProofOfMigrationView.java index 0b022157b..de98a04ef 100644 --- a/ladybug-common/src/main/java/org/wearefrank/ladybug/storage/proofofmigration/ProofOfMigrationView.java +++ b/ladybug-common/src/main/java/org/wearefrank/ladybug/storage/proofofmigration/ProofOfMigrationView.java @@ -1,5 +1,5 @@ /* - Copyright 2022-2025 WeAreFrank! + Copyright 2022-2026 WeAreFrank! Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -22,7 +22,7 @@ import jakarta.inject.Inject; import lombok.Setter; import org.wearefrank.ladybug.filter.View; -import org.wearefrank.ladybug.storage.LogStorage; +import org.wearefrank.ladybug.storage.Storage; // @Dependent disabled for Quarkus for now because of the use of JdbcTemplate public class ProofOfMigrationView extends View { @@ -38,7 +38,7 @@ public String getName() { } @Override - public LogStorage getDebugStorage() { + public Storage getDebugStorage() { return proofOfMigrationStorage; } diff --git a/ladybug-common/src/main/java/org/wearefrank/ladybug/util/OpenTelemetryUtil.java b/ladybug-common/src/main/java/org/wearefrank/ladybug/util/OpenTelemetryUtil.java index 5a02b1ff9..2aef74b61 100644 --- a/ladybug-common/src/main/java/org/wearefrank/ladybug/util/OpenTelemetryUtil.java +++ b/ladybug-common/src/main/java/org/wearefrank/ladybug/util/OpenTelemetryUtil.java @@ -1,5 +1,5 @@ /* - Copyright 2024, 2025 WeAreFrank! + Copyright 2024, 2025, 2026 WeAreFrank! Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -71,7 +71,7 @@ public static Tracer getOpenTelemetryTracer(String openTelemetryEndpoint) { ContextPropagators.create( TextMapPropagator.composite( W3CTraceContextPropagator.getInstance(), W3CBaggagePropagator.getInstance()))) - .buildAndRegisterGlobal(); + .build(); return openTelemetry.getTracer(Report.class.getName(), "0.1.0"); } return null; diff --git a/ladybug-common/src/main/java/org/wearefrank/ladybug/web/common/CollectorApiImpl.java b/ladybug-common/src/main/java/org/wearefrank/ladybug/web/common/CollectorApiImpl.java deleted file mode 100644 index 65a0612d4..000000000 --- a/ladybug-common/src/main/java/org/wearefrank/ladybug/web/common/CollectorApiImpl.java +++ /dev/null @@ -1,61 +0,0 @@ -/* - Copyright 2025 WeAreFrank! - - Licensed 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.wearefrank.ladybug.web.common; - -import lombok.Setter; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; -import org.wearefrank.ladybug.Span; -import org.wearefrank.ladybug.TestTool; - -import java.lang.invoke.MethodHandles; -import java.util.ArrayList; - -@Component -public class CollectorApiImpl { - private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); - - @Autowired - private @Setter TestTool testTool; - - public void processSpans(Span[] trace) { - ArrayList parentIds = new ArrayList<>(); - for (Span span: trace) { - if (span.getParentId() != null && !parentIds.contains(span.getParentId())) { - parentIds.add(span.getParentId()); - } - } - ArrayList endpoints = new ArrayList<>(); - for (int i = trace.length - 1; i >= 0; i--) { - if (trace[i].getParentId() == null) { - testTool.startpoint(trace[i].getTraceId(), null, trace[i].getName(), trace[i].toHashmap().toString()); - endpoints.add(trace[i].getName()); - } else { - if (parentIds.contains(trace[i].getId())) { - testTool.startpoint(trace[i].getTraceId(), null, trace[i].getName(), trace[i].toHashmap().toString()); - endpoints.add(trace[i].getName()); - } else { - testTool.infopoint(trace[i].getTraceId(), null, trace[i].getName(), trace[i].toHashmap().toString()); - } - } - } - for (int i = endpoints.size() - 1; i >= 0; i--) { - testTool.endpoint(trace[0].getTraceId(), null, endpoints.get(i), "Endpoint"); - } - } -} diff --git a/ladybug-common/src/main/java/org/wearefrank/ladybug/web/common/TracingApiImpl.java b/ladybug-common/src/main/java/org/wearefrank/ladybug/web/common/TracingApiImpl.java new file mode 100644 index 000000000..4b68e8a0c --- /dev/null +++ b/ladybug-common/src/main/java/org/wearefrank/ladybug/web/common/TracingApiImpl.java @@ -0,0 +1,114 @@ +/* + Copyright 2025, 2026 WeAreFrank! + + Licensed 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.wearefrank.ladybug.web.common; + +import com.google.protobuf.ByteString; +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.util.JsonFormat; +import io.opentelemetry.proto.collector.trace.v1.ExportTraceServiceRequest; +import io.opentelemetry.proto.trace.v1.ResourceSpans; +import io.opentelemetry.proto.trace.v1.ScopeSpans; +import io.opentelemetry.proto.trace.v1.Span; +import io.opentelemetry.proto.common.v1.AnyValue; +import io.opentelemetry.proto.common.v1.KeyValue; +import lombok.Setter; +import org.apache.commons.codec.binary.Hex; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.wearefrank.ladybug.SpanBuffer; +import org.wearefrank.ladybug.TestTool; + +import java.lang.invoke.MethodHandles; +import java.util.HashMap; +import java.util.List; + +@Component +public class TracingApiImpl { + private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + private SpanBuffer spanBuffer = new SpanBuffer(this); + + @Autowired + private @Setter TestTool testTool; + + public void processSpans(List spans) { + testTool.setUpdateReportsEnabled(true); + + String traceId = byteStringToHex(spans.get(0).getTraceId()); + + for (Span span : spans) { + String spanId = byteStringToHex(span.getSpanId()); + String parentSpanId = span.getParentSpanId().isEmpty() + ? "" : byteStringToHex(span.getParentSpanId()); + long startTime = span.getStartTimeUnixNano(); + + testTool.startpoint(traceId, null, span.getName(), toHashMap(span), spanId, parentSpanId, startTime); + for (KeyValue keyValue : span.getAttributesList()) { + AnyValue anyValue = keyValue.getValue(); + String value = String.valueOf(anyValue.getField(anyValue.getDescriptorForType().findFieldByNumber(anyValue.getValueCase().getNumber()))); + testTool.infopoint(byteStringToHex(span.getTraceId()), null, keyValue.getKey(), value, spanId, spanId); + } + testTool.endpoint(traceId, null, span.getName(), null, spanId, parentSpanId, startTime); + } + testTool.close(traceId); + } + + public void addSpansToBuffer(String contentType, byte[] data) throws InvalidProtocolBufferException { + List resourceSpans = parseData(contentType, data); + + for (ResourceSpans resourceSpan : resourceSpans) { + for (ScopeSpans scopeSpans : resourceSpan.getScopeSpansList()) { + for (Span span : scopeSpans.getSpansList()) { + spanBuffer.addSpan(span); + } + } + } + } + + public List parseData(String contentType, byte[] data) throws InvalidProtocolBufferException { + ExportTraceServiceRequest request = null; + + if (contentType != null && contentType.startsWith("application/x-protobuf")) { + request = ExportTraceServiceRequest.parseFrom(data); + } else if (contentType != null && contentType.startsWith("application/json")) { + String json = new String(data); + ExportTraceServiceRequest.Builder builder = ExportTraceServiceRequest.newBuilder(); + JsonFormat.parser().merge(json, builder); + request = builder.build(); + } + + return request.getResourceSpansList(); + } + + public String byteStringToHex(ByteString byteString) { + return Hex.encodeHexString(byteString.toByteArray()); + } + + public HashMap toHashMap(Span span) { + HashMap map = new HashMap<>(); + + span.getAllFields().forEach((descriptor, value) -> { + if (value instanceof ByteString) { + map.put(descriptor.getName(), byteStringToHex((ByteString) value)); + } else { + map.put(descriptor.getName(), value.toString()); + } + }); + + return map; + } +} diff --git a/ladybug-common/src/test/java/org/wearefrank/ladybug/test/junit/TestSpanBuffer.java b/ladybug-common/src/test/java/org/wearefrank/ladybug/test/junit/TestSpanBuffer.java new file mode 100644 index 000000000..e9a94316e --- /dev/null +++ b/ladybug-common/src/test/java/org/wearefrank/ladybug/test/junit/TestSpanBuffer.java @@ -0,0 +1,107 @@ +/* + Copyright 2026 WeAreFrank! + + Licensed 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.wearefrank.ladybug.test.junit; + +import com.google.protobuf.ByteString; +import io.opentelemetry.proto.trace.v1.Span; +import org.junit.Test; +import org.wearefrank.ladybug.SpanBuffer; +import org.wearefrank.ladybug.web.common.TracingApiImpl; + +import java.util.ArrayList; + +import static org.mockito.Mockito.*; + +public class TestSpanBuffer { + @Test + public void testAddSingleSpan() { + TracingApiImpl delegate = mock(TracingApiImpl.class); + + when(delegate.byteStringToHex(any())) + .thenReturn("trace1"); + + SpanBuffer spanBuffer = new SpanBuffer(delegate); + + Span span = Span.newBuilder() + .setTraceId(ByteString.copyFromUtf8("trace1")) + .setSpanId(ByteString.copyFromUtf8("span1")) + .setName("span") + .build(); + + spanBuffer.addSpan(span); + + verify(delegate, times(1)) + .byteStringToHex(span.getTraceId()); + } + + @Test + public void testAddMultipleSpansSameTrace() { + TracingApiImpl delegate = mock(TracingApiImpl.class); + + when(delegate.byteStringToHex(any())) + .thenReturn("trace1"); + + SpanBuffer spanBuffer = new SpanBuffer(delegate); + + Span span1 = Span.newBuilder() + .setTraceId(ByteString.copyFromUtf8("trace1")) + .setSpanId(ByteString.copyFromUtf8("span1")) + .setName("span1") + .build(); + + Span span2 = Span.newBuilder() + .setTraceId(ByteString.copyFromUtf8("trace1")) + .setSpanId(ByteString.copyFromUtf8("span2")) + .setName("span2") + .build(); + + spanBuffer.addSpan(span1); + spanBuffer.addSpan(span2); + + verify(delegate, times(2)) + .byteStringToHex(any()); + } + + @Test + public void testExpirationProcessesSpans() throws Exception { + TracingApiImpl delegate = mock(TracingApiImpl.class); + + when(delegate.byteStringToHex(any())) + .thenReturn("trace1"); + + SpanBuffer spanBuffer = new SpanBuffer(delegate); + + Span span1 = Span.newBuilder() + .setTraceId(ByteString.copyFromUtf8("trace1")) + .setSpanId(ByteString.copyFromUtf8("span1")) + .setName("span1") + .build(); + + Span span2 = Span.newBuilder() + .setTraceId(ByteString.copyFromUtf8("trace1")) + .setSpanId(ByteString.copyFromUtf8("span2")) + .setName("span2") + .build(); + + spanBuffer.addSpan(span1); + spanBuffer.addSpan(span2); + + Thread.sleep(31000); + + verify(delegate, timeout(5000).times(1)) + .processSpans(any(ArrayList.class)); + } +} \ No newline at end of file diff --git a/ladybug-common/src/test/java/org/wearefrank/ladybug/test/junit/TestTracingApiImpl.java b/ladybug-common/src/test/java/org/wearefrank/ladybug/test/junit/TestTracingApiImpl.java new file mode 100644 index 000000000..298b7c557 --- /dev/null +++ b/ladybug-common/src/test/java/org/wearefrank/ladybug/test/junit/TestTracingApiImpl.java @@ -0,0 +1,131 @@ +package org.wearefrank.ladybug.test.junit; + +import com.google.protobuf.ByteString; +import io.opentelemetry.proto.common.v1.AnyValue; +import io.opentelemetry.proto.common.v1.KeyValue; +import io.opentelemetry.proto.trace.v1.Span; +import org.junit.Before; +import org.junit.Test; +import org.wearefrank.ladybug.TestTool; +import org.wearefrank.ladybug.web.common.TracingApiImpl; + +import java.util.HashMap; +import java.util.List; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +public class TestTracingApiImpl { + + private TracingApiImpl tracingApi; + private TestTool testTool; + + @Before + public void setUp() { + tracingApi = new TracingApiImpl(); + + testTool = mock(TestTool.class); + tracingApi.setTestTool(testTool); + } + + @Test + public void testByteStringToHex() { + ByteString byteString = ByteString.copyFromUtf8("test"); + + String result = tracingApi.byteStringToHex(byteString); + + assertEquals("74657374", result); + } + + @Test + public void testToHashMap() { + Span span = Span.newBuilder() + .setTraceId(ByteString.copyFromUtf8("trace")) + .setSpanId(ByteString.copyFromUtf8("span")) + .setParentSpanId(ByteString.copyFromUtf8("parent")) + .setName("test-span") + .setStartTimeUnixNano(12345L) + .addAttributes( + KeyValue.newBuilder() + .setKey("http.method") + .setValue( + AnyValue.newBuilder() + .setStringValue("GET") + .build() + ) + .build() + ) + .build(); + + HashMap map = tracingApi.toHashMap(span); + + assertNotNull(map); + + assertEquals("test-span", map.get("name")); + assertEquals("12345", map.get("start_time_unix_nano")); + + assertTrue(map.containsKey("trace_id")); + assertTrue(map.containsKey("span_id")); + assertTrue(map.containsKey("parent_span_id")); + } + + @Test + public void testProcessSpans() { + Span span = Span.newBuilder() + .setTraceId(ByteString.copyFromUtf8("trace1")) + .setSpanId(ByteString.copyFromUtf8("span1")) + .setParentSpanId(ByteString.copyFromUtf8("parent1")) + .setName("test-span") + .setStartTimeUnixNano(12345L) + .build(); + + tracingApi.processSpans(List.of(span)); + + verify(testTool, times(1)) + .startpoint( + anyString(), + isNull(), + eq("test-span"), + anyMap(), + anyString(), + anyString(), + eq(12345L) + ); + + verify(testTool, times(1)) + .endpoint( + anyString(), + isNull(), + eq("test-span"), + isNull(), + anyString(), + anyString(), + eq(12345L) + ); + + verify(testTool, times(1)) + .close(anyString()); + } + + @Test + public void testProcessSpansWithoutParent() { + Span span = Span.newBuilder() + .setTraceId(ByteString.copyFromUtf8("trace1")) + .setSpanId(ByteString.copyFromUtf8("span1")) + .setName("root-span") + .setStartTimeUnixNano(999L) + .build(); + + tracingApi.processSpans(List.of(span)); + + verify(testTool).startpoint( + anyString(), + isNull(), + eq("root-span"), + anyMap(), + anyString(), + eq(""), + eq(999L) + ); + } +} \ No newline at end of file diff --git a/ladybug-common/src/test/java/org/wearefrank/ladybug/test/junit/createreport/TestReportParenting.java b/ladybug-common/src/test/java/org/wearefrank/ladybug/test/junit/createreport/TestReportParenting.java new file mode 100644 index 000000000..9a77302c9 --- /dev/null +++ b/ladybug-common/src/test/java/org/wearefrank/ladybug/test/junit/createreport/TestReportParenting.java @@ -0,0 +1,231 @@ +package org.wearefrank.ladybug.test.junit.createreport; + +import org.junit.Before; +import org.junit.Test; +import org.wearefrank.ladybug.*; +import org.wearefrank.ladybug.test.junit.ReportRelatedTestCase; + +import static org.junit.Assert.*; + +public class TestReportParenting extends ReportRelatedTestCase { + private TestableReport report; + + private static class TestableReport extends Report { + public T callCheckpoint( + String childThreadId, + String sourceClassName, + String name, + T message, + java.util.Map messageContext, + StubableCode stubableCode, + StubableCodeThrowsException stubableCodeThrowsException, + java.util.Set matchingStubStrategies, + int checkpointType, + int levelChangeNextCheckpoint, + String id, + String parentId, + long startTime + ) { + return checkpoint( + childThreadId, + sourceClassName, + name, + message, + messageContext, + stubableCode, + stubableCodeThrowsException, + matchingStubStrategies, + checkpointType, + levelChangeNextCheckpoint, + id, + parentId, + startTime + ); + } + + public void initialize() { + init(); + } + } + + @Before + public void setup() { + super.setUp(); + + report = new TestableReport(); + report.setCorrelationId("corr"); + report.setBeingUpdated(true); + report.setTestTool(testTool); + + report.initialize(); + } + + @Test + public void testCheckpointStoresIdParentIdAndStartTime() { + report.callCheckpoint( + null, + null, + "parent", + "message", + null, + null, + null, + null, + CheckpointType.STARTPOINT.toInt(), + 1, + "id-1", + "", + 123L + ); + + Checkpoint checkpoint = report.getCheckpoints().get(0); + + assertEquals("id-1", checkpoint.getId()); + assertNull(checkpoint.getParentId()); + assertEquals(123L, checkpoint.getStartTime()); + } + + @Test + public void testChildCheckpointGetsNestedUnderParent() { + report.callCheckpoint( + null, + null, + "parent", + "message", + null, + null, + null, + null, + CheckpointType.STARTPOINT.toInt(), + 1, + "parent-id", + "", + 100L + ); + + report.callCheckpoint( + null, + null, + "child", + "message", + null, + null, + null, + null, + CheckpointType.STARTPOINT.toInt(), + 1, + "child-id", + "parent-id", + 200L + ); + + Checkpoint parent = report.getCheckpoints().get(0); + Checkpoint child = report.getCheckpoints().get(1); + + assertEquals(0, parent.getLevel()); + assertEquals(1, child.getLevel()); + + assertEquals("parent-id", child.getParentId()); + } + + @Test + public void testOrphanGetsReparentedLater() { + report.callCheckpoint( + null, + null, + "orphan", + "message", + null, + null, + null, + null, + CheckpointType.STARTPOINT.toInt(), + 1, + "child-id", + "parent-id", + 200L + ); + + assertEquals(0, report.getCheckpoints().get(0).getLevel()); + + report.callCheckpoint( + null, + null, + "parent", + "message", + null, + null, + null, + null, + CheckpointType.STARTPOINT.toInt(), + 1, + "parent-id", + "", + 100L + ); + + Checkpoint parent = report.getCheckpoints().get(0); + Checkpoint child = report.getCheckpoints().get(1); + + assertEquals("parent", parent.getName()); + assertEquals("orphan", child.getName()); + + assertEquals(0, parent.getLevel()); + assertEquals(1, child.getLevel()); + } + + @Test + public void testChildrenOrderedByStartTime() { + report.callCheckpoint( + null, + null, + "parent", + "message", + null, + null, + null, + null, + CheckpointType.STARTPOINT.toInt(), + 1, + "parent-id", + "", + 100L + ); + + report.callCheckpoint( + null, + null, + "child-2", + "message", + null, + null, + null, + null, + CheckpointType.STARTPOINT.toInt(), + 1, + "child-2-id", + "parent-id", + 300L + ); + + report.callCheckpoint( + null, + null, + "child-1", + "message", + null, + null, + null, + null, + CheckpointType.STARTPOINT.toInt(), + 1, + "child-1-id", + "parent-id", + 200L + ); + + assertEquals("parent", report.getCheckpoints().get(0).getName()); + assertEquals("child-1", report.getCheckpoints().get(1).getName()); + assertEquals("child-2", report.getCheckpoints().get(2).getName()); + } +} \ No newline at end of file diff --git a/ladybug-common/src/test/java/org/wearefrank/ladybug/test/junit/util/TestExport.java b/ladybug-common/src/test/java/org/wearefrank/ladybug/test/junit/util/TestExport.java index ad624d07d..388c41d04 100644 --- a/ladybug-common/src/test/java/org/wearefrank/ladybug/test/junit/util/TestExport.java +++ b/ladybug-common/src/test/java/org/wearefrank/ladybug/test/junit/util/TestExport.java @@ -22,10 +22,7 @@ import java.io.IOException; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.zip.GZIPInputStream; import org.junit.Test; @@ -51,11 +48,11 @@ public void testExport() throws IllegalAccessException, IllegalArgumentException // Find all bean properties and change default values to test that transient properties are not added to the // XMLEncoder xml (properties with default values will never be added to the xml by XMLEncoder) Report report = new Report(); - Map setMethods = new HashMap<>(); + Map setMethods = new TreeMap<>(); getBeanProperties(report.getClass(), "set", setMethods); - Map getMethods = new HashMap<>(); + Map getMethods = new TreeMap<>(); getBeanProperties(report.getClass(), "get", getMethods); - Map isMethods = new HashMap<>(); + Map isMethods = new TreeMap<>(); getBeanProperties(report.getClass(), "is", isMethods); for (String name : setMethods.keySet()) { Method method = setMethods.get(name); @@ -119,6 +116,9 @@ public void testExport() throws IllegalAccessException, IllegalArgumentException if (method.getParameters()[0].getType() == int.class) { Integer defaultValue = (Integer)getMethods.get(name).invoke(checkpoint, new Object[0]); method.invoke(checkpoint, defaultValue + name.length()); + } else if (method.getParameters()[0].getType() == long.class) { + Long defaultValue = (Long) getMethods.get(name).invoke(checkpoint, new Object[0]); + method.invoke(checkpoint, defaultValue + name.length()); } else if (method.getParameters()[0].getType() == boolean.class) { assertIsMethodAvailable(isMethods, name); Boolean defaultValue = (Boolean)isMethods.get(name).invoke(checkpoint, new Object[0]); diff --git a/ladybug-test-webapp/src/main/resources/springTestToolTestWebapp.xml b/ladybug-test-webapp/src/main/resources/springTestToolTestWebapp.xml index fb4839f6c..a1eea38a0 100644 --- a/ladybug-test-webapp/src/main/resources/springTestToolTestWebapp.xml +++ b/ladybug-test-webapp/src/main/resources/springTestToolTestWebapp.xml @@ -33,8 +33,13 @@ + + + + + @@ -155,7 +160,7 @@ - + @@ -171,7 +176,7 @@ - + @@ -247,7 +252,7 @@ - + @@ -272,7 +277,7 @@ diff --git a/ladybug-test-webapp/src/main/webapp/index.jsp b/ladybug-test-webapp/src/main/webapp/index.jsp index 49c9de348..52ac2668c 100644 --- a/ladybug-test-webapp/src/main/webapp/index.jsp +++ b/ladybug-test-webapp/src/main/webapp/index.jsp @@ -2,7 +2,6 @@ <%@ page import="org.wearefrank.ladybug.TestTool"%> <%@ page import="org.wearefrank.ladybug.MessageEncoderImpl"%> <%@ page import="org.wearefrank.ladybug.storage.CrudStorage"%> -<%@ page import="org.wearefrank.ladybug.storage.LogStorage"%> <%@ page import="org.wearefrank.ladybug.storage.Storage"%> <%@ page import="org.wearefrank.ladybug.test.webapp.test.webapp.ComplexReports"%> <%@ page import="org.springframework.web.context.WebApplicationContext"%> @@ -165,7 +164,7 @@ } // Other actions if ("true".equals(request.getParameter("clearDebugStorage"))) { - LogStorage debugStorage = (LogStorage)webApplicationContext.getBean("debugStorage"); + Storage debugStorage = (Storage)webApplicationContext.getBean("debugStorage"); debugStorage.clear(); } if ("true".equals(request.getParameter("clearDatabaseStorage"))) { @@ -173,10 +172,10 @@ databaseStorage.clear(); } if (request.getParameter("changeDebugStorage") != null) { - testTool.setDebugStorage((LogStorage)testTool.getStorage(request.getParameter("changeDebugStorage"))); + testTool.setDebugStorage((Storage)testTool.getStorage(request.getParameter("changeDebugStorage"))); } if (request.getParameter("resetDebugStorage") != null) { - testTool.setDebugStorage((LogStorage) webApplicationContext.getBean("debugStorage")); + testTool.setDebugStorage((Storage) webApplicationContext.getBean("debugStorage")); } if (request.getParameter("removeReportsInProgress") != null) { while (testTool.getNumberOfReportsInProgress() > 0) {