diff --git a/allure-okhttp3/README.md b/allure-okhttp3/README.md
index 76b1ea8e..62dc34e9 100644
--- a/allure-okhttp3/README.md
+++ b/allure-okhttp3/README.md
@@ -8,7 +8,7 @@ Use this module when your tests or test clients use OkHttp and you want request,
- Allure Java 3.x requires Java 17 or newer.
- This module targets the OkHttp `okhttp3` API used by OkHttp 3, 4, and 5.
-- The current build validates against OkHttp 5.4.0.
+- The current build validates against OkHttp 5.5.0.
## Installation
@@ -42,6 +42,21 @@ OkHttpClient client = new OkHttpClient.Builder()
.build();
```
+## Server-Sent Events
+
+OkHttp invokes server-sent event callbacks on reusable dispatcher threads. Use the Allure event-source factory so
+steps and HTTP exchange attachments stay with the test or fixture that opens each source:
+
+```java
+EventSource.Factory eventSourceFactory = AllureEventSources.createFactory(client);
+
+EventSource eventSource = eventSourceFactory.newEventSource(request, listener);
+```
+
+The returned factory is reusable. It captures context when `newEventSource` is called, so the client, factory, request,
+and listener may all be constructed before the owning test or fixture starts. When no Allure test or fixture is
+current, callbacks run without an Allure owner instead of inheriting stale context from an OkHttp dispatcher thread.
+
## Report Output
- Request method, URL, headers, and body when available.
diff --git a/allure-okhttp3/build.gradle.kts b/allure-okhttp3/build.gradle.kts
index 33f1c669..366b2574 100644
--- a/allure-okhttp3/build.gradle.kts
+++ b/allure-okhttp3/build.gradle.kts
@@ -5,6 +5,7 @@ val okhttpVersion = "5.5.0"
dependencies {
api(project(":allure-java-commons"))
compileOnly("com.squareup.okhttp3:okhttp:$okhttpVersion")
+ compileOnly("com.squareup.okhttp3:okhttp-sse:$okhttpVersion")
testImplementation("org.wiremock:wiremock")
testImplementation("com.squareup.okhttp3:okhttp:$okhttpVersion")
testImplementation("com.squareup.okhttp3:okhttp-sse:$okhttpVersion")
diff --git a/allure-okhttp3/src/main/java/io/qameta/allure/okhttp3/AllureEventSources.java b/allure-okhttp3/src/main/java/io/qameta/allure/okhttp3/AllureEventSources.java
new file mode 100644
index 00000000..e9348d86
--- /dev/null
+++ b/allure-okhttp3/src/main/java/io/qameta/allure/okhttp3/AllureEventSources.java
@@ -0,0 +1,101 @@
+/*
+ * Copyright 2016-2026 Qameta Software Inc
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.qameta.allure.okhttp3;
+
+import io.qameta.allure.AllureThreadBinding;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+import okhttp3.sse.EventSource;
+import okhttp3.sse.EventSourceListener;
+import okhttp3.sse.EventSources;
+
+import java.util.Objects;
+
+/**
+ * Allure-aware server-sent event source factories for OkHttp.
+ */
+public final class AllureEventSources {
+
+ private AllureEventSources() {
+ throw new IllegalStateException("Do not instance");
+ }
+
+ /**
+ * Creates a reusable event source factory that captures the current Allure test or fixture when each event source
+ * is opened. The captured context is restored for the HTTP interceptor and every listener callback.
+ *
+ *
The client, factory, request, and listener may be created before a test or fixture starts. Context is captured
+ * only when {@link EventSource.Factory#newEventSource(Request, EventSourceListener)} is called.
+ *
+ * @param client the OkHttp client
+ * @return the context-aware event source factory
+ */
+ public static EventSource.Factory createFactory(final OkHttpClient client) {
+ final EventSource.Factory delegate = EventSources.createFactory(Objects.requireNonNull(client, "client"));
+ return (request, listener) -> {
+ final AllureOkHttp3Context context = AllureOkHttp3Context.capture();
+ final Request contextRequest = Objects.requireNonNull(request, "request")
+ .newBuilder()
+ .tag(AllureOkHttp3Context.class, context)
+ .build();
+ final EventSourceListener contextListener = new ContextEventSourceListener(
+ Objects.requireNonNull(listener, "listener"),
+ context
+ );
+ return delegate.newEventSource(contextRequest, contextListener);
+ };
+ }
+
+ private static final class ContextEventSourceListener extends EventSourceListener {
+
+ private final EventSourceListener delegate;
+ private final AllureOkHttp3Context context;
+
+ private ContextEventSourceListener(final EventSourceListener delegate, final AllureOkHttp3Context context) {
+ this.delegate = delegate;
+ this.context = context;
+ }
+
+ @Override
+ public void onOpen(final EventSource eventSource, final Response response) {
+ runInContext(() -> delegate.onOpen(eventSource, response));
+ }
+
+ @Override
+ public void onEvent(final EventSource eventSource, final String id,
+ final String type, final String data) {
+ runInContext(() -> delegate.onEvent(eventSource, id, type, data));
+ }
+
+ @Override
+ public void onClosed(final EventSource eventSource) {
+ runInContext(() -> delegate.onClosed(eventSource));
+ }
+
+ @Override
+ public void onFailure(final EventSource eventSource, final Throwable throwable,
+ final Response response) {
+ runInContext(() -> delegate.onFailure(eventSource, throwable, response));
+ }
+
+ private void runInContext(final Runnable callback) {
+ try (AllureThreadBinding ignored = context.bind()) {
+ callback.run();
+ }
+ }
+ }
+}
diff --git a/allure-okhttp3/src/main/java/io/qameta/allure/okhttp3/AllureOkHttp3.java b/allure-okhttp3/src/main/java/io/qameta/allure/okhttp3/AllureOkHttp3.java
index 7dbecba5..5e8bd5a5 100644
--- a/allure-okhttp3/src/main/java/io/qameta/allure/okhttp3/AllureOkHttp3.java
+++ b/allure-okhttp3/src/main/java/io/qameta/allure/okhttp3/AllureOkHttp3.java
@@ -16,6 +16,7 @@
package io.qameta.allure.okhttp3;
import io.qameta.allure.Allure;
+import io.qameta.allure.AllureThreadBinding;
import io.qameta.allure.http.HttpExchange;
import io.qameta.allure.http.HttpExchangeBody;
import io.qameta.allure.http.HttpExchangeError;
@@ -63,13 +64,23 @@ public AllureOkHttp3 configureHttpExchange(final Consumer
*/
@Override
public Response intercept(final Chain chain) throws IOException {
+ final Request request = chain.request();
+ final AllureOkHttp3Context context = request.tag(AllureOkHttp3Context.class);
+ if (Objects.isNull(context)) {
+ return interceptInCurrentContext(chain, request);
+ }
+ try (AllureThreadBinding ignored = context.bind()) {
+ return interceptInCurrentContext(chain, request);
+ }
+ }
+
+ private Response interceptInCurrentContext(final Chain chain, final Request request) throws IOException {
// enrichment-only integration: pass the call through untouched when no executable is
// running — no warnings, no request/response body buffering
if (Allure.getLifecycle().getCurrentExecutableKey().isEmpty()) {
- return chain.proceed(chain.request());
+ return chain.proceed(request);
}
final long start = System.currentTimeMillis();
- final Request request = chain.request();
final HttpExchangeRequest.Builder requestBuilder = HttpExchangeRequest
.builder(request.method(), request.url().toString())
.addHeaders(toNameValues(request.headers().toMultimap()));
diff --git a/allure-okhttp3/src/main/java/io/qameta/allure/okhttp3/AllureOkHttp3Context.java b/allure-okhttp3/src/main/java/io/qameta/allure/okhttp3/AllureOkHttp3Context.java
new file mode 100644
index 00000000..e93eeb26
--- /dev/null
+++ b/allure-okhttp3/src/main/java/io/qameta/allure/okhttp3/AllureOkHttp3Context.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2016-2026 Qameta Software Inc
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.qameta.allure.okhttp3;
+
+import io.qameta.allure.Allure;
+import io.qameta.allure.AllureExternalKey;
+import io.qameta.allure.AllureLifecycle;
+import io.qameta.allure.AllureThreadBinding;
+
+final class AllureOkHttp3Context {
+
+ private static final AllureExternalKey NO_OWNER = AllureExternalKey.random(AllureOkHttp3Context.class);
+
+ private final AllureLifecycle lifecycle;
+ private final AllureExternalKey owner;
+
+ private AllureOkHttp3Context(final AllureLifecycle lifecycle, final AllureExternalKey owner) {
+ this.lifecycle = lifecycle;
+ this.owner = owner;
+ }
+
+ static AllureOkHttp3Context capture() {
+ final AllureLifecycle lifecycle = Allure.getLifecycle();
+ final AllureExternalKey owner = lifecycle.getCurrentRootKey().orElse(NO_OWNER);
+ return new AllureOkHttp3Context(lifecycle, owner);
+ }
+
+ AllureThreadBinding bind() {
+ return lifecycle.bindDetached(owner);
+ }
+}
diff --git a/allure-okhttp3/src/test/java/io/qameta/allure/okhttp3/AllureOkHttp3SseTest.java b/allure-okhttp3/src/test/java/io/qameta/allure/okhttp3/AllureOkHttp3SseTest.java
index bbea491c..8ca7e33b 100644
--- a/allure-okhttp3/src/test/java/io/qameta/allure/okhttp3/AllureOkHttp3SseTest.java
+++ b/allure-okhttp3/src/test/java/io/qameta/allure/okhttp3/AllureOkHttp3SseTest.java
@@ -16,7 +16,16 @@
package io.qameta.allure.okhttp3;
import com.sun.net.httpserver.HttpServer;
+import io.qameta.allure.Allure;
+import io.qameta.allure.AllureExternalKey;
+import io.qameta.allure.AllureLifecycle;
+import io.qameta.allure.Description;
+import io.qameta.allure.Issue;
+import io.qameta.allure.model.StepResult;
+import io.qameta.allure.model.TestResult;
+import io.qameta.allure.test.AllureResults;
import io.qameta.allure.test.IsolatedLifecycle;
+import okhttp3.Dispatcher;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.sse.EventSource;
@@ -32,10 +41,15 @@
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Objects;
+import java.util.UUID;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import static io.qameta.allure.test.RunUtils.runTests;
import static io.qameta.allure.test.RunUtils.runWithinTestContext;
import static org.assertj.core.api.Assertions.assertThat;
@@ -51,6 +65,8 @@ class AllureOkHttp3SseTest {
private static final List EVENTS = List.of("first", "second");
+ private static final String FINITE_EVENT = "context event";
+
private HttpServer server;
private final CountDownLatch connectionHold = new CountDownLatch(1);
@@ -74,6 +90,14 @@ void setUp() throws IOException {
}
}
});
+ server.createContext("/finite-sse", exchange -> {
+ exchange.getResponseHeaders().add("Content-Type", "text/event-stream");
+ exchange.sendResponseHeaders(200, 0);
+ try (OutputStream os = exchange.getResponseBody()) {
+ os.write(("data: " + FINITE_EVENT + "\n\n").getBytes(StandardCharsets.UTF_8));
+ os.flush();
+ }
+ });
server.start();
}
@@ -129,4 +153,166 @@ public void onFailure(final EventSource eventSource, final Throwable t,
assertThat(received).containsExactlyElementsOf(EVENTS);
}
+
+ /**
+ * Verifies that callbacks and HTTP exchanges from a reused OkHttp dispatcher thread report to the test that opens
+ * each event source, even when the shared client, factory, request, and listeners were all created earlier.
+ */
+ @Description
+ @Issue("1036")
+ @Test
+ void shouldCaptureContextWhenOpeningEventSourceWithSharedObjects() {
+ final ExecutorService dispatcherExecutor = Executors.newSingleThreadExecutor();
+ final OkHttpClient client = new OkHttpClient.Builder()
+ .dispatcher(new Dispatcher(dispatcherExecutor))
+ .addInterceptor(new AllureOkHttp3())
+ .build();
+ final EventSource.Factory eventSourceFactory = AllureEventSources.createFactory(client);
+ final Request request = finiteSseRequest();
+ final SseProbe firstProbe = new SseProbe("event received by first test");
+ final SseProbe secondProbe = new SseProbe("event received by second test");
+
+ try {
+ final AllureResults results = runTests(lifecycle -> {
+ final AllureExternalKey firstTestKey = scheduleTest(lifecycle, "first SSE test");
+ final AllureExternalKey secondTestKey = scheduleTest(lifecycle, "second SSE test");
+
+ lifecycle.startTest(firstTestKey);
+ receiveEvent(eventSourceFactory, request, firstProbe);
+ lifecycle.stopTest(firstTestKey);
+
+ lifecycle.startTest(secondTestKey);
+ receiveEvent(eventSourceFactory, request, secondProbe);
+ lifecycle.stopTest(secondTestKey);
+
+ lifecycle.writeTest(firstTestKey);
+ lifecycle.writeTest(secondTestKey);
+ });
+
+ final TestResult firstTest = results.getTestResultByName("first SSE test");
+ final TestResult secondTest = results.getTestResultByName("second SSE test");
+
+ assertThat(firstTest.getSteps())
+ .extracting(StepResult::getName)
+ .filteredOn(AllureOkHttp3SseTest::isSseEvidenceStep)
+ .containsExactly("HTTP exchange", "event received by first test");
+ assertThat(secondTest.getSteps())
+ .extracting(StepResult::getName)
+ .filteredOn(AllureOkHttp3SseTest::isSseEvidenceStep)
+ .containsExactly("HTTP exchange", "event received by second test");
+ } finally {
+ dispatcherExecutor.shutdownNow();
+ }
+ }
+
+ /**
+ * Verifies that an event source opened without a running Allure test or fixture cannot reuse context retained by
+ * an OkHttp dispatcher thread from an earlier request.
+ */
+ @Description
+ @Issue("1036")
+ @Test
+ void shouldSuppressStaleDispatcherContextForUnownedEventSource() {
+ final ExecutorService dispatcherExecutor = Executors.newSingleThreadExecutor();
+ final OkHttpClient client = new OkHttpClient.Builder()
+ .dispatcher(new Dispatcher(dispatcherExecutor))
+ .addInterceptor(new AllureOkHttp3())
+ .build();
+ final EventSource.Factory eventSourceFactory = AllureEventSources.createFactory(client);
+ final Request request = finiteSseRequest();
+ final SseProbe ownedProbe = new SseProbe("event received by owned test");
+ final SseProbe unownedProbe = new SseProbe("event received without owner");
+
+ try {
+ final AllureResults results = runTests(lifecycle -> {
+ final AllureExternalKey testKey = scheduleTest(lifecycle, "owning SSE test");
+
+ lifecycle.startTest(testKey);
+ receiveEvent(eventSourceFactory, request, ownedProbe);
+ lifecycle.stopTest(testKey);
+
+ receiveEvent(eventSourceFactory, request, unownedProbe);
+ lifecycle.writeTest(testKey);
+ });
+
+ final TestResult owningTest = results.getTestResultByName("owning SSE test");
+
+ assertThat(owningTest.getSteps())
+ .extracting(StepResult::getName)
+ .filteredOn(AllureOkHttp3SseTest::isSseEvidenceStep)
+ .containsExactly("HTTP exchange", "event received by owned test");
+ } finally {
+ dispatcherExecutor.shutdownNow();
+ }
+ }
+
+ private static AllureExternalKey scheduleTest(final AllureLifecycle lifecycle, final String name) {
+ final AllureExternalKey key = AllureExternalKey.random(AllureOkHttp3SseTest.class);
+ lifecycle.scheduleTest(key, new TestResult().setUuid(UUID.randomUUID().toString()).setName(name));
+ return key;
+ }
+
+ private Request finiteSseRequest() {
+ return new Request.Builder()
+ .url("http://localhost:" + server.getAddress().getPort() + "/finite-sse")
+ .build();
+ }
+
+ private static void receiveEvent(final EventSource.Factory eventSourceFactory, final Request request,
+ final SseProbe probe) {
+ eventSourceFactory.newEventSource(request, probe);
+ probe.await();
+ }
+
+ private static boolean isSseEvidenceStep(final String name) {
+ return "HTTP exchange".equals(name) || name.startsWith("event received");
+ }
+
+ private static final class SseProbe extends EventSourceListener {
+
+ private final String stepName;
+ private final CountDownLatch eventReceived = new CountDownLatch(1);
+ private final CountDownLatch eventSourceClosed = new CountDownLatch(1);
+ private final AtomicReference failure = new AtomicReference<>();
+
+ private SseProbe(final String stepName) {
+ this.stepName = stepName;
+ }
+
+ @Override
+ public void onEvent(final EventSource eventSource, final String id,
+ final String type, final String data) {
+ Allure.step(stepName);
+ eventReceived.countDown();
+ }
+
+ @Override
+ public void onClosed(final EventSource eventSource) {
+ eventSourceClosed.countDown();
+ }
+
+ @Override
+ public void onFailure(final EventSource eventSource, final Throwable throwable,
+ final okhttp3.Response response) {
+ failure.set(throwable);
+ eventSourceClosed.countDown();
+ }
+
+ private void await() {
+ try {
+ assertThat(eventReceived.await(5, TimeUnit.SECONDS))
+ .as("SSE event received")
+ .isTrue();
+ assertThat(eventSourceClosed.await(5, TimeUnit.SECONDS))
+ .as("SSE event source closed")
+ .isTrue();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new AssertionError("Interrupted while waiting for the SSE callback", e);
+ }
+ assertThat(failure.get())
+ .as("SSE callback failure")
+ .isNull();
+ }
+ }
}
diff --git a/allure-okhttp3/src/test/resources/allure.properties b/allure-okhttp3/src/test/resources/allure.properties
index a835d79e..b68f37af 100644
--- a/allure-okhttp3/src/test/resources/allure.properties
+++ b/allure-okhttp3/src/test/resources/allure.properties
@@ -1,3 +1,4 @@
allure.results.directory=build/allure-results
+allure.link.issue.pattern=https://github.com/allure-framework/allure-java/issues/{}
allure.label.epic=#project.description#
allure.label.module=allure-okhttp3