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 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> 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 argumentsActionScenarios() { + return Arrays.asList(new Object[][] { + { "with.new.span.boolean", AttributeKey.booleanKey("with.new.span.boolean"), true }, + { "with.new.span.string", AttributeKey.stringKey("with.new.span.string"), "true" }, + { "with.new.span.long", AttributeKey.longKey("with.new.span.long"), 2L }, + { "with.new.span.double", AttributeKey.doubleKey("with.new.span.double"), 2.22 }, + }); + } + + @Before + public void setup() { + ExtensionList jenkinsOpenTelemetries = jenkinsRule.getInstance().getExtensionList(JenkinsControllerOpenTelemetry.class); + verify(jenkinsOpenTelemetries.size() == 1, "Number of jenkinsControllerOpenTelemetrys: %s", jenkinsOpenTelemetries.size()); + JenkinsControllerOpenTelemetry jenkinsControllerOpenTelemetry = Mockito.spy(jenkinsOpenTelemetries.get(0)); + + Tracer tracer = Mockito.spy(new TracerMock()); + + Mockito.when(tracer.spanBuilder(WITH_NEW_SPAN_NAME)).thenReturn(spanBuilderMock); + + Mockito.when(jenkinsControllerOpenTelemetry.getDefaultTracer()).thenReturn(tracer); + + monitoringPipelineListener = new MonitoringPipelineListener(); + monitoringPipelineListener.jenkinsControllerOpenTelemetry = jenkinsControllerOpenTelemetry; + + Assert.assertNull(monitoringPipelineListener.getTracer()); + // postConstruct() calls the getDefaultTracer() method which needs to be stubbed in advance before using the tracer. + // Manually invoke the postConstruct() method to re-apply the @PostConstruct logic. + monitoringPipelineListener.postConstruct(); + + Assert.assertNotNull(monitoringPipelineListener.getTracer()); + + monitoringPipelineListener.setOpenTelemetryTracerService(otelTraceService); + } + + @Test + public void testOnStartWithNewSpanStep() { + + StepStartNode stepStartNode = Mockito.mock(StepStartNode.class); + ArgumentsAction action = new ArgumentsAction() { + @NotNull + @Override + protected Map getArgumentsInternal() { + Map map = new HashMap<>(); + map.put("label", WITH_NEW_SPAN_NAME); + List spanAttributes = new ArrayList<>(); + SpanAttribute spanAttribute = new SpanAttribute(attributeKeyName, attributeValueObj, null, null); + spanAttributes.add(spanAttribute); + map.put("attributes", spanAttributes); + return map; + } + }; + Mockito.when(stepStartNode.getPersistentAction(ArgumentsAction.class)).thenReturn(action); + + Scope scope = Mockito.mock(Scope.class); + + SpanMock rootSpan = Mockito.spy(new SpanMock(START_NODE_ROOT_SPAN_NAME)); + + Mockito.when(rootSpan.makeCurrent()).thenReturn(scope); + + Mockito.when(otelTraceService.getSpan(workflowRun)).thenReturn(rootSpan); + Mockito.when(otelTraceService.getSpan(workflowRun, stepStartNode)).thenReturn(rootSpan); + + monitoringPipelineListener.setOpenTelemetryTracerService(otelTraceService); + Assert.assertNotNull(monitoringPipelineListener.getTracerService().getSpan(workflowRun)); + + SpanMock newSpan = Mockito.spy(new SpanMock(WITH_NEW_SPAN_NAME)); + Mockito.when(monitoringPipelineListener.getTracer().spanBuilder(WITH_NEW_SPAN_NAME).startSpan()).thenReturn(newSpan); + + try (MockedStatic mockedStaticSpan = mockStatic(Span.class); + MockedStatic mockedStaticContext = mockStatic(Context.class)) { + // Span.current() should return the mocked span. + mockedStaticSpan.when(Span::current).thenReturn(rootSpan); + Assert.assertEquals(rootSpan, Span.current()); + + mockedStaticSpan.when(() -> Span.fromContext(any())).thenReturn(rootSpan); + + Context context = Mockito.mock(Context.class); + when(context.with(any(ImplicitContextKeyed.class))).thenReturn(context); + mockedStaticContext.when(Context::current).thenReturn(context); + + // The span builder shouldn't have any attributes. + Assert.assertEquals(0, spanBuilderMock.getAttributes().size()); + Assert.assertFalse(spanBuilderMock.getAttributes().containsKey(attributeKeyObj)); + + monitoringPipelineListener.onStartWithNewSpanStep(stepStartNode, workflowRun); + + // After the onStartWithNewSpanStep() call, the spanBuilder should contain the attribute. + Assert.assertTrue(spanBuilderMock.getAttributes().containsKey(attributeKeyObj)); + Assert.assertEquals(attributeValueObj, spanBuilderMock.getAttributes().get(attributeKeyObj)); + } + } + } + + public static class NonParamTests { + + private static final String TEST_SPAN_NAME = "test-span"; + private static final String SH_STEP_SPAN_NAME = "sh-span"; + private final FlowNode flowNode = Mockito.mock(FlowNode.class); + private final MonitoringPipelineListener monitoringPipelineListener = new MonitoringPipelineListener(); + private SpanMock testSpan; + + @Before + public void setup() throws IOException, InterruptedException { + monitoringPipelineListener.setOpenTelemetryTracerService(otelTraceService); + + testSpan = new SpanMock(TEST_SPAN_NAME); + testSpan.setAttribute("caller.name", "testuser"); + + Mockito.when(stepContext.get(FlowNode.class)).thenReturn(flowNode); + + Mockito.when(otelTraceService.getSpan(workflowRun)).thenReturn(testSpan); + Mockito.when(otelTraceService.getSpan(workflowRun, flowNode)).thenReturn(testSpan); + } + + @After + public void cleanup() { + testSpan.end(); + } + + private void setupAttributesActionStubs(List allowedIds) throws IOException, InterruptedException { + Computer computer = Mockito.mock(Computer.class); + + Mockito.when(stepContext.get(Computer.class)).thenReturn(computer); + + // Computer AttributesAction stub. + OpenTelemetryAttributesAction otelComputerAttributesAction = new OpenTelemetryAttributesAction(); + otelComputerAttributesAction.getAttributes().put(AttributeKey.stringKey("attribute.from.computer.action.applied"), "true"); + Mockito.when(computer.getAction(OpenTelemetryAttributesAction.class)).thenReturn(otelComputerAttributesAction); + + // Child AttributesAction stub. + OpenTelemetryAttributesAction otelChildAttributesAction = new OpenTelemetryAttributesAction(); + + for (String id : allowedIds) { + otelChildAttributesAction.addSpanIdToInheritanceAllowedList(id); + } + + otelChildAttributesAction.getAttributes().put(AttributeKey.stringKey("attribute.from.child.action.applied"), "true"); + Mockito.when(stepContext.get(OpenTelemetryAttributesAction.class)).thenReturn(otelChildAttributesAction); + } + + @Test + public void testSetAttributesToSpan() throws IOException, InterruptedException { + // Pass an empty list. All spans are allowed to get attributes. + setupAttributesActionStubs(new ArrayList<>()); + + try (MockedStatic mockedStatic = mockStatic(Span.class)) { + // Span.current() should return the mocked span. + mockedStatic.when(Span::current).thenReturn(testSpan); + Assert.assertEquals(testSpan, Span.current()); + + // The span should contain only 1 attribute. + Assert.assertEquals(1, testSpan.getAttributes().keySet().size()); + Assert.assertTrue(testSpan.getAttributes().containsKey(AttributeKey.stringKey("caller.name"))); + Assert.assertEquals("testuser", testSpan.getAttributes().get(AttributeKey.stringKey("caller.name"))); + + Step step = Mockito.mock(Step.class); + monitoringPipelineListener.notifyOfNewStep(step, stepContext); + + // The span should now contain the computer and child action attributes as well. + Assert.assertEquals(3, testSpan.getAttributes().keySet().size()); + Assert.assertTrue(testSpan.getAttributes().containsKey(AttributeKey.stringKey("caller.name"))); + Assert.assertEquals("testuser", testSpan.getAttributes().get(AttributeKey.stringKey("caller.name"))); + + for (String component : Arrays.asList("computer", "child")) { + AttributeKey attributeKey = AttributeKey.stringKey("attribute.from." + component + ".action.applied"); + Assert.assertTrue(testSpan.getAttributes().containsKey(attributeKey)); + Assert.assertEquals("true", testSpan.getAttributes().get(attributeKey)); + } + } + } + + @Test + public void testSetAttributesToSpanWithNotAllowedSpanId() throws IOException, InterruptedException { + String testSpanId = testSpan.getSpanContext().getSpanId(); + setupAttributesActionStubs(Collections.singletonList(testSpanId)); + + SpanMock shSpan = new SpanMock(SH_STEP_SPAN_NAME); + + Assert.assertNotEquals(testSpan.getSpanContext().getSpanId(), shSpan.getSpanContext().getSpanId()); + + try (MockedStatic mockedStatic = mockStatic(Span.class)) { + // Span.current() should return the mocked span. + mockedStatic.when(Span::current).thenReturn(shSpan); + Assert.assertEquals(shSpan, Span.current()); + + // The span doesn't have any attributes. + Assert.assertEquals(0, shSpan.getAttributes().keySet().size()); + + Step step = Mockito.mock(Step.class); + monitoringPipelineListener.notifyOfNewStep(step, stepContext); + + // The span should now contain only the computer action attribute. + // The computer action allowedSpanIdList is empty while + // the child action allowedSpanIdList has an id. + // The id on the list is different from the id of the current span. + Assert.assertEquals(1, shSpan.getAttributes().keySet().size()); + + AttributeKey attributeKey = AttributeKey.stringKey("attribute.from.computer.action.applied"); + Assert.assertTrue(shSpan.getAttributes().containsKey(attributeKey)); + Assert.assertEquals("true", shSpan.getAttributes().get(attributeKey)); + } + } + } +} diff --git a/src/test/java/io/jenkins/plugins/opentelemetry/job/step/WithNewSpanStepTest.java b/src/test/java/io/jenkins/plugins/opentelemetry/job/step/WithNewSpanStepTest.java new file mode 100644 index 000000000..42760454c --- /dev/null +++ b/src/test/java/io/jenkins/plugins/opentelemetry/job/step/WithNewSpanStepTest.java @@ -0,0 +1,218 @@ +/* + * Copyright The Original Author or Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.jenkins.plugins.opentelemetry.job.step; + +import com.github.rutledgepaulv.prune.Tree; +import hudson.model.Result; +import io.jenkins.plugins.opentelemetry.BaseIntegrationTest; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.sdk.trace.data.SpanData; +import org.apache.commons.lang3.SystemUtils; +import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition; +import org.jenkinsci.plugins.workflow.job.WorkflowJob; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static org.junit.Assume.assumeFalse; + +public class WithNewSpanStepTest extends BaseIntegrationTest { + + private static WorkflowJob pipeline; + + @BeforeClass + public static void setUp() throws Exception { + assumeFalse(SystemUtils.IS_OS_WINDOWS); + + jenkinsRule.createOnlineSlave(); + + final String jobName = "test-pipeline-spans" + jobNameSuffix.incrementAndGet(); + pipeline = jenkinsRule.createProject(WorkflowJob.class, jobName); + } + + @Test + public void testLibCallWithUserDefinedSpan() throws Exception { + String pipelineScript = + "def xsh(cmd) {if (isUnix()) {sh cmd} else {bat cmd}};\n" + + "\n" + + "def runBuilds() {\n" + + " withNewSpan(label: 'run-builds') {\n" + + " xsh (label: 'build-mod1', script: 'echo building-module-1') \n" + + " xsh (label: 'build-mod2', script: 'echo building-module-2') \n" + + " }\n" + + "}\n" + + "\n" + + "node {\n" + + " stage('build') {\n" + + " runBuilds()" + + " }\n" + + "}"; + + pipeline.setDefinition(new CpsFlowDefinition(pipelineScript, true)); + jenkinsRule.assertBuildStatus(Result.SUCCESS, pipeline.scheduleBuild2(0)); + + final Tree spansTree = getGeneratedSpans(); + Map> spansTreeMap = getSpanMapWithChildrenFromTree(spansTree); + + // Check that the new spans exist. + Assert.assertNotNull(spansTreeMap.get("run-builds")); + Assert.assertNotNull(spansTreeMap.get("build-mod1")); + Assert.assertNotNull(spansTreeMap.get("build-mod2")); + + // Check span children. + Assert.assertTrue(spansTreeMap.get("run-builds").containsAll(Arrays.asList("build-mod1", "build-mod2"))); + Assert.assertEquals(2, spansTreeMap.get("run-builds").size()); + } + + @Test + public void testUserDefinedSpanWithAttributes() throws Exception { + String pipelineScript = + "def xsh(cmd) {if (isUnix()) {sh cmd} else {bat cmd}};\n" + + "node {\n" + + " stage('build') {\n" + + " withNewSpan(label: 'run-builds', attributes: ([\n" + + " spanAttribute(key: 'modules-num', value: '2'),\n" + + " spanAttribute(key: 'command', value: 'build')\n" + + " ])) {\n" + + " xsh (label: 'build-mod1', script: 'echo building-module-1') \n" + + " echo 'building-module-2'\n" + + " }\n" + + " }\n" + + "}"; + + pipeline.setDefinition(new CpsFlowDefinition(pipelineScript, true)); + jenkinsRule.assertBuildStatus(Result.SUCCESS, pipeline.scheduleBuild2(0)); + + final Tree spansTree = getGeneratedSpans(); + Map> spansTreeMap = getSpanMapWithChildrenFromTree(spansTree); + Map spansDataMap = getSpanDataMapFromTree(spansTree); + + // Check that the new spans exist. + Assert.assertNotNull(spansTreeMap.get("run-builds")); + Assert.assertNotNull(spansTreeMap.get("build-mod1")); + + // Check span children. + Assert.assertTrue(spansTreeMap.get("run-builds").contains("build-mod1")); + Assert.assertEquals(1, spansTreeMap.get("run-builds").size()); + + // Check user-defined span attributes. + Attributes parentAttributes = spansDataMap.get("run-builds").getAttributes(); + Assert.assertEquals("2", parentAttributes.get(AttributeKey.stringKey("modules-num"))); + Assert.assertEquals("build", parentAttributes.get(AttributeKey.stringKey("command"))); + + // Span attributes are also passed to children. + Attributes childAttributes = spansDataMap.get("build-mod1").getAttributes(); + Assert.assertEquals("2", childAttributes.get(AttributeKey.stringKey("modules-num"))); + Assert.assertEquals("build", childAttributes.get(AttributeKey.stringKey("command"))); + } + + @Test + public void testUserDefinedSpanWithChildren() throws Exception { + String pipelineScript = + "def xsh(cmd) {if (isUnix()) {sh cmd} else {bat cmd}};\n" + + "node {\n" + + " stage('build') {\n" + + " withNewSpan(label: 'run-builds') {\n" + + " xsh (label: 'build-mod1', script: 'echo building-module-1') \n" + + " xsh (label: 'build-mod2', script: 'echo building-module-2') \n" + + " }\n" + + " }\n" + + "}"; + + pipeline.setDefinition(new CpsFlowDefinition(pipelineScript, true)); + jenkinsRule.assertBuildStatus(Result.SUCCESS, pipeline.scheduleBuild2(0)); + + final Tree spansTree = getGeneratedSpans(); + Map> spansTreeMap = getSpanMapWithChildrenFromTree(spansTree); + + // Use a non-existent span name. + Assert.assertNull(spansTreeMap.get("non-existent-span")); + + // Check that the new spans exist. + Assert.assertNotNull(spansTreeMap.get("run-builds")); + Assert.assertNotNull(spansTreeMap.get("build-mod1")); + Assert.assertNotNull(spansTreeMap.get("build-mod2")); + + // Check span children. + Assert.assertTrue(spansTreeMap.get("run-builds").containsAll(Arrays.asList("build-mod1", "build-mod2"))); + Assert.assertFalse(spansTreeMap.get("run-builds").containsAll(Arrays.asList("build-mod1", "non-existent"))); + Assert.assertEquals(2, spansTreeMap.get("run-builds").size()); + } + + @Test + public void testUserDefinedSpanWithNoChildren() throws Exception { + String pipelineScript = + "def xsh(cmd) {if (isUnix()) {sh cmd} else {bat cmd}};\n" + + "node {\n" + + " stage('build') {\n" + + " withNewSpan(label: 'run-builds') {\n" + + " echo 'building-module-1'\n" + + " echo 'building-module-2'\n" + + " }\n" + + " }\n" + + "}"; + + pipeline.setDefinition(new CpsFlowDefinition(pipelineScript, true)); + jenkinsRule.assertBuildStatus(Result.SUCCESS, pipeline.scheduleBuild2(0)); + + final Tree spansTree = getGeneratedSpans(); + Map> spansTreeMap = getSpanMapWithChildrenFromTree(spansTree); + + // Check that the new spans exist. + Assert.assertNotNull(spansTreeMap.get("run-builds")); + + // Check span children. + Assert.assertTrue(spansTreeMap.get("run-builds").isEmpty()); + Assert.assertEquals(0, spansTreeMap.get("run-builds").size()); + } + + @Test + public void testUserDefinedSpanWithAttributesNotPassedOnToChildren() throws Exception { + String pipelineScript = + "def xsh(cmd) {if (isUnix()) {sh cmd} else {bat cmd}};\n" + + "node {\n" + + " stage('build') {\n" + + " withNewSpan(label: 'run-builds', attributes: ([\n" + + " spanAttribute(key: 'modules-num', value: '2'),\n" + + " spanAttribute(key: 'command', value: 'build')\n" + + " ]), setAttributesOnlyOnParent: true) {\n" + + " xsh (label: 'build-mod1', script: 'echo building-module-1') \n" + + " echo 'building-module-2'\n" + + " }\n" + + " }\n" + + "}"; + + pipeline.setDefinition(new CpsFlowDefinition(pipelineScript, true)); + jenkinsRule.assertBuildStatus(Result.SUCCESS, pipeline.scheduleBuild2(0)); + + final Tree spansTree = getGeneratedSpans(); + Map> spansTreeMap = getSpanMapWithChildrenFromTree(spansTree); + Map spansDataMap = getSpanDataMapFromTree(spansTree); + + // Check that the new spans exist. + Assert.assertNotNull(spansTreeMap.get("run-builds")); + + // Check span children. + Assert.assertTrue(spansTreeMap.get("run-builds").contains("build-mod1")); + Assert.assertEquals(1, spansTreeMap.get("run-builds").size()); + + // Check user-defined span attributes. + Attributes parentAttributes = spansDataMap.get("run-builds").getAttributes(); + Assert.assertEquals("2", parentAttributes.get(AttributeKey.stringKey("modules-num"))); + Assert.assertEquals("build", parentAttributes.get(AttributeKey.stringKey("command"))); + + // Span attributes are NOT passed to children. + Attributes childAttributes = spansDataMap.get("build-mod1").getAttributes(); + Assert.assertNull(childAttributes.get(AttributeKey.stringKey("modules-num"))); + Assert.assertNull(childAttributes.get(AttributeKey.stringKey("command"))); + } + +} diff --git a/src/test/java/io/opentelemetry/sdk/testing/trace/SpanMock.java b/src/test/java/io/opentelemetry/sdk/testing/trace/SpanMock.java new file mode 100644 index 000000000..2ef309aa0 --- /dev/null +++ b/src/test/java/io/opentelemetry/sdk/testing/trace/SpanMock.java @@ -0,0 +1,181 @@ +/* + * Copyright The Original Author or Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.testing.trace; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.context.Context; +import org.mockito.Mockito; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * Mock class for {@link Span}. It exposes the attributes for unit testing. + */ +public class SpanMock implements Span { + + private final String spanName; + private final Span delegate; + private final Map, Object> attributesMap = new HashMap<>(); + + public SpanMock(String spanName) { + this.spanName = spanName; + this.delegate = new SpanBuilderMock(spanName).startSpan(); + } + + public Map, Object> getAttributes() { + return attributesMap; + } + + @Override + public Span setAttribute(String key, String value) { + attributesMap.put(AttributeKey.stringKey(key), value); + return delegate.setAttribute(key, value); + } + + @Override + public Span setAttribute(String key, long value) { + attributesMap.put(AttributeKey.stringKey(key), value); + return delegate.setAttribute(key, value); + } + + @Override + public Span setAttribute(String key, double value) { + attributesMap.put(AttributeKey.stringKey(key), value); + return delegate.setAttribute(key, value); + } + + @Override + public Span setAttribute(String key, boolean value) { + attributesMap.put(AttributeKey.stringKey(key), value); + return delegate.setAttribute(key, value); + } + + @Override + public Span setAttribute(AttributeKey key, int value) { + attributesMap.put(key, value); + return delegate.setAttribute(key, value); + } + + @Override + public Span setAllAttributes(Attributes attributes) { + if (!attributes.isEmpty()) { + attributes.forEach(attributesMap::put); + } + return delegate.setAllAttributes(attributes); + } + + @Override + public Span addEvent(String name) { + return delegate.addEvent(name); + } + + @Override + public Span addEvent(String name, long timestamp, TimeUnit unit) { + return delegate.addEvent(name, timestamp, unit); + } + + @Override + public Span addEvent(String name, Instant timestamp) { + return delegate.addEvent(name, timestamp); + } + + @Override + public Span addEvent(String name, Attributes attributes, Instant timestamp) { + return delegate.addEvent(name, attributes, timestamp); + } + + @Override + public Span setStatus(StatusCode statusCode) { + return delegate.setStatus(statusCode); + } + + @Override + public Span recordException(Throwable exception) { + return delegate.recordException(exception); + } + + @Override + public Span addLink(SpanContext spanContext) { + return delegate.addLink(spanContext); + } + + @Override + public Span addLink(SpanContext spanContext, Attributes attributes) { + return delegate.addLink(spanContext, attributes); + } + + @Override + public void end(Instant timestamp) { + delegate.end(timestamp); + } + + @Override + public Context storeInContext(Context context) { + return delegate.storeInContext(context); + } + + @Override + public Span setAttribute(AttributeKey attributeKey, T t) { + attributesMap.put(attributeKey, t); + return delegate.setAttribute(attributeKey, t); + } + + @Override + public Span addEvent(String s, Attributes attributes) { + return delegate.addEvent(s, attributes); + } + + @Override + public Span addEvent(String s, Attributes attributes, long l, TimeUnit timeUnit) { + return delegate.addEvent(s, attributes, l, timeUnit); + } + + @Override + public Span setStatus(StatusCode statusCode, String s) { + return delegate.setStatus(statusCode, s); + } + + @Override + public Span recordException(Throwable throwable, Attributes attributes) { + return delegate.recordException(throwable, attributes); + } + + @Override + public Span updateName(String s) { + return delegate.updateName(s); + } + + @Override + public void end() { + delegate.end(); + } + + @Override + public void end(long l, TimeUnit timeUnit) { + delegate.end(l, timeUnit); + } + + @Override + public SpanContext getSpanContext() { + // The span id is always 0000000000000000. + // Spy the object and stub it to provide a new id. + SpanContext spanContext = Mockito.spy(delegate.getSpanContext()); + Mockito.when(spanContext.getSpanId()).thenReturn("spanId-" + spanName); + return spanContext; + } + + @Override + public boolean isRecording() { + return delegate.isRecording(); + } +}