diff --git a/docs/images/jenkins-opentelemetry-jaeger-withNewSpan.png b/docs/images/jenkins-opentelemetry-jaeger-withNewSpan.png
new file mode 100644
index 000000000..f36dd978d
Binary files /dev/null and b/docs/images/jenkins-opentelemetry-jaeger-withNewSpan.png differ
diff --git a/docs/job-traces.md b/docs/job-traces.md
index b9cfe1336..fa268dfc2 100644
--- a/docs/job-traces.md
+++ b/docs/job-traces.md
@@ -1,7 +1,7 @@
# Traces of Jobs and Pipeline Builds
-## Traces and Spans Attributes
+## Traces, Spans and Span Attributes
The Jenkins OpenTelemetry integration collects comprehensive contextual attributes of the jobs and pipelines builds to:
* Provide build executions details in order to be an alternative to the Jenkins GUI if desired
@@ -58,6 +58,50 @@ pipeline {
}
````
+### Custom spans
+
+Custom spans can be defined using the `withNewSpan` step, which accepts the following parameters
+* `label`
+ * the label of the span
+ * the value is a `string`
+* `attributes`
+ * a list of attributes, defined the same way as in the `withSpanAttributes` step
+ * ```groovy
+ attributes: ([
+ spanAttribute(key: 'modules', value: '2'),
+ spanAttribute(key: 'command', value: 'mvn clean install')
+ ])
+ ```
+* `setAttributesOnlyOnParent`
+ * flag used to define whether to inherit the provided attributes to the children spans or not
+ * `true` by default, all user-defined attributes for a span are passed on to children spans
+ * the value is a boolean, `true` or `false`
+
+Example definitions:
+
+* All parameters provided
+ ```groovy
+ stage('build') {
+ withNewSpan(label: 'custom-build-span', attributes: ([
+ spanAttribute(key: 'modules', value: '2'),
+ spanAttribute(key: 'command', value: 'mvn clean install')
+ ]), setAttributesOnlyOnParent: true) {
+ sh './build-module1.sh'
+ sh './build-module2.sh'
+ }
+ }
+ ```
+
+* Only the `label` parameter is required, all others are optional.
+ ```groovy
+ stage('build') {
+ withNewSpan(label: 'custom-build-span') {
+ sh './build-module1.sh'
+ sh './build-module2.sh'
+ }
+ }
+ ```
+
### Pipeline, freestyle, and matrix project build spans
Attributes reported on the root span of Jenkins job and pipeline builds:
diff --git a/pom.xml b/pom.xml
index 7053ff776..641727d31 100644
--- a/pom.xml
+++ b/pom.xml
@@ -457,6 +457,11 @@
junit
test
+
+ org.mockito
+ mockito-core
+ test
+
com.github.rutledgepaulv
prune
diff --git a/src/main/java/io/jenkins/plugins/opentelemetry/OpenTelemetryAttributesAction.java b/src/main/java/io/jenkins/plugins/opentelemetry/OpenTelemetryAttributesAction.java
index 13d099dc6..9bfe6d4c8 100644
--- a/src/main/java/io/jenkins/plugins/opentelemetry/OpenTelemetryAttributesAction.java
+++ b/src/main/java/io/jenkins/plugins/opentelemetry/OpenTelemetryAttributesAction.java
@@ -12,8 +12,10 @@
import edu.umd.cs.findbugs.annotations.NonNull;
import java.io.Serializable;
+import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
+import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.logging.Logger;
@@ -31,6 +33,10 @@ public class OpenTelemetryAttributesAction extends InvisibleAction implements Se
private transient Map, Object> attributes;
private transient Set appliedToSpans;
+ // If the list has any values, then only the spans on the list will get attributes.
+ // If the list is empty, then there is no restriction.
+ // Used to control attribute inheritance to children spans.
+ private transient List inheritanceAllowedSpanIdList;
@NonNull
public Map, Object> getAttributes() {
@@ -52,6 +58,27 @@ public boolean isNotYetAppliedToSpan(String spanId) {
return appliedToSpans.add(spanId);
}
+ public void addSpanIdToInheritanceAllowedList(String spanId) {
+ if (inheritanceAllowedSpanIdList == null) {
+ inheritanceAllowedSpanIdList = new ArrayList<>();
+ }
+ inheritanceAllowedSpanIdList.add(spanId);
+ }
+
+ public boolean inheritanceAllowedSpanIdListIsEmpty() {
+ if (inheritanceAllowedSpanIdList == null) {
+ return true;
+ }
+ return inheritanceAllowedSpanIdList.isEmpty();
+ }
+
+ public boolean isSpanIdAllowedToInheritAttributes(String spanId) {
+ if (inheritanceAllowedSpanIdList == null) {
+ return false;
+ }
+ return inheritanceAllowedSpanIdList.contains(spanId);
+ }
+
@Override
public String toString() {
return "OpenTelemetryAttributesAction{" +
diff --git a/src/main/java/io/jenkins/plugins/opentelemetry/job/MonitoringPipelineListener.java b/src/main/java/io/jenkins/plugins/opentelemetry/job/MonitoringPipelineListener.java
index 27feb317d..4c3f9c6c8 100644
--- a/src/main/java/io/jenkins/plugins/opentelemetry/job/MonitoringPipelineListener.java
+++ b/src/main/java/io/jenkins/plugins/opentelemetry/job/MonitoringPipelineListener.java
@@ -5,6 +5,7 @@
package io.jenkins.plugins.opentelemetry.job;
+import com.google.common.base.Strings;
import com.google.errorprone.annotations.MustBeClosed;
import edu.umd.cs.findbugs.annotations.NonNull;
import edu.umd.cs.findbugs.annotations.Nullable;
@@ -22,6 +23,7 @@
import io.jenkins.plugins.opentelemetry.job.jenkins.AbstractPipelineListener;
import io.jenkins.plugins.opentelemetry.job.jenkins.PipelineListener;
import io.jenkins.plugins.opentelemetry.job.step.SetSpanAttributesStep;
+import io.jenkins.plugins.opentelemetry.job.step.SpanAttribute;
import io.jenkins.plugins.opentelemetry.job.step.StepHandler;
import io.jenkins.plugins.opentelemetry.job.step.WithSpanAttributeStep;
import io.jenkins.plugins.opentelemetry.job.step.WithSpanAttributesStep;
@@ -267,6 +269,19 @@ private String getStepName(@NonNull StepAtomNode node, @NonNull String name) {
return stepDescriptor.getDisplayName();
}
+ private String getStepName(@NonNull StepStartNode node, @NonNull String name) {
+ StepDescriptor stepDescriptor = node.getDescriptor();
+ if (stepDescriptor == null) {
+ return name;
+ }
+ UninstantiatedDescribable describable = getUninstantiatedDescribableOrNull(node, stepDescriptor);
+ if (describable != null) {
+ Descriptor extends Describable> d = SymbolLookup.get().findDescriptor(Describable.class, describable.getSymbol());
+ return d.getDisplayName();
+ }
+ return stepDescriptor.getDisplayName();
+ }
+
private String getStepType(@NonNull FlowNode node, @Nullable StepDescriptor stepDescriptor, @NonNull String type) {
if (stepDescriptor == null) {
return type;
@@ -374,6 +389,66 @@ private void endCurrentSpan(FlowNode node, WorkflowRun run, GenericStatus status
}
}
+ @Override
+ public void onStartWithNewSpanStep(@NonNull StepStartNode stepStartNode, @NonNull WorkflowRun run) {
+ try (Scope ignored = setupContext(run, stepStartNode)) {
+ verifyNotNull(ignored, "%s - No span found for node %s", run, stepStartNode);
+
+ String stepName = getStepName(stepStartNode, "withNewSpan");
+ String stepType = getStepType(stepStartNode, stepStartNode.getDescriptor(), "step");
+ JenkinsOpenTelemetryPluginConfiguration.StepPlugin stepPlugin = JenkinsOpenTelemetryPluginConfiguration.get().findStepPluginOrDefault(stepType, stepStartNode);
+
+ // Get the arguments.
+ final Map arguments = ArgumentsAction.getFilteredArguments(stepStartNode);
+ // Argument 'label'.
+ final String spanLabelArgument = (String) arguments.getOrDefault("label", stepName);
+ final String spanLabel = Strings.isNullOrEmpty(spanLabelArgument) ? stepName : spanLabelArgument;
+ SpanBuilder spanBuilder = getTracer().spanBuilder(spanLabel)
+ .setParent(Context.current())
+ .setAttribute(JenkinsOtelSemanticAttributes.JENKINS_STEP_TYPE, stepType)
+ .setAttribute(JenkinsOtelSemanticAttributes.JENKINS_STEP_ID, stepStartNode.getId())
+ .setAttribute(JenkinsOtelSemanticAttributes.JENKINS_STEP_NAME, stepName)
+ .setAttribute(JenkinsOtelSemanticAttributes.JENKINS_STEP_PLUGIN_NAME, stepPlugin.getName())
+ .setAttribute(JenkinsOtelSemanticAttributes.JENKINS_STEP_PLUGIN_VERSION, stepPlugin.getVersion());
+
+ // Populate the attributes if any 'attributes' argument was passed to the 'withNewSpan' step.
+ try {
+ Object attributesObj = arguments.getOrDefault("attributes", Collections.emptyList());
+ if (attributesObj instanceof List>) {
+ // Filter the list items and cast to SpanAttribute.
+ List attributes = ((List>) attributesObj).stream()
+ .filter(item -> item instanceof SpanAttribute)
+ .map(item -> (SpanAttribute) item)
+ .collect(Collectors.toList());
+
+ for (SpanAttribute attribute : attributes) {
+ // Set the attributeType in case it's not there.
+ attributes.forEach(SpanAttribute::setDefaultType);
+ // attributeKey is null, call convert() to set the appropriate key value
+ // and convert the attribute value.
+ attribute.convert();
+ spanBuilder.setAttribute(attribute.getAttributeKey(), attribute.getConvertedValue());
+ }
+ } else {
+ LOGGER.log(Level.WARNING, "Attributes are in an unexpected format: " + attributesObj.getClass().getSimpleName());
+ }
+ } catch (ClassCastException cce) {
+ LOGGER.log(Level.WARNING, run.getFullDisplayName() + " failure to gather the attributes for the 'withNewSpan' step.", cce);
+ }
+
+ Span newSpan = spanBuilder.startSpan();
+ LOGGER.log(Level.FINE, () -> run.getFullDisplayName() + " - > " + stepStartNode.getDisplayFunctionName() + " - begin " + OtelUtils.toDebugString(newSpan));
+ getTracerService().putSpan(run, newSpan, stepStartNode);
+ }
+ }
+
+ @Override
+ public void onEndWithNewSpanStep(@NonNull StepEndNode node, FlowNode nextNode, @NonNull WorkflowRun run) {
+ StepStartNode nodeStartNode = node.getStartNode();
+ GenericStatus nodeStatus = StatusAndTiming.computeChunkStatus2(run, null, nodeStartNode, node, nextNode);
+ endCurrentSpan(node, run, nodeStatus);
+ }
+
@Override
public void notifyOfNewStep(@NonNull Step step, @NonNull StepContext context) {
try {
@@ -416,6 +491,17 @@ private void setAttributesToSpan(@NonNull Span span, OpenTelemetryAttributesActi
if (openTelemetryAttributesAction == null) {
return;
}
+
+ // If the list is empty, ignore this check.
+ if (!openTelemetryAttributesAction.inheritanceAllowedSpanIdListIsEmpty() &&
+ !openTelemetryAttributesAction.isSpanIdAllowedToInheritAttributes(span.getSpanContext().getSpanId())) {
+ // If the list isn't empty, then the attributes shouldn't be set on children spans.
+ // Attributes should only be set on Ids from the list.
+ // If there are Ids on the list but the provided Id isn't part of them,
+ // don't set attributes on the span.
+ return;
+ }
+
if (!openTelemetryAttributesAction.isNotYetAppliedToSpan(span.getSpanContext().getSpanId())) {
// Do not reapply attributes, if previously applied.
// This is important for overriding of attributes to work in an intuitive manner.
diff --git a/src/main/java/io/jenkins/plugins/opentelemetry/job/jenkins/AbstractPipelineListener.java b/src/main/java/io/jenkins/plugins/opentelemetry/job/jenkins/AbstractPipelineListener.java
index 1bb89d91b..756052558 100644
--- a/src/main/java/io/jenkins/plugins/opentelemetry/job/jenkins/AbstractPipelineListener.java
+++ b/src/main/java/io/jenkins/plugins/opentelemetry/job/jenkins/AbstractPipelineListener.java
@@ -45,6 +45,16 @@ public void onEndStageStep(@NonNull StepEndNode stageStepEndNode, @NonNull Strin
}
+ @Override
+ public void onStartWithNewSpanStep(@NonNull StepStartNode stepStartNode, @NonNull WorkflowRun run) {
+
+ }
+
+ @Override
+ public void onEndWithNewSpanStep(@NonNull StepEndNode nodeStepEndNode, FlowNode nextNode, @NonNull WorkflowRun run) {
+
+ }
+
@Override
public void onAtomicStep(@NonNull StepAtomNode node, @NonNull WorkflowRun run) {
diff --git a/src/main/java/io/jenkins/plugins/opentelemetry/job/jenkins/GraphListenerAdapterToPipelineListener.java b/src/main/java/io/jenkins/plugins/opentelemetry/job/jenkins/GraphListenerAdapterToPipelineListener.java
index b8fba7d2b..7b5ae9069 100644
--- a/src/main/java/io/jenkins/plugins/opentelemetry/job/jenkins/GraphListenerAdapterToPipelineListener.java
+++ b/src/main/java/io/jenkins/plugins/opentelemetry/job/jenkins/GraphListenerAdapterToPipelineListener.java
@@ -67,6 +67,8 @@ private void processPreviousNodes(FlowNode node, WorkflowRun run) {
StepStartNode beginParallelBranch = endParallelBranchNode.getStartNode();
ThreadNameAction persistentAction = verifyNotNull(beginParallelBranch.getPersistentAction(ThreadNameAction.class), "Null ThreadNameAction on %s", beginParallelBranch);
fireOnAfterEndParallelStepBranch(endParallelBranchNode, persistentAction.getThreadName(), node, run);
+ } else if (isBeforeEndWithNewSpanStep(previousNode)) {
+ fireOnAfterEndWithNewSpanStep((StepEndNode) previousNode, node, run);
} else {
log(Level.FINE, () -> "Ignore previous node " + PipelineNodeUtil.getDetailedDebugString(previousNode));
}
@@ -98,6 +100,8 @@ private void processCurrentNode(FlowNode node, WorkflowRun run) {
final Map arguments = ArgumentsAction.getFilteredArguments(node);
String label = Objects.toString(arguments.get("label"), null);
fireOnAfterStartNodeStep((StepStartNode) node, label, run);
+ } else if (PipelineNodeUtil.isStartWithNewSpan(node)) {
+ fireOnBeforeWithNewSpanStep((StepStartNode) node, run);
} else {
logFlowNodeDetails(node, run);
}
@@ -212,6 +216,28 @@ public void fireOnAfterEndStageStep(@NonNull StepEndNode node, @NonNull String s
}
}
+ public void fireOnBeforeWithNewSpanStep(@NonNull StepStartNode node, @NonNull WorkflowRun run) {
+ for (PipelineListener pipelineListener : PipelineListener.all()) {
+ log(() -> "onBeforeWithNewSpanStep(" + node.getDisplayName() + "): " + pipelineListener.toString());
+ try {
+ pipelineListener.onStartWithNewSpanStep(node, run);
+ } catch (RuntimeException e) {
+ LOGGER.log(Level.WARNING, e, () -> "Exception invoking `onBeforeWithNewSpanStep` on " + pipelineListener);
+ }
+ }
+ }
+
+ public void fireOnAfterEndWithNewSpanStep(@NonNull StepEndNode node, FlowNode nextNode, @NonNull WorkflowRun run) {
+ for (PipelineListener pipelineListener : PipelineListener.all()) {
+ log(() -> "onAfterEndWithNewSpanStep(" + node.getDisplayName() + "): " + pipelineListener.toString() + (node.getError() != null ? ("error: " + node.getError().getError()) : ""));
+ try {
+ pipelineListener.onEndWithNewSpanStep(node, nextNode, run);
+ } catch (RuntimeException e) {
+ LOGGER.log(Level.WARNING, e, () -> "Exception invoking `onAfterEndWithNewSpanStep` on " + pipelineListener);
+ }
+ }
+ }
+
public void fireOnBeforeAtomicStep(@NonNull StepAtomNode node, @NonNull WorkflowRun run) {
for (PipelineListener pipelineListener : PipelineListener.all()) {
log(() -> "onBeforeAtomicStep(" + node.getDisplayName() + "): " + pipelineListener.toString());
@@ -268,6 +294,10 @@ private boolean isBeforeEndParallelBranch(@NonNull FlowNode node) {
return (node instanceof StepEndNode) && PipelineNodeUtil.isStartParallelBranch(((StepEndNode) node).getStartNode());
}
+ private boolean isBeforeEndWithNewSpanStep(@NonNull FlowNode node) {
+ return (node instanceof StepEndNode) && PipelineNodeUtil.isStartWithNewSpan(((StepEndNode) node).getStartNode());
+ }
+
protected void log(@NonNull Supplier message) {
log(Level.FINE, message);
}
diff --git a/src/main/java/io/jenkins/plugins/opentelemetry/job/jenkins/PipelineListener.java b/src/main/java/io/jenkins/plugins/opentelemetry/job/jenkins/PipelineListener.java
index ea8175011..2d17b3f97 100644
--- a/src/main/java/io/jenkins/plugins/opentelemetry/job/jenkins/PipelineListener.java
+++ b/src/main/java/io/jenkins/plugins/opentelemetry/job/jenkins/PipelineListener.java
@@ -63,6 +63,16 @@ static List all() {
*/
void onEndParallelStepBranch(@NonNull StepEndNode stepStepNode, @NonNull String branchName, FlowNode nextNode, @NonNull WorkflowRun run);
+ /**
+ * Just before the `withNewSpan` step starts
+ */
+ void onStartWithNewSpanStep(@NonNull StepStartNode stepStartNode, @NonNull WorkflowRun run);
+
+ /**
+ * Just before the `withNewSpan` step ends
+ */
+ void onEndWithNewSpanStep(@NonNull StepEndNode nodeStepEndNode, FlowNode nextNode, @NonNull WorkflowRun run);
+
/**
* Just before the atomic step starts
*/
diff --git a/src/main/java/io/jenkins/plugins/opentelemetry/job/jenkins/PipelineNodeUtil.java b/src/main/java/io/jenkins/plugins/opentelemetry/job/jenkins/PipelineNodeUtil.java
index 0740193f6..f9e6c420c 100644
--- a/src/main/java/io/jenkins/plugins/opentelemetry/job/jenkins/PipelineNodeUtil.java
+++ b/src/main/java/io/jenkins/plugins/opentelemetry/job/jenkins/PipelineNodeUtil.java
@@ -8,10 +8,12 @@
import com.google.common.collect.Iterables;
import hudson.model.Action;
import hudson.model.Queue;
+import io.jenkins.plugins.opentelemetry.job.step.WithNewSpanStep;
import org.apache.commons.lang.StringUtils;
import org.jenkinsci.plugins.pipeline.StageStatus;
import org.jenkinsci.plugins.pipeline.SyntheticStage;
import org.jenkinsci.plugins.workflow.actions.*;
+import org.jenkinsci.plugins.workflow.cps.actions.ArgumentsActionImpl;
import org.jenkinsci.plugins.workflow.cps.nodes.StepEndNode;
import org.jenkinsci.plugins.workflow.cps.nodes.StepStartNode;
import org.jenkinsci.plugins.workflow.cps.steps.ParallelStep;
@@ -57,6 +59,21 @@ public static boolean isStartStage(FlowNode node) {
return node.getAction(LabelAction.class) != null;
}
+ public static boolean isStartWithNewSpan(FlowNode node) {
+ if (node == null) {
+ return false;
+ }
+
+ if (!(node instanceof StepStartNode)) {
+ return false;
+ }
+ StepStartNode stepStartNode = (StepStartNode) node;
+ if (!(stepStartNode.getDescriptor() instanceof WithNewSpanStep.DescriptorImpl)) {
+ return false;
+ }
+ return node.getAction(ArgumentsActionImpl.class) != null;
+ }
+
/**
* copy of {@code io.jenkins.blueocean.rest.impl.pipeline.PipelineNodeUtil}
*/
diff --git a/src/main/java/io/jenkins/plugins/opentelemetry/job/step/SpanAttributeStepExecution.java b/src/main/java/io/jenkins/plugins/opentelemetry/job/step/SpanAttributeStepExecution.java
index cf38f446a..3874b51d2 100644
--- a/src/main/java/io/jenkins/plugins/opentelemetry/job/step/SpanAttributeStepExecution.java
+++ b/src/main/java/io/jenkins/plugins/opentelemetry/job/step/SpanAttributeStepExecution.java
@@ -30,10 +30,20 @@ public class SpanAttributeStepExecution extends GeneralNonBlockingStepExecution
private final boolean setOnChildren;
+ private final boolean setAttributesOnlyOnParent;
+
public SpanAttributeStepExecution(List spanAttributes, boolean setOnChildren, StepContext context) {
super(context);
this.spanAttributes = spanAttributes;
this.setOnChildren = setOnChildren;
+ this.setAttributesOnlyOnParent = false;
+ }
+
+ public SpanAttributeStepExecution(List spanAttributes, boolean setOnChildren, StepContext context, boolean setAttributesOnlyOnParent) {
+ super(context);
+ this.spanAttributes = spanAttributes;
+ this.setOnChildren = setOnChildren;
+ this.setAttributesOnlyOnParent = setAttributesOnlyOnParent;
}
@Override
@@ -58,6 +68,7 @@ protected Void run() throws Exception {
OtelTraceService otelTraceService = ExtensionList.lookupSingleton(OtelTraceService.class);
Run run = getContext().get(Run.class);
FlowNode flowNode = getContext().get(FlowNode.class);
+ String currentSpanId = otelTraceService.getSpan(run, flowNode).getSpanContext().getSpanId();
spanAttributes.forEach(spanAttribute -> {
switch (spanAttribute.getTarget()) {
case PIPELINE_ROOT_SPAN:
@@ -100,7 +111,7 @@ protected Void run() throws Exception {
});
getContext()
.newBodyInvoker()
- .withContext(mergeAttributes(getContext(), spanAttributes))
+ .withContext(mergeAttributes(getContext(), spanAttributes, currentSpanId))
.withCallback(NopCallback.INSTANCE)
.start();
}
@@ -117,7 +128,7 @@ private void addAttributeToRunAction(Actionable actionable, AttributeKey attribu
openTelemetryAttributesAction.getAttributes().put(attributeKey, convertedValue);
}
- private OpenTelemetryAttributesAction mergeAttributes(StepContext context, List spanAttributes) throws IOException, InterruptedException {
+ private OpenTelemetryAttributesAction mergeAttributes(StepContext context, List spanAttributes, String currentSpanId) throws IOException, InterruptedException {
OpenTelemetryAttributesAction existingAttributes = context.get(OpenTelemetryAttributesAction.class);
OpenTelemetryAttributesAction resultingAttributes = new OpenTelemetryAttributesAction();
if (existingAttributes != null) {
@@ -126,6 +137,12 @@ private OpenTelemetryAttributesAction mergeAttributes(StepContext context, List<
spanAttributes.forEach(spanAttribute ->
resultingAttributes.getAttributes().put(spanAttribute.getAttributeKey(), spanAttribute.getConvertedValue())
);
+
+ if (setAttributesOnlyOnParent) {
+ // If the flag is set to true, then only the current span will get the attributes.
+ // This will prevent any children from inheriting the attributes of the parent span.
+ resultingAttributes.addSpanIdToInheritanceAllowedList(currentSpanId);
+ }
return resultingAttributes;
}
diff --git a/src/main/java/io/jenkins/plugins/opentelemetry/job/step/WithNewSpanStep.java b/src/main/java/io/jenkins/plugins/opentelemetry/job/step/WithNewSpanStep.java
new file mode 100644
index 000000000..b40c7d911
--- /dev/null
+++ b/src/main/java/io/jenkins/plugins/opentelemetry/job/step/WithNewSpanStep.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright The Original Author or Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.jenkins.plugins.opentelemetry.job.step;
+
+import hudson.Extension;
+import hudson.model.TaskListener;
+import org.jenkinsci.plugins.workflow.steps.Step;
+import org.jenkinsci.plugins.workflow.steps.StepContext;
+import org.jenkinsci.plugins.workflow.steps.StepDescriptor;
+import org.jenkinsci.plugins.workflow.steps.StepExecution;
+import org.kohsuke.stapler.DataBoundConstructor;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+public class WithNewSpanStep extends Step {
+
+ private final String label;
+ private final List attributes;
+ private final boolean setAttributesOnlyOnParent;
+
+ @DataBoundConstructor
+ public WithNewSpanStep(String label, List attributes, Boolean setAttributesOnlyOnParent) {
+ this.label = label;
+ // Allow empty attributes.
+ this.attributes = attributes == null ? new ArrayList<>() : attributes;
+ // Set to 'false', if no value is provided.
+ this.setAttributesOnlyOnParent = setAttributesOnlyOnParent != null && setAttributesOnlyOnParent;
+ }
+
+ public String getLabel() {
+ return label;
+ }
+
+ public List getAttributes() {
+ return attributes;
+ }
+
+ public boolean isSetAttributesOnlyOnParent() {
+ return setAttributesOnlyOnParent;
+ }
+
+ @Override
+ public DescriptorImpl getDescriptor() {
+ return (DescriptorImpl)super.getDescriptor();
+ }
+
+ @Override
+ public StepExecution start(StepContext context) throws Exception {
+ // Set AttributeType for any provided attributes, to avoid an exception if null.
+ attributes.forEach(SpanAttribute::setDefaultType);
+
+ return new SpanAttributeStepExecution(attributes, context.hasBody(), context, setAttributesOnlyOnParent);
+ }
+
+ @Extension
+ public static class DescriptorImpl extends StepDescriptor {
+ @Override
+ public String getFunctionName() {
+ return "withNewSpan";
+ }
+
+ @Override
+ public boolean takesImplicitBlockArgument() {
+ return true;
+ }
+
+ @Override
+ public String getDisplayName() {
+ return "Step with a new user-defined Span";
+ }
+
+ @Override
+ public Set extends Class>> getRequiredContext() {
+ return Collections.singleton(TaskListener.class);
+ }
+ }
+}
diff --git a/src/test/java/io/jenkins/plugins/opentelemetry/BaseIntegrationTest.java b/src/test/java/io/jenkins/plugins/opentelemetry/BaseIntegrationTest.java
index 2e6193f5b..9127eb54b 100644
--- a/src/test/java/io/jenkins/plugins/opentelemetry/BaseIntegrationTest.java
+++ b/src/test/java/io/jenkins/plugins/opentelemetry/BaseIntegrationTest.java
@@ -41,9 +41,12 @@
import org.jvnet.hudson.test.BuildWatcher;
import edu.umd.cs.findbugs.annotations.NonNull;
+
+import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
+import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
@@ -101,6 +104,15 @@ public static void beforeClass() throws Exception {
jenkinsRule.waitUntilNoActivity();
LOGGER.log(Level.INFO, "Jenkins started");
+ // Update all sites to reload available plugins.
+ jenkinsRule.jenkins.getUpdateCenter().updateAllSites();
+
+ // install() returns a Future for every plugin. Call get() on the Future so that this line blocks
+ // until the operation is finished and the future is available.
+ jenkinsRule.jenkins.getPluginManager().install(
+ Collections.singletonList("durable-task"), true).get(0).get();
+ Assert.assertNotNull(jenkinsRule.jenkins.getPluginManager().getPlugin("durable-task"));
+
ExtensionList jenkinsOpenTelemetries = jenkinsRule.getInstance().getExtensionList(JenkinsControllerOpenTelemetry.class);
verify(jenkinsOpenTelemetries.size() == 1, "Number of jenkinsControllerOpenTelemetrys: %s", jenkinsOpenTelemetries.size());
jenkinsControllerOpenTelemetry = jenkinsOpenTelemetries.get(0);
@@ -253,6 +265,42 @@ protected void assertBuildStepMetadata(Tree spans, String stepN
MatcherAssert.assertThat(attributes.get(JenkinsOtelSemanticAttributes.JENKINS_STEP_PLUGIN_VERSION), CoreMatchers.is(CoreMatchers.notNullValue()));
}
+ /**
+ * This method is used for testing that the correct spans and their children have been generated.
+ * It returns a map,
+ * - key: span name
+ * - value: list of children span names
+ */
+ protected Map> getSpanMapWithChildrenFromTree(Tree spansTree) {
+ Map> spansTreeMap = new HashMap<>();
+ // Stream all the nodes.
+ spansTree.breadthFirstStreamNodes().forEach(node -> {
+ String parentSpanName = node.getData().spanData.getName();
+ List childrenSpanNames = new ArrayList<>();
+
+ // Get the children and for each one, store the name.
+ node.getChildren().forEach(child -> childrenSpanNames.add(child.getData().spanData.getName()));
+
+ // Put the span and its children on the map.
+ spansTreeMap.put(parentSpanName, childrenSpanNames);
+ });
+ return spansTreeMap;
+ }
+
+ /**
+ * This method is used for testing a span's data like attributes.
+ * It returns a map,
+ * - key: span name
+ * - value: SpanData
+ */
+ protected Map getSpanDataMapFromTree(Tree spansTree) {
+ Map spansTreeMap = new HashMap<>();
+ // Stream all the nodes.
+ spansTree.breadthFirstStreamNodes().forEach(node ->
+ spansTreeMap.put(node.getData().spanData.getName(), node.getData().spanData));
+ return spansTreeMap;
+ }
+
// https://github.com/jenkinsci/workflow-multibranch-plugin/blob/master/src/test/java/org/jenkinsci/plugins/workflow/multibranch/WorkflowMultiBranchProjectTest.java
@NonNull
public static WorkflowJob scheduleAndFindBranchProject(@NonNull WorkflowMultiBranchProject mp, @NonNull String name) throws Exception {
diff --git a/src/test/java/io/jenkins/plugins/opentelemetry/job/MonitoringPipelineListenerTest.java b/src/test/java/io/jenkins/plugins/opentelemetry/job/MonitoringPipelineListenerTest.java
new file mode 100644
index 000000000..3e39382ee
--- /dev/null
+++ b/src/test/java/io/jenkins/plugins/opentelemetry/job/MonitoringPipelineListenerTest.java
@@ -0,0 +1,306 @@
+/*
+ * Copyright The Original Author or Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.jenkins.plugins.opentelemetry.job;
+
+import hudson.ExtensionList;
+import hudson.model.Computer;
+import io.jenkins.plugins.opentelemetry.JenkinsControllerOpenTelemetry;
+import io.jenkins.plugins.opentelemetry.OpenTelemetryAttributesAction;
+import io.jenkins.plugins.opentelemetry.job.step.SpanAttribute;
+import io.opentelemetry.api.common.AttributeKey;
+import io.opentelemetry.api.trace.Span;
+import io.opentelemetry.api.trace.Tracer;
+import io.opentelemetry.context.Context;
+import io.opentelemetry.context.ImplicitContextKeyed;
+import io.opentelemetry.context.Scope;
+import io.opentelemetry.sdk.testing.trace.SpanBuilderMock;
+import io.opentelemetry.sdk.testing.trace.SpanMock;
+import io.opentelemetry.sdk.testing.trace.TracerMock;
+import jenkins.model.Jenkins;
+import org.jenkinsci.plugins.workflow.actions.ArgumentsAction;
+import org.jenkinsci.plugins.workflow.cps.nodes.StepStartNode;
+import org.jenkinsci.plugins.workflow.graph.FlowNode;
+import org.jenkinsci.plugins.workflow.job.WorkflowRun;
+import org.jenkinsci.plugins.workflow.steps.Step;
+import org.jenkinsci.plugins.workflow.steps.StepContext;
+import org.jetbrains.annotations.NotNull;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.ClassRule;
+import org.junit.Test;
+
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Suite;
+import org.jvnet.hudson.test.JenkinsRule;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static com.google.common.base.Verify.verify;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for {@link MonitoringPipelineListener}.
+ * Using subclasses so that the non-parameterized tests
+ * don't have to be run once for every parameter.
+ */
+@RunWith(Suite.class)
+@Suite.SuiteClasses({
+ MonitoringPipelineListenerTest.ParamTests.class,
+ MonitoringPipelineListenerTest.NonParamTests.class
+})
+public class MonitoringPipelineListenerTest {
+
+ @ClassRule
+ public static final JenkinsRule jenkinsRule = new JenkinsRule();
+
+ private static final StepContext stepContext = Mockito.mock(StepContext.class);
+ private static final OtelTraceService otelTraceService = Mockito.mock(OtelTraceService.class);
+ private static final WorkflowRun workflowRun = Mockito.mock(WorkflowRun.class);
+
+ @BeforeClass
+ public static void commonSetup() throws IOException, InterruptedException {
+ // Jenkins must have been initialized.
+ Assert.assertNotNull(Jenkins.getInstanceOrNull());
+
+ Mockito.when(stepContext.get(WorkflowRun.class)).thenReturn(workflowRun);
+ }
+
+ @RunWith(Parameterized.class)
+ public static class ParamTests {
+
+ private static final String START_NODE_ROOT_SPAN_NAME = "root-span";
+ private static final String WITH_NEW_SPAN_NAME = "with-new-span";
+ private final SpanBuilderMock spanBuilderMock = Mockito.spy(new SpanBuilderMock(WITH_NEW_SPAN_NAME));
+ private MonitoringPipelineListener monitoringPipelineListener;
+
+ @Parameterized.Parameter(0)
+ public String attributeKeyName;
+
+ @Parameterized.Parameter(1)
+ public Object attributeKeyObj;
+
+ @Parameterized.Parameter(2)
+ public Object attributeValueObj;
+
+ @Parameterized.Parameters
+ public static Collection