Skip to content
Closed
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
2497d7e
Allow multiple LogStorage with primary and secondaries
jgreffe Jul 15, 2025
aefa52d
Additional tests and use cases
jgreffe Jul 16, 2025
2e0d37c
Apply suggestions
jgreffe Jul 17, 2025
88322ca
Apply suggestions
jgreffe Jul 17, 2025
2e2c97b
Switch from a list of factories to simple primary and secondary
jgreffe Jul 17, 2025
dd6e930
Create a specific `LogStorageFactoryDescriptor` for further server-si…
jgreffe Jul 18, 2025
b2a5025
Remove useless methods
jgreffe Jul 18, 2025
bd88176
Remove `TeePrintStream`, remove synchronization, update `mangled` test
jgreffe Jul 18, 2025
9090904
Handle the default descriptor and the TeeLogStorageFactory dropdowns
jgreffe Jul 21, 2025
2137275
Handle exceptions in `TeeOutputStream` and unit test
jgreffe Jul 22, 2025
ed2165c
Handle duplicate factory case
jgreffe Jul 22, 2025
dd5d67d
Handle default factory instance
jgreffe Jul 22, 2025
1512a75
Default factory instance test
jgreffe Jul 23, 2025
3c9d50d
Additional test
jgreffe Jul 23, 2025
1476e12
Fix test on windows
jgreffe Jul 23, 2025
05c1f4d
Fix test on windows
jgreffe Jul 23, 2025
3fe4cc6
Test on windows
jgreffe Jul 23, 2025
da22a49
Properly close the logger
jgreffe Jul 24, 2025
cdc3958
Fix test
jgreffe Jul 24, 2025
8dec700
More robust test
jgreffe Jul 24, 2025
02e97a3
Handle default factory
jgreffe Jul 24, 2025
ee9a44e
Cleanup
jgreffe Jul 28, 2025
d8fec33
UI test for the default factory
jgreffe Jul 28, 2025
423246b
Apply suggestions
jgreffe Jul 29, 2025
6d543ad
Update the default factory configuration
jgreffe Jul 30, 2025
77d2fc3
Display the factory plugin
jgreffe Aug 5, 2025
b707a36
Update the labels
jgreffe Aug 5, 2025
b1e6d01
Fix the tests
jgreffe Aug 5, 2025
2b3f3d6
Add readme.md
jgreffe Aug 5, 2025
8da65e3
Apply suggestions from code review
jgreffe Aug 6, 2025
62b9450
Update javadoc
jgreffe Aug 6, 2025
3782d00
Add `@Restricted` annotation
jgreffe Aug 6, 2025
96d6dae
Change descriptor methods to `isReadWrite` and `isWriteOnly`
jgreffe Aug 6, 2025
365c9e6
Apply suggestions from code review
jgreffe Aug 8, 2025
d93ae64
Update src/main/java/org/jenkinsci/plugins/workflow/log/tee/TeeBuildL…
jgreffe Aug 8, 2025
d83b6c0
Apply suggestions from code review
jgreffe Sep 1, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -138,5 +138,10 @@
<artifactId>apache-httpcomponents-client-4-api</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.jenkins.configuration-as-code</groupId>
<artifactId>test-harness</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package org.jenkinsci.plugins.workflow.log;

import edu.umd.cs.findbugs.annotations.NonNull;
import hudson.Extension;
import hudson.model.Descriptor;
import java.io.File;
import org.jenkinsci.Symbol;
import org.jenkinsci.plugins.workflow.flow.FlowExecutionOwner;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.Beta;
import org.kohsuke.stapler.DataBoundConstructor;

@Restricted(Beta.class)
public class FileLogStorageFactory implements LogStorageFactory {

@DataBoundConstructor
public FileLogStorageFactory() {}

@Override
public LogStorage forBuild(@NonNull FlowExecutionOwner b) {
try {
return FileLogStorage.forFile(new File(b.getRootDir(), "log"));
} catch (Exception x) {
return new BrokenLogStorage(x);

Check warning on line 24 in src/main/java/org/jenkinsci/plugins/workflow/log/FileLogStorageFactory.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 23-24 are not covered by tests
}
}

@Extension
@Symbol("file")
public static final class DescriptorImpl extends LogStorageFactoryDescriptor<FileLogStorageFactory> {
@NonNull
@Override
public String getDisplayName() {
return "File Log Storage Factory";
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@

package org.jenkinsci.plugins.workflow.log;

import edu.umd.cs.findbugs.annotations.NonNull;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import hudson.ExtensionList;
import hudson.console.AnnotatedLargeText;
import hudson.console.ConsoleAnnotationOutputStream;
import hudson.model.BuildListener;
Expand All @@ -35,10 +35,11 @@
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;
import edu.umd.cs.findbugs.annotations.NonNull;
import org.jenkinsci.plugins.workflow.actions.LogAction;
import org.jenkinsci.plugins.workflow.log.configuration.PipelineLoggingGlobalConfiguration;
import org.jenkinsci.plugins.workflow.flow.FlowExecutionOwner;
import org.jenkinsci.plugins.workflow.graph.FlowNode;
import org.jenkinsci.plugins.workflow.steps.StepContext;
Expand Down Expand Up @@ -112,7 +113,7 @@
* @return a log for this just this node
* @see LogAction
*/
@NonNull AnnotatedLargeText<FlowNode> stepLog(@NonNull FlowNode node, boolean complete);
@NonNull AnnotatedLargeText<FlowNode> stepLog(@NonNull FlowNode node, boolean complete);

/**
* Provide a file containing the log text.
Expand Down Expand Up @@ -160,8 +161,9 @@
*/
static @NonNull LogStorage of(@NonNull FlowExecutionOwner b) {
try {
for (LogStorageFactory factory : ExtensionList.lookup(LogStorageFactory.class)) {
LogStorage storage = factory.forBuild(b);
PipelineLoggingGlobalConfiguration config = PipelineLoggingGlobalConfiguration.get();
if (config.getFactory() != null) {
LogStorage storage = config.getFactory().forBuild(b);
if (storage != null) {
// Pending integration with JEP-207 / JEP-212, this choice is not persisted.
return storage;
Expand All @@ -174,6 +176,22 @@
}
}

/**
* Return the primary Log Storage. By default, it's the current implementation.
* See {@link org.jenkinsci.plugins.workflow.log.tee.TeeLogStorage} for overriden implementation.
*/
default LogStorage getPrimary() {
return this;
}

/**
* Return a list of secondary Log Storages. Buy default it's an empty list.
* See {@link org.jenkinsci.plugins.workflow.log.tee.TeeLogStorage} for overriden implementation.
*/
default List<LogStorage> getSecondaries() {
return List.of();

Check warning on line 192 in src/main/java/org/jenkinsci/plugins/workflow/log/LogStorage.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 184-192 are not covered by tests
}

/**
* Wraps the specified {@link OutputStream} with a {@link BuildListener} that automatically buffers and flushes
* remote writes.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,13 @@

package org.jenkinsci.plugins.workflow.log;

import hudson.ExtensionPoint;
import edu.umd.cs.findbugs.annotations.CheckForNull;
import edu.umd.cs.findbugs.annotations.NonNull;
import hudson.DescriptorExtensionList;
import hudson.model.Describable;
import hudson.model.Descriptor;
import java.util.List;
import jenkins.model.Jenkins;
import org.jenkinsci.plugins.workflow.flow.FlowExecutionOwner;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.Beta;
Expand All @@ -35,7 +39,7 @@
* Factory interface for {@link LogStorage}.
*/
@Restricted(Beta.class)
public interface LogStorageFactory extends ExtensionPoint {
public interface LogStorageFactory extends Describable<LogStorageFactory> {

/**
* Checks whether we should handle a given build.
Expand All @@ -44,4 +48,11 @@ public interface LogStorageFactory extends ExtensionPoint {
*/
@CheckForNull LogStorage forBuild(@NonNull FlowExecutionOwner b);

default LogStorageFactoryDescriptor<?> getDescriptor() {
return (LogStorageFactoryDescriptor<?>) Jenkins.get().getDescriptorOrDie(this.getClass());
}

static List<LogStorageFactoryDescriptor<?>> all() {
return Jenkins.get().getDescriptorList(LogStorageFactory.class);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package org.jenkinsci.plugins.workflow.log;

import hudson.model.Descriptor;

public abstract class LogStorageFactoryDescriptor<T extends LogStorageFactory> extends Descriptor<LogStorageFactory> {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package org.jenkinsci.plugins.workflow.log.configuration;

import hudson.Extension;
import hudson.ExtensionList;
import java.util.List;
import java.util.logging.Logger;
import jenkins.model.GlobalConfiguration;
import jenkins.model.Jenkins;
import org.jenkinsci.Symbol;
import org.jenkinsci.plugins.workflow.log.LogStorageFactory;
import org.jenkinsci.plugins.workflow.log.LogStorageFactoryDescriptor;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.Beta;
import org.kohsuke.stapler.DataBoundSetter;

@Extension
@Symbol("pipelineLogging")
@Restricted(Beta.class)
public class PipelineLoggingGlobalConfiguration extends GlobalConfiguration {
private static final Logger LOGGER = Logger.getLogger(PipelineLoggingGlobalConfiguration.class.getName());
private LogStorageFactory factory;

public PipelineLoggingGlobalConfiguration() {
load();
}

public LogStorageFactory getFactory() {
return factory;
}

@DataBoundSetter
public void setFactory(LogStorageFactory factory) {
this.factory = factory;
save();
}

public List<LogStorageFactoryDescriptor<?>> getLogStorageFactoryDescriptors() {
return LogStorageFactory.all();
}

public LogStorageFactoryDescriptor<?> getDefaultLogStorageFactoryDescriptor() {
return (LogStorageFactoryDescriptor<?>) Jenkins.get().getDescriptor("file");
}

public static PipelineLoggingGlobalConfiguration get() {
return ExtensionList.lookupSingleton(PipelineLoggingGlobalConfiguration.class);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package org.jenkinsci.plugins.workflow.log.tee;

import edu.umd.cs.findbugs.annotations.NonNull;
import hudson.model.BuildListener;
import hudson.model.TaskListener;
import java.io.OutputStream;
import java.io.PrintStream;
import java.io.Serial;
import java.util.List;
import java.util.stream.Stream;
import org.jenkinsci.plugins.workflow.log.OutputStreamTaskListener;

class TeeBuildListener implements BuildListener, OutputStreamTaskListener, AutoCloseable {

@Serial
private static final long serialVersionUID = 1L;

private final transient TeeLogStorage teeLogStorage;

private final BuildListener primary;

private final List<BuildListener> secondaries;

private transient OutputStream outputStream;

private transient PrintStream printStream;

TeeBuildListener(TeeLogStorage teeLogStorage, BuildListener primary, BuildListener... secondaries) {
if (!(primary instanceof OutputStreamTaskListener)) {

Check warning on line 29 in src/main/java/org/jenkinsci/plugins/workflow/log/tee/TeeBuildListener.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 29 is only partially covered, one branch is missing
throw new ClassCastException("Primary is not an instance of OutputStreamTaskListener: " + primary);

Check warning on line 30 in src/main/java/org/jenkinsci/plugins/workflow/log/tee/TeeBuildListener.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 30 is not covered by tests
}
List.of(secondaries).forEach(secondary -> {
if (!(secondary instanceof OutputStreamTaskListener)) {

Check warning on line 33 in src/main/java/org/jenkinsci/plugins/workflow/log/tee/TeeBuildListener.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 33 is only partially covered, one branch is missing
throw new ClassCastException("Secondary is not an instance of OutputStreamTaskListener: " + secondary);

Check warning on line 34 in src/main/java/org/jenkinsci/plugins/workflow/log/tee/TeeBuildListener.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 34 is not covered by tests
}
});
this.teeLogStorage = teeLogStorage;
this.primary = primary;
this.secondaries = List.of(secondaries);
}

TeeBuildListener(TeeLogStorage teeLogStorage, TaskListener primary, TaskListener... secondaries) {
this(
teeLogStorage,
(BuildListener) primary,
Stream.of(secondaries)
.map(secondary -> (BuildListener) secondary)
.toArray(BuildListener[]::new));
}

@NonNull
@Override
public synchronized OutputStream getOutputStream() {
if (outputStream == null) {
outputStream = new TeeOutputStream(
teeLogStorage,
((OutputStreamTaskListener) primary).getOutputStream(),
secondaries.stream()
.map(secondary -> ((OutputStreamTaskListener) secondary).getOutputStream())
.toArray(OutputStream[]::new));
}
return outputStream;

Check warning on line 62 in src/main/java/org/jenkinsci/plugins/workflow/log/tee/TeeBuildListener.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 54-62 are not covered by tests
}

@NonNull
@Override
public synchronized PrintStream getLogger() {
if (printStream == null) {
printStream = new TeePrintStream(
teeLogStorage,
primary.getLogger(),
secondaries.stream().map(TaskListener::getLogger).toArray(PrintStream[]::new));
}
return printStream;
}

@Override
public void close() throws Exception {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to note: While I was looking at the code yesterday I realized the logic here is a bit confusing. We close the delegate TaskListeners, but we don't actually close the TeeOutputStream or the PrintStream returned by getLogger(). At first I thought maybe we could simplify by just calling getLogger().close(), which would eventually close the TeeOutputStream and we could get rid of the rest of the method, but at least BufferedBuildListener and CloudWatchSender have special close methods that do more than just closing the internal streams, so we really do need to try to close the TaskListeners themselves.

This means that after a call to close(), getLogger() and outputStream will still exist as unclosed streams even after their delegates have been been closed, which is a bit weird. We might be able to call getLogger().close() and then close the listeners, but I can't remember if any of the relevant streams have problems with being closed twice. I would maybe look into this a bit just to make sure whether the TeeOutputStream not being closed is ok.

Copy link
Author

@jgreffe jgreffe Jul 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I update with something like:

        Exception exception = null;
        if (outputStream != null) {
            try {
                outputStream.close();
            } catch (IOException e) {
                exception = e;
            }
        }

then TeeLogStorageTest#smokes fails with:

org.opentest4j.MultipleFailuresError: Multiple Failures (2 failures)
	org.junit.ComparisonFailure: expected:<[<a href='http://nowhere.net/'>nikde</a>
]> but was:<[]>
	java.lang.AssertionError: 
Expected: is "starting\none #1\ntwo #1\ntwo #2\ninterrupting\none #2\none #3\npausing\nresuming\none #4\nthree #1\nending\n"
     but: was "starting\none #1\ntwo #1\ntwo #2\ninterrupting\none #2\none #3\npausing\nresuming\none #4\nthree #1\nending\nha:////4AM+Xbq6l0DXt+Aa5ectJ4m2Ny0f1G1cNAXESjnHxoh7AAAAlB+LCAAAAAAAAP9b85aBtbiIQSajNKU4P08vOT+vOD8nVc+jsiC1KCczL9svvyTVzHb1RttJBUeZGJg8GdhyUvPSSzJ8GJhLi3JKGIR8shLLEvVzEvPS9YNLijLz0q0rihik0IxzhtAgwxgggJGJgaGiAMhgLWEQzigpKbDS18/LL89ILUrVy0st0QcAFd2f8JgAAAA=nikde\n"
	...
	Suppressed: org.junit.ComparisonFailure: expected:<[<a href='http://nowhere.net/'>nikde</a>
]> but was:<[]>
		at org.jenkinsci.plugins.workflow.log.LogStorageTestBase.assertLog(LogStorageTestBase.java:324)
		at org.jenkinsci.plugins.workflow.log.LogStorageTestBase.assertStepLog(LogStorageTestBase.java:305)
		at org.jenkinsci.plugins.workflow.log.LogStorageTestBase.smokes(LogStorageTestBase.java:155)

Copy link
Member

@dwnusbaum dwnusbaum Jul 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you specifically need to use getLogger().close(), not outputStream.close, maybe so that the PrintStream flushes its buffer and the stream, but I am not exactly sure why the former works but the latter does not without investigating more deeply. If things are ok without closing the PrintStream or TeeOutputStream itself that might be fine too, I would just check to make sure everything is getting GC'd as expected. Maybe there could be a difference in behavior if you tested writing lines without newlines or something like that, but I do not know.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Author

@jgreffe jgreffe Jul 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But now with the additional getLogger().close(), the logger is flushed and closed, making TeeOutputStreamTest#primary_fails_close fail, and the close() method is called twice:

Breakpoint reached
	at org.jenkinsci.plugins.workflow.log.tee.TeeOutputStreamTest$4.close(TeeOutputStreamTest.java:118)
	at org.jenkinsci.plugins.workflow.log.tee.RemoteCustomFileLogStorage$Writer.close(RemoteCustomFileLogStorage.java:138)
	at org.jenkinsci.plugins.workflow.log.tee.TeeOutputStream$$Lambda/0x000000d80168a838.apply(Unknown Source:-1)
	at org.jenkinsci.plugins.workflow.log.tee.TeeOutputStream.handleAction(TeeOutputStream.java:45)
	at org.jenkinsci.plugins.workflow.log.tee.TeeOutputStream.close(TeeOutputStream.java:34)
	at java.io.PrintStream.implClose(PrintStream.java:500)
	at java.io.PrintStream.close(PrintStream.java:484)
	at org.jenkinsci.plugins.workflow.log.tee.TeeBuildListener.close(TeeBuildListener.java:51)
...
Breakpoint reached
	at org.jenkinsci.plugins.workflow.log.tee.TeeOutputStreamTest$4.close(TeeOutputStreamTest.java:118)
	at org.jenkinsci.plugins.workflow.log.tee.RemoteCustomFileLogStorage$Writer.close(RemoteCustomFileLogStorage.java:138)
	at org.jenkinsci.plugins.workflow.log.tee.RemoteCustomFileLogStorage$MyListener.close(RemoteCustomFileLogStorage.java:99)
	at org.jenkinsci.plugins.workflow.log.tee.TeeBuildListener.close(TeeBuildListener.java:55)
	at org.jenkinsci.plugins.workflow.log.tee.TeeOutputStreamTest.primary_fails_close(TeeOutputStreamTest.java:123)
	at java.lang.invoke.LambdaForm$DMH/0x000000d801218c00.invokeVirtual(LambdaForm$DMH:-1)
	at java.lang.invoke.LambdaForm$MH/0x000000d801324000.invoke(LambdaForm$MH:-1)
	at java.lang.invoke.Invokers$Holder.invokeExact_MT(Invokers$Holder:-1)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, sorry, I was not saying that we definitely need it, only that if we do need it, we need to close the full PrintStream, not just the OutputStream, and yes it means the stream will be closed twice as I mentioned in #417 (comment). It's just not clear to me whether it is preferable to leave the outer streams open and just let them be cleaned up by GC after the delegate TaskListeners are closed, or to close the PrintStream, causing the TeeOutputStreamTest stream to be closed twice. I will try to investigate in more detail later today.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, so now we close the stream twice, but everything seems to be ok IIUC. @jgreffe did you investigate to see if you prefer the behavior with the current code or how you had things prior to adding line 51?

Exception exception = null;
if (primary instanceof AutoCloseable) {

Check warning on line 80 in src/main/java/org/jenkinsci/plugins/workflow/log/tee/TeeBuildListener.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 80 is only partially covered, one branch is missing
try {
((AutoCloseable) primary).close();
} catch (Exception e) {
exception = e;

Check warning on line 84 in src/main/java/org/jenkinsci/plugins/workflow/log/tee/TeeBuildListener.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 83-84 are not covered by tests
}
}
for (BuildListener secondary : secondaries) {
if (secondary instanceof AutoCloseable) {

Check warning on line 88 in src/main/java/org/jenkinsci/plugins/workflow/log/tee/TeeBuildListener.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 88 is only partially covered, one branch is missing
try {
((AutoCloseable) secondary).close();
} catch (Exception e) {
if (exception == null) {
exception = e;
} else {
exception.addSuppressed(e);

Check warning on line 95 in src/main/java/org/jenkinsci/plugins/workflow/log/tee/TeeBuildListener.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 91-95 are not covered by tests
}
}
}
}
if (exception != null) {

Check warning on line 100 in src/main/java/org/jenkinsci/plugins/workflow/log/tee/TeeBuildListener.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 100 is only partially covered, one branch is missing
throw exception;

Check warning on line 101 in src/main/java/org/jenkinsci/plugins/workflow/log/tee/TeeBuildListener.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 101 is not covered by tests
}
}
}
Loading