Skip to content

Allow multiple LogStorage with primary and secondaries #417

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 25 commits into
base: master
Choose a base branch
from

Conversation

jgreffe
Copy link

@jgreffe jgreffe commented Jul 15, 2025

This is a follow-up of #406 (comment).
This PR aims to allow defining multiple log storages, one primary for all reads/writes and secondaries for writes only.

This changes the contract of LogStorageFactory interface by:

  • removing the ExtensionPoint
  • and replacing with Describable<LogStorageFactory>, which allows to configure the log storages through a dropdown. When TeeLogStorageFactory is selected, one can choose mandatory primary and secondary through dropdowns.
Monosnap.screencast.2025-07-18.15-01-23.mp4

Downstream changes will be listed here when available.

Testing done

Submitter checklist

  • Make sure you are opening from a topic/feature/bugfix branch (right side) and not your main branch!
  • Ensure that the pull request title represents the desired changelog entry
  • Please describe what you did
  • Link to relevant issues in GitHub or Jira
  • Link to relevant pull requests, esp. upstream and downstream changes
  • Ensure you have provided tests that demonstrate the feature works or the issue is fixed

@@ -35,13 +38,21 @@
* 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.
* @param b a build about to start
* @return a mechanism for handling this build, or null to fall back to the next implementation or the default
Copy link
Member

Choose a reason for hiding this comment

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

The Javadoc here is no longer accurate, but we need to decide how we want to change it. I think the expectation is that all factories are expected to handle all builds, explicitly falling back to FileLogStorage or BrokenLogStorage as necessary. We can think about this as we look into migrating the existing implementations.

Comment on lines 11 to 13
/**
* Foundation for compliance tests of {@link TeeLogStorage} implementations.
*/
Copy link
Member

Choose a reason for hiding this comment

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

With the current approach, nothing else would implement TeeLogStorage, so I would just inline all of this into TeeLogStorageTest.

Copy link
Author

@jgreffe jgreffe Jul 17, 2025

Choose a reason for hiding this comment

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

I would expect downstream plugins to have a test extending the current one and implement primaryStorage() ?

Copy link
Member

@dwnusbaum dwnusbaum Jul 17, 2025

Choose a reason for hiding this comment

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

I'm not sure. I don't think we really expect any implementation to have a problem as a primary. If there is a problem, it's probably with TeeLogStorage itself, or with code that won't be covered by these tests, for example CPE will need to adjust code that checks the LogStorageFactory implementation, but that's not going to be covered here since it is only testing at the LogStorage layer. The downstream plugins may also want special tests for UI-related stuff if we figure out how to get Descriptor check methods working, but that would also be separate. So IDK, maybe we can leave it like this for now and see as we refactor the other plugins whether this catches any bugs. If not, we can inline it.


@Extension
@Symbol("teeLogStorageFactory")
public static final class DescriptorImpl extends Descriptor<LogStorageFactory> {
Copy link
Member

Choose a reason for hiding this comment

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

I think we might want a LogStorageFactoryDescriptor type with a few special methods, like doCheckSecondaries(List<...> secondaries), doCheckPrimary(... primary), or whatever, so that implementations can reject unsupported configurations, e.g. trying to use the CPE factory and FileLogStorageFactory at the same time is no good because they both use log, or trying to use the CPE factory as a secondary is pointless since CPE wouldn't actually work, etc.

Copy link
Author

Choose a reason for hiding this comment

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

Looking into..

Copy link
Author

@jgreffe jgreffe Jul 18, 2025

Choose a reason for hiding this comment

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

Please see dd6e930.
The new Descriptor doesn't provide specific methods yet because I need to find a way to have some server-side validation with <f:dropdownDescriptorSelector>. The default doCheckXyz doesn't work, see dropdownDescriptorSelector.
Tried:

  • with an additional boolean enabled and a doCheckEnabled(@QueryParam boolean enabled, @QueryParam String primary) but primary is always null
  • by overriding configure(StaplerRequest2 req, JSONObject json) in TeeLogStorage.DescriptorImpl but it's not called
  • adding some custom javascript, but <f:dropdownDescriptorSelector> doesn't accept onchange event
  • replacing <f:dropdownDescriptorSelector> with a <f:select>, but each item value is a String and therefore doesn't properly bindJSON, even when trying to set item values with a string representation of {"$class": "a log storage class name"}

Comment on lines 7 to 16
<f:dropdownDescriptorSelector
field="primary" title="${%Primary}"
descriptors="${descriptor.getFilteredDescriptors()}"
default="${descriptor.getDefaultLogStorageFactoryDescriptor()}"
/>
<f:dropdownDescriptorSelector
field="secondary" title="${%Secondary}"
descriptors="${descriptor.getFilteredDescriptors()}"
default="${descriptor.getDefaultLogStorageFactoryDescriptor()}"
/>
Copy link
Member

Choose a reason for hiding this comment

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

I don't know your exact use case, but maybe repeatableHeteroProperty would be a better fit (select multiple implementations and order them)

Copy link
Author

Choose a reason for hiding this comment

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

Yeah, this was used in a previous commit, but it seemed simpler to have only one primary and only one secondary at this point. But maybe additional checks are possible.

Copy link
Member

Choose a reason for hiding this comment

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

From recent discussions, there seem to be some factories which do not make sense as secondaries, so it would make sense to have two f:dropdownDescriptorSelectors as here, but with different getFilteredDescriptors calls.

Copy link
Author

Choose a reason for hiding this comment

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

Please see 9090904

@jgreffe jgreffe force-pushed the jgreffe/multiple-log-storage branch from 269b2ee to bd88176 Compare July 18, 2025 14:57
}

@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.

@jgreffe jgreffe force-pushed the jgreffe/multiple-log-storage branch from 2b775e6 to da22a49 Compare July 24, 2025 06:52
<f:dropdownDescriptorSelector
field="factory" title="${%Logging Factory}"
descriptors="${descriptor.getLogStorageFactoryDescriptors()}"
default="${descriptor.getDefaultFactoryDescriptor()}"
Copy link
Member

Choose a reason for hiding this comment

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

@jgreffe This is a bit notable because it means that the first time you install this update and save the "Configure System" page, the current default factory will be selected permanently, and so it will show up in things like CasC exports. It also means that even if you didn't specifically pick the setting, it will no longer be possible for a newly installed plugin to take over as the default once this has been saved once. You'll have to manually reconfigure things.

Is it possible to instead keep this as null in that case? That might be preferable, so that someone who is using FileLogStorage and installs a plugin with a custom LogStorageFactory has the new factory take effect automatically instead of having to manually reconfigure things. That said, the way you have it is probably how we would make it work if we were doing things today from scratch, so IDK. What do you think?

Copy link
Author

@jgreffe jgreffe Jul 30, 2025

Choose a reason for hiding this comment

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

Added an empty/null entry so users can still use the default behavior and avoid any configuration change, see 6d543ad

Monosnap.screencast.2025-07-30.10-43-52.mp4

Copy link
Author

Choose a reason for hiding this comment

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

How about using Jenkins.get().getPluginManager().whichPlugin(LogStorageFactory.getDefaultFactory().getClass() in order to display the plugin of the default factory?

Would give something like:

Monosnap System - Jenkins 2025-07-31 09-39-18 Monosnap System - Jenkins 2025-07-31 09-44-14

wdyt @dwnusbaum ?

Copy link
Author

Choose a reason for hiding this comment

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

If we go this route, could you check the label related to the plugin info @kellie-freeman? Please set it directly in this thread: https://github.com/jenkinsci/workflow-api-plugin/pull/417/files#r2244648496 🙏🏼

Copy link
Member

Choose a reason for hiding this comment

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

Sure, that seems fine. In general I think we'll need to go through all of this with Kellie to refine the user-facing text and symbols. For example IDK if we really want to refer to these as "factories" and "tee" might not be the clearest way to describe that mode for users.

@@ -0,0 +1 @@
description=Define the logging factory to use for pipeline logging. Without any explicit configuration, the default logging factory will be "{0}".
Copy link
Author

@jgreffe jgreffe Jul 31, 2025

Choose a reason for hiding this comment

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

Please check the label here @kellie-freeman 🙏🏼

Choose a reason for hiding this comment

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

As mentioned above, "logging factory" is a bit confusing to me. If this isn't an established term that customers will immediately recognize/understand, recommend updating slightly.

Suggested change
description=Define the logging factory to use for pipeline logging. Without any explicit configuration, the default logging factory will be "{0}".
description=Specify the Pipeline log storage location. The default location is "{0}".

or maybe

Suggested change
description=Define the logging factory to use for pipeline logging. Without any explicit configuration, the default logging factory will be "{0}".
description=Specify the Pipeline logger. The default logger is "{0}".

@@ -0,0 +1 @@
description=Controls the Tee Log Storage factories order. Primary allows read and writes. Secondary allows writes only.
Copy link
Author

Choose a reason for hiding this comment

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

Please check the label here @kellie-freeman 🙏🏼

Choose a reason for hiding this comment

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

  1. What is Tee Log Storage referring to? Based on a quick internet search, I’m guessing this? https://linuxcommandlibrary.com/man/tee
  2. Why are we using the term “factory/factories” in this context? Is this common terminology for logging methods or maybe this? https://refactoring.guru/design-patterns/factory-method
    It seems confusing to me. It looks like Jenkins may use the term "logger" instead?

Copy link
Member

@dwnusbaum dwnusbaum Jul 31, 2025

Choose a reason for hiding this comment

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

tee is a Unix shell utility that copies its input to its output while also copying the input to specified files. In our case we use the name tee just because the functionality is similar (we let you pick two log handlers and write all of the content to both). I think tee is a fine name for the code internally, but for the user facing text I think we should probably avoid it (maybe the help text could say something like "similar to the tee Unix utility").

As far as "factory/factories", Jenkins has an extensible system for reading and writing Pipeline build logs. In the code, each of these implementations is a "LogStorageFactory". The default implementation writes to a file in JENKINS_HOME. CPE has an implementation that also writes to a file but adds special metadata. opentelemetry and pipeline-cloudwatch-logs have implementations that send logs to OTel and Amazon CloudWatch Logs, respectively. This new "TeeLogStorageFactory" allows you to pick 2 of these, the first of which will handle all log reads and writes, and the second of which will just get a copy of all writes. I am not sure how best to name these in the UI. Maybe "Log handlers" would be clearer?

"Tee Log Storage Factory" could maybe be something like "Multiple log handlers" or "Primary log handler with writes copied to second handler", idk. I'll think about it as well.

Copy link

@kellie-freeman kellie-freeman left a comment

Choose a reason for hiding this comment

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

I added some suggestions for the strings, but probably didn't catch all instances. Overall, I think the use of "Tee" and "factories" is a bit confusing, so I added some possible alternatives. I'll be OOTO until August 11th, so I added several options in most cases, depending on the route we may take.

@@ -0,0 +1 @@
description=Controls the Tee Log Storage factories order. Primary allows read and writes. Secondary allows writes only.

Choose a reason for hiding this comment

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

  1. What is Tee Log Storage referring to? Based on a quick internet search, I’m guessing this? https://linuxcommandlibrary.com/man/tee
  2. Why are we using the term “factory/factories” in this context? Is this common terminology for logging methods or maybe this? https://refactoring.guru/design-patterns/factory-method
    It seems confusing to me. It looks like Jenkins may use the term "logger" instead?

@@ -0,0 +1 @@
description=Controls the Tee Log Storage factories order. Primary allows read and writes. Secondary allows writes only.
Copy link

@kellie-freeman kellie-freeman Jul 31, 2025

Choose a reason for hiding this comment

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

Suggested change
description=Controls the Tee Log Storage factories order. Primary allows read and writes. Secondary allows writes only.
description=Controls the log storage location order; <b>Primary</b> allows read and writes; <b>Secondary</b> allows writes only.

@@ -0,0 +1,12 @@
<?jelly escape-by-default='true'?>
<j:jelly xmlns:j="jelly:core" xmlns:f="/lib/form">
<f:section title="${%Pipeline Logging}">

Choose a reason for hiding this comment

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

If this isn't the correct location for this suggestion, then please disregard!

Suggested change
<f:section title="${%Pipeline Logging}">
<f:section title="${%Pipeline logging}">

${%description(descriptor.getDefaultFactoryDescriptor().getDisplayName())}
</f:block>
<f:dropdownDescriptorSelector
field="factory" title="${%Logging Factory}"

Choose a reason for hiding this comment

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

If this isn't the correct location for this suggestion, then please disregard!

Suggested change
field="factory" title="${%Logging Factory}"
field="factory" title="${%Log storage location}"

or

Suggested change
field="factory" title="${%Logging Factory}"
field="factory" title="${%Log storage}"

or

Suggested change
field="factory" title="${%Logging Factory}"
field="factory" title="${%Logger}"

@@ -0,0 +1 @@
description=Define the logging factory to use for pipeline logging. Without any explicit configuration, the default logging factory will be "{0}".

Choose a reason for hiding this comment

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

As mentioned above, "logging factory" is a bit confusing to me. If this isn't an established term that customers will immediately recognize/understand, recommend updating slightly.

Suggested change
description=Define the logging factory to use for pipeline logging. Without any explicit configuration, the default logging factory will be "{0}".
description=Specify the Pipeline log storage location. The default location is "{0}".

or maybe

Suggested change
description=Define the logging factory to use for pipeline logging. Without any explicit configuration, the default logging factory will be "{0}".
description=Specify the Pipeline logger. The default logger is "{0}".

@NonNull
@Override
public String getDisplayName() {
return "Remote custom file log storage factory";

Choose a reason for hiding this comment

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

Suggested change
return "Remote custom file log storage factory";
return "Remote custom log file storage";

@@ -0,0 +1,16 @@
<?jelly escape-by-default='true'?>
<j:jelly xmlns:j="jelly:core" xmlns:f="/lib/form">
<f:section title="${%Tee Log Storage Factory}">

Choose a reason for hiding this comment

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

I think "Tee" and "Factory" are confusing. Maybe:

Suggested change
<f:section title="${%Tee Log Storage Factory}">
<f:section title="${%Log storage location}">

or

Suggested change
<f:section title="${%Tee Log Storage Factory}">
<f:section title="${%Pipeline log storage location}">

or

Suggested change
<f:section title="${%Tee Log Storage Factory}">
<f:section title="${%Pipeline log storage}">

form.getFirstByXPath("//*[@id='pipeline-logging']/../descendant::select/option[@selected]/text()");
assertThat(selectedText, nullValue());
var description = form.getFirstByXPath("//*[@id='pipeline-logging']/../descendant::div[@colspan]/text()");
assertThat(description.toString(), containsString("My Custom Log"));

Choose a reason for hiding this comment

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

This suggestion applies to all instances of this string.

Suggested change
assertThat(description.toString(), containsString("My Custom Log"));
assertThat(description.toString(), containsString("My custom log"));

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants