diff --git a/java/src/org/openqa/selenium/grid/data/SessionClosedEvent.java b/java/src/org/openqa/selenium/grid/data/SessionClosedEvent.java index 276dc239cf565..c0016d5d63d33 100644 --- a/java/src/org/openqa/selenium/grid/data/SessionClosedEvent.java +++ b/java/src/org/openqa/selenium/grid/data/SessionClosedEvent.java @@ -17,6 +17,7 @@ package org.openqa.selenium.grid.data; +import java.util.Objects; import java.util.function.Consumer; import org.openqa.selenium.events.Event; import org.openqa.selenium.events.EventListener; @@ -26,15 +27,39 @@ public class SessionClosedEvent extends Event { + public static final String DEFAULT_REASON = "session-closed-event"; + private static final EventName SESSION_CLOSED = new EventName("session-closed"); public SessionClosedEvent(SessionId id) { - super(SESSION_CLOSED, id); + this(id, DEFAULT_REASON); + } + + public SessionClosedEvent(SessionId id, String reason) { + super(SESSION_CLOSED, new Data(id, reason)); } - public static EventListener listener(Consumer handler) { + public static EventListener listener(Consumer handler) { Require.nonNull("Handler", handler); - return new EventListener<>(SESSION_CLOSED, SessionId.class, handler); + return new EventListener<>(SESSION_CLOSED, Data.class, handler); + } + + public static class Data { + private final SessionId sessionId; + private final String reason; + + private Data(SessionId sessionId, String reason) { + this.sessionId = Require.nonNull("Session id", sessionId); + this.reason = Objects.requireNonNullElse(reason, DEFAULT_REASON); + } + + public SessionId getSessionId() { + return sessionId; + } + + public String getReason() { + return reason; + } } } diff --git a/java/src/org/openqa/selenium/grid/distributor/local/LocalGridModel.java b/java/src/org/openqa/selenium/grid/distributor/local/LocalGridModel.java index 1d6483faf6633..985d01f841fe2 100644 --- a/java/src/org/openqa/selenium/grid/distributor/local/LocalGridModel.java +++ b/java/src/org/openqa/selenium/grid/distributor/local/LocalGridModel.java @@ -71,7 +71,8 @@ public LocalGridModel(EventBus events) { this.events = Require.nonNull("Event bus", events); this.events.addListener(NodeDrainStarted.listener(nodeId -> setAvailability(nodeId, DRAINING))); - this.events.addListener(SessionClosedEvent.listener(this::release)); + this.events.addListener( + SessionClosedEvent.listener(event -> this.release(event.getSessionId()))); } public static LocalGridModel create(Config config) { diff --git a/java/src/org/openqa/selenium/grid/node/local/LocalNode.java b/java/src/org/openqa/selenium/grid/node/local/LocalNode.java index 91f52d16c31c5..0e026a3e65122 100644 --- a/java/src/org/openqa/selenium/grid/node/local/LocalNode.java +++ b/java/src/org/openqa/selenium/grid/node/local/LocalNode.java @@ -96,6 +96,7 @@ import org.openqa.selenium.grid.node.config.NodeOptions; import org.openqa.selenium.grid.node.docker.DockerSession; import org.openqa.selenium.grid.security.Secret; +import org.openqa.selenium.grid.sessionmap.SessionMap; import org.openqa.selenium.internal.Debug; import org.openqa.selenium.internal.Either; import org.openqa.selenium.internal.Require; @@ -321,6 +322,11 @@ private void stopTimedOutSession(SessionId id, SessionSlot slot, RemovalCause ca attributeMap.put("session.id", id.toString()); attributeMap.put("session.timeout_in_seconds", getSessionTimeout().toSeconds()); attributeMap.put("session.remove.cause", cause.name()); + String closeReason = + cause == RemovalCause.EXPIRED + ? SessionMap.REASON_SESSION_TIMEOUT + : SessionMap.REASON_SESSION_CLOSED_EVENT; + attributeMap.put("session.close.reason", closeReason); if (cause == RemovalCause.EXPIRED) { // Session is timing out, stopping it by sending a DELETE LOG.log(Level.INFO, () -> String.format("Session id %s timed out, stopping...", id)); @@ -342,7 +348,7 @@ private void stopTimedOutSession(SessionId id, SessionSlot slot, RemovalCause ca } } // Attempt to stop the session - slot.stop(); + slot.stop(closeReason); // Decrement pending sessions if Node is draining if (this.isDraining()) { int done = pendingSessions.decrementAndGet(); diff --git a/java/src/org/openqa/selenium/grid/node/local/SessionSlot.java b/java/src/org/openqa/selenium/grid/node/local/SessionSlot.java index 3c51b785c13c0..f68ddbf0b26a2 100644 --- a/java/src/org/openqa/selenium/grid/node/local/SessionSlot.java +++ b/java/src/org/openqa/selenium/grid/node/local/SessionSlot.java @@ -103,11 +103,12 @@ public ActiveSession getSession() { return currentSession; } - public void stop() { + public void stop(String reason) { if (isAvailable()) { return; } + String closeReason = Require.nonNull("Session close reason", reason); SessionId id = currentSession.getId(); try { currentSession.stop(); @@ -117,8 +118,8 @@ public void stop() { currentSession = null; connectionCounter.set(0); release(); - bus.fire(new SessionClosedEvent(id)); - LOG.info(String.format("Stopping session %s", id)); + bus.fire(new SessionClosedEvent(id, closeReason)); + LOG.info(String.format("Stopping session %s (reason: %s)", id, closeReason)); } @Override diff --git a/java/src/org/openqa/selenium/grid/router/Router.java b/java/src/org/openqa/selenium/grid/router/Router.java index d0e0ae362208f..0f3ec75a962ca 100644 --- a/java/src/org/openqa/selenium/grid/router/Router.java +++ b/java/src/org/openqa/selenium/grid/router/Router.java @@ -62,6 +62,7 @@ public Router( routes = combine( get("/status").to(() -> new GridStatusHandler(tracer, distributor)), + get("/se/grid/sessions/history").to(() -> new SessionHistoryHandler(tracer, sessions)), sessions.with(new SpanDecorator(tracer, req -> "session_map")), queue.with(new SpanDecorator(tracer, req -> "session_queue")), distributor.with(new SpanDecorator(tracer, req -> "distributor")), diff --git a/java/src/org/openqa/selenium/grid/router/SessionHistoryHandler.java b/java/src/org/openqa/selenium/grid/router/SessionHistoryHandler.java new file mode 100644 index 0000000000000..d61244c81dfc8 --- /dev/null +++ b/java/src/org/openqa/selenium/grid/router/SessionHistoryHandler.java @@ -0,0 +1,89 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you 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 org.openqa.selenium.grid.router; + +import static org.openqa.selenium.remote.http.Contents.asJson; +import static org.openqa.selenium.remote.tracing.HttpTracing.newSpanAsChildOf; +import static org.openqa.selenium.remote.tracing.Tags.HTTP_REQUEST; + +import com.google.common.collect.ImmutableMap; +import java.time.Instant; +import java.util.List; +import org.openqa.selenium.grid.sessionmap.SessionHistoryFilters; +import org.openqa.selenium.grid.sessionmap.SessionMap; +import org.openqa.selenium.grid.sessionmap.SessionMetadata; +import org.openqa.selenium.internal.Require; +import org.openqa.selenium.remote.http.HttpHandler; +import org.openqa.selenium.remote.http.HttpRequest; +import org.openqa.selenium.remote.http.HttpResponse; +import org.openqa.selenium.remote.tracing.Span; +import org.openqa.selenium.remote.tracing.Tracer; + +class SessionHistoryHandler implements HttpHandler { + + private final Tracer tracer; + private final SessionMap sessions; + + SessionHistoryHandler(Tracer tracer, SessionMap sessions) { + this.tracer = Require.nonNull("Tracer", tracer); + this.sessions = Require.nonNull("Session map", sessions); + } + + @Override + public HttpResponse execute(HttpRequest req) { + try (Span span = newSpanAsChildOf(tracer, req, "router.get_session_history")) { + HTTP_REQUEST.accept(span, req); + + SessionHistoryFilters filters; + try { + filters = SessionHistoryFilters.fromRequest(req); + } catch (IllegalArgumentException e) { + return new HttpResponse().setStatus(400).setContent(errorPayload(e.getMessage())); + } + + filters + .getSessionId() + .ifPresent(sessionId -> span.setAttribute("session.history.sessionId", sessionId.toString())); + filters + .getCloseReason() + .ifPresent(reason -> span.setAttribute("session.history.reason", reason)); + filters + .getStartedAfter() + .map(Instant::toString) + .ifPresent(value -> span.setAttribute("session.history.startedAfter", value)); + filters + .getEndedAfter() + .map(Instant::toString) + .ifPresent(value -> span.setAttribute("session.history.endedAfter", value)); + + List history = sessions.getSessionHistory(filters); + return new HttpResponse().setContent(asJson(ImmutableMap.of("value", history))); + } + } + + private byte[] errorPayload(String message) { + return asJson( + ImmutableMap.of( + "value", + ImmutableMap.of( + "error", "invalid argument", "message", message, "stacktrace", ""), + "status", + 13)); + } +} + diff --git a/java/src/org/openqa/selenium/grid/sessionmap/GetSessionHistory.java b/java/src/org/openqa/selenium/grid/sessionmap/GetSessionHistory.java new file mode 100644 index 0000000000000..5d77b97a0d261 --- /dev/null +++ b/java/src/org/openqa/selenium/grid/sessionmap/GetSessionHistory.java @@ -0,0 +1,81 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you 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 org.openqa.selenium.grid.sessionmap; + +import static org.openqa.selenium.remote.http.Contents.asJson; +import static org.openqa.selenium.remote.tracing.HttpTracing.newSpanAsChildOf; +import static org.openqa.selenium.remote.tracing.Tags.HTTP_REQUEST; + +import com.google.common.collect.ImmutableMap; +import java.time.Instant; +import java.util.List; +import org.openqa.selenium.internal.Require; +import org.openqa.selenium.remote.http.HttpHandler; +import org.openqa.selenium.remote.http.HttpRequest; +import org.openqa.selenium.remote.http.HttpResponse; +import org.openqa.selenium.remote.tracing.Span; +import org.openqa.selenium.remote.tracing.Tracer; + +class GetSessionHistory implements HttpHandler { + + private final Tracer tracer; + private final SessionMap sessions; + + GetSessionHistory(Tracer tracer, SessionMap sessions) { + this.tracer = Require.nonNull("Tracer", tracer); + this.sessions = Require.nonNull("Session map", sessions); + } + + @Override + public HttpResponse execute(HttpRequest req) { + try (Span span = newSpanAsChildOf(tracer, req, "sessions.get_session_history")) { + HTTP_REQUEST.accept(span, req); + + SessionHistoryFilters filters; + try { + filters = SessionHistoryFilters.fromRequest(req); + } catch (IllegalArgumentException e) { + return new HttpResponse().setStatus(400).setContent(asJson(errorPayload(e.getMessage()))); + } + + span.setAttribute("session.history.hasSessionId", filters.getSessionId().isPresent()); + filters.getCloseReason().ifPresent(reason -> span.setAttribute("session.history.reason", reason)); + filters + .getStartedAfter() + .map(Instant::toString) + .ifPresent(start -> span.setAttribute("session.history.startedAfter", start)); + filters + .getEndedAfter() + .map(Instant::toString) + .ifPresent(end -> span.setAttribute("session.history.endedAfter", end)); + + List value = sessions.getSessionHistory(filters); + return new HttpResponse().setContent(asJson(ImmutableMap.of("value", value))); + } + } + + private ImmutableMap errorPayload(String message) { + return ImmutableMap.of( + "value", + ImmutableMap.of( + "error", "invalid argument", "message", message, "stacktrace", ""), + "status", + 13); + } +} + diff --git a/java/src/org/openqa/selenium/grid/sessionmap/NullSessionMap.java b/java/src/org/openqa/selenium/grid/sessionmap/NullSessionMap.java index 9a2174df53730..4042feb0929fe 100644 --- a/java/src/org/openqa/selenium/grid/sessionmap/NullSessionMap.java +++ b/java/src/org/openqa/selenium/grid/sessionmap/NullSessionMap.java @@ -17,6 +17,7 @@ package org.openqa.selenium.grid.sessionmap; +import java.time.Instant; import org.openqa.selenium.NoSuchSessionException; import org.openqa.selenium.grid.data.Session; import org.openqa.selenium.remote.SessionId; @@ -39,7 +40,7 @@ public Session get(SessionId id) throws NoSuchSessionException { } @Override - public void remove(SessionId id) { + public void remove(SessionId id, String reason, Instant endedAt) { // no-op } diff --git a/java/src/org/openqa/selenium/grid/sessionmap/RemoveFromSession.java b/java/src/org/openqa/selenium/grid/sessionmap/RemoveFromSession.java index da4583edd21dd..9de4bbaeff76c 100644 --- a/java/src/org/openqa/selenium/grid/sessionmap/RemoveFromSession.java +++ b/java/src/org/openqa/selenium/grid/sessionmap/RemoveFromSession.java @@ -20,7 +20,12 @@ import static org.openqa.selenium.remote.RemoteTags.SESSION_ID; import static org.openqa.selenium.remote.tracing.HttpTracing.newSpanAsChildOf; import static org.openqa.selenium.remote.tracing.Tags.HTTP_REQUEST; +import static org.openqa.selenium.remote.http.Contents.asJson; +import com.google.common.collect.ImmutableMap; +import java.time.Instant; +import java.time.format.DateTimeParseException; +import java.util.Optional; import org.openqa.selenium.internal.Require; import org.openqa.selenium.remote.SessionId; import org.openqa.selenium.remote.http.HttpHandler; @@ -47,8 +52,48 @@ public HttpResponse execute(HttpRequest req) { HTTP_REQUEST.accept(span, req); SESSION_ID.accept(span, id); - sessions.remove(id); - return new HttpResponse(); + String reason = + Optional.ofNullable(req.getQueryParameter("reason")) + .filter(value -> !value.isBlank()) + .orElse(SessionMap.REASON_HTTP_REQUEST); + span.setAttribute("session.close.reason", reason); + + Instant endedAt = parseEndTime(req).orElseGet(Instant::now); + span.setAttribute("session.close.endedAt", endedAt.toString()); + + try { + sessions.remove(id, reason, endedAt); + return new HttpResponse(); + } catch (IllegalArgumentException e) { + return new HttpResponse().setStatus(400).setContent(errorPayload(e.getMessage())); + } + } + } + + private Optional parseEndTime(HttpRequest req) { + String endedAt = req.getQueryParameter("endedAt"); + if ((endedAt == null || endedAt.isBlank())) { + endedAt = req.getQueryParameter("timestamp"); } + + if (endedAt == null || endedAt.isBlank()) { + return Optional.empty(); + } + + try { + return Optional.of(Instant.parse(endedAt)); + } catch (DateTimeParseException e) { + throw new IllegalArgumentException("Unable to parse timestamp: " + endedAt, e); + } + } + + private byte[] errorPayload(String message) { + return asJson( + ImmutableMap.of( + "value", + ImmutableMap.of( + "error", "invalid argument", "message", message, "stacktrace", ""), + "status", + 13)); } } diff --git a/java/src/org/openqa/selenium/grid/sessionmap/SessionHistoryFilters.java b/java/src/org/openqa/selenium/grid/sessionmap/SessionHistoryFilters.java new file mode 100644 index 0000000000000..cf0cbc653b3ee --- /dev/null +++ b/java/src/org/openqa/selenium/grid/sessionmap/SessionHistoryFilters.java @@ -0,0 +1,93 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you 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 org.openqa.selenium.grid.sessionmap; + +import java.time.Instant; +import java.time.format.DateTimeParseException; +import java.util.Locale; +import java.util.Optional; +import org.openqa.selenium.remote.SessionId; +import org.openqa.selenium.remote.http.HttpRequest; + +public class SessionHistoryFilters { + + private final Optional sessionId; + private final Optional closeReason; + private final Optional startedAfter; + private final Optional endedAfter; + + private SessionHistoryFilters( + Optional sessionId, + Optional closeReason, + Optional startedAfter, + Optional endedAfter) { + this.sessionId = sessionId; + this.closeReason = closeReason; + this.startedAfter = startedAfter; + this.endedAfter = endedAfter; + } + + public static SessionHistoryFilters fromRequest(HttpRequest req) { + Optional sessionId = + Optional.ofNullable(req.getQueryParameter("sessionId")) + .or(() -> Optional.ofNullable(req.getQueryParameter("id"))) + .filter(value -> !value.isBlank()) + .map(SessionId::new); + + Optional reason = + Optional.ofNullable(req.getQueryParameter("reason")) + .filter(value -> !value.isBlank()) + .map(value -> value.toLowerCase(Locale.ROOT)); + + Optional startedAfter = parseInstant(req.getQueryParameter("startedAfter")); + Optional endedAfter = + parseInstant(req.getQueryParameter("endedAfter")) + .or(() -> parseInstant(req.getQueryParameter("timestamp"))); + + return new SessionHistoryFilters(sessionId, reason, startedAfter, endedAfter); + } + + private static Optional parseInstant(String value) { + if (value == null || value.isBlank()) { + return Optional.empty(); + } + + try { + return Optional.of(Instant.parse(value)); + } catch (DateTimeParseException e) { + throw new IllegalArgumentException("Unable to parse timestamp: " + value, e); + } + } + + public Optional getSessionId() { + return sessionId; + } + + public Optional getCloseReason() { + return closeReason; + } + + public Optional getStartedAfter() { + return startedAfter; + } + + public Optional getEndedAfter() { + return endedAfter; + } +} + diff --git a/java/src/org/openqa/selenium/grid/sessionmap/SessionMap.java b/java/src/org/openqa/selenium/grid/sessionmap/SessionMap.java index f39b7aaed0817..97782f5663e39 100644 --- a/java/src/org/openqa/selenium/grid/sessionmap/SessionMap.java +++ b/java/src/org/openqa/selenium/grid/sessionmap/SessionMap.java @@ -22,8 +22,17 @@ import static org.openqa.selenium.remote.http.Route.post; import java.net.URI; +import java.time.Instant; +import java.util.List; import java.util.Map; +import java.util.Locale; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.stream.Collectors; import org.openqa.selenium.NoSuchSessionException; +import org.openqa.selenium.grid.data.SessionClosedEvent; import org.openqa.selenium.grid.data.Session; import org.openqa.selenium.internal.Require; import org.openqa.selenium.json.Json; @@ -73,12 +82,29 @@ public abstract class SessionMap implements HasReadyState, Routable { protected final Tracer tracer; private final Route routes; + private final ConcurrentMap trackedSessions = new ConcurrentHashMap<>(); + private final CopyOnWriteArrayList sessionHistory = new CopyOnWriteArrayList<>(); + + public static final String REASON_HTTP_REQUEST = "http-request"; + public static final String REASON_SESSION_CLOSED_EVENT = SessionClosedEvent.DEFAULT_REASON; + public static final String REASON_SESSION_TIMEOUT = "session-timeout"; + public static final String REASON_NODE_REMOVED = "node-removed"; + public static final String REASON_NODE_RESTARTED = "node-restarted"; + public static final String REASON_UNKNOWN = "unknown"; public abstract boolean add(Session session); public abstract Session get(SessionId id) throws NoSuchSessionException; - public abstract void remove(SessionId id); + public void remove(SessionId id) { + remove(id, REASON_UNKNOWN, Instant.now()); + } + + public void remove(SessionId id, String reason) { + remove(id, reason, Instant.now()); + } + + public abstract void remove(SessionId id, String reason, Instant endedAt); public URI getUri(SessionId id) throws NoSuchSessionException { return get(id).getUri(); @@ -95,6 +121,8 @@ public SessionMap(Tracer tracer) { post("/se/grid/session").to(() -> new AddToSessionMap(tracer, json, this)), Route.get("/se/grid/session/{sessionId}") .to(params -> new GetFromSessionMap(tracer, this, sessionIdFrom(params))), + Route.get("/se/grid/sessions/history") + .to(() -> new GetSessionHistory(tracer, this)), delete("/se/grid/session/{sessionId}") .to(params -> new RemoveFromSession(tracer, this, sessionIdFrom(params)))); } @@ -112,4 +140,60 @@ public boolean matches(HttpRequest req) { public HttpResponse execute(HttpRequest req) { return routes.execute(req); } + + protected void trackSession(Session session) { + trackedSessions.put(session.getId(), session); + } + + protected void recordSessionClosed(SessionId id, Session removedSession, Instant endedAt, String reason) { + String normalisedReason = normaliseReason(reason); + Session session = removedSession != null ? removedSession : trackedSessions.remove(id); + if (session != null) { + sessionHistory.add(new SessionMetadata(session, endedAt, normalisedReason)); + } else { + sessionHistory.add(new SessionMetadata(id, endedAt, normalisedReason)); + } + trackedSessions.remove(id); + } + + public List getSessionHistory(SessionHistoryFilters filters) { + Require.nonNull("Session history filters", filters); + return getSessionHistory( + filters.getSessionId(), filters.getCloseReason(), filters.getStartedAfter(), filters.getEndedAfter()); + } + + public List getSessionHistory( + Optional sessionId, + Optional reason, + Optional startedAfter, + Optional endedAfter) { + + return sessionHistory.stream() + .filter( + metadata -> + sessionId.map(id -> id.equals(metadata.getSessionId())).orElse(true)) + .filter( + metadata -> + reason + .map(value -> metadata.getCloseReason().equalsIgnoreCase(value)) + .orElse(true)) + .filter( + metadata -> + startedAfter + .map(start -> metadata.getStartTime() != null && !metadata.getStartTime().isBefore(start)) + .orElse(true)) + .filter( + metadata -> + endedAfter + .map(end -> !metadata.getEndTime().isBefore(end)) + .orElse(true)) + .collect(Collectors.toUnmodifiableList()); + } + + protected String normaliseReason(String reason) { + return Optional.ofNullable(reason) + .map(value -> value.trim().toLowerCase(Locale.ROOT)) + .filter(value -> !value.isEmpty()) + .orElse(REASON_UNKNOWN); + } } diff --git a/java/src/org/openqa/selenium/grid/sessionmap/SessionMetadata.java b/java/src/org/openqa/selenium/grid/sessionmap/SessionMetadata.java new file mode 100644 index 0000000000000..f16a8c82f7079 --- /dev/null +++ b/java/src/org/openqa/selenium/grid/sessionmap/SessionMetadata.java @@ -0,0 +1,172 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you 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 org.openqa.selenium.grid.sessionmap; + +import static java.util.Collections.unmodifiableMap; + +import java.io.Serializable; +import java.net.URI; +import java.time.Instant; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import org.openqa.selenium.grid.data.Session; +import org.openqa.selenium.internal.Require; +import org.openqa.selenium.json.JsonInput; +import org.openqa.selenium.remote.SessionId; + +/** + * Metadata tracked for a session after it has ended. It captures when the session started, when it + * finished, why it was closed, and the last known URI that hosted it. + */ +public class SessionMetadata implements Serializable { + + private final SessionId sessionId; + private final URI uri; + private final Instant startTime; + private final Instant endTime; + private final String closeReason; + + SessionMetadata(Session session, Instant endTime, String closeReason) { + this( + Require.nonNull("Session", session).getId(), + session.getUri(), + session.getStartTime(), + endTime, + closeReason); + } + + SessionMetadata(SessionId sessionId, Instant endTime, String closeReason) { + this(sessionId, null, null, endTime, closeReason); + } + + private SessionMetadata( + SessionId sessionId, URI uri, Instant startTime, Instant endTime, String closeReason) { + this.sessionId = Require.nonNull("Session id", sessionId); + this.uri = uri; + this.startTime = startTime; + this.endTime = Require.nonNull("Session end time", endTime); + this.closeReason = Require.nonNull("Close reason", closeReason); + } + + public SessionId getSessionId() { + return sessionId; + } + + public URI getUri() { + return uri; + } + + public Instant getStartTime() { + return startTime; + } + + public Instant getEndTime() { + return endTime; + } + + public String getCloseReason() { + return closeReason; + } + + private Map toJson() { + Map toReturn = new TreeMap<>(); + toReturn.put("sessionId", sessionId); + if (uri != null) { + toReturn.put("uri", uri); + } + if (startTime != null) { + toReturn.put("startTime", startTime); + } + toReturn.put("endTime", endTime); + toReturn.put("closeReason", closeReason); + return unmodifiableMap(toReturn); + } + + private static SessionMetadata fromJson(JsonInput input) { + SessionId sessionId = null; + URI uri = null; + Instant start = null; + Instant end = null; + String reason = null; + + input.beginObject(); + while (input.hasNext()) { + switch (input.nextName()) { + case "sessionId": + sessionId = input.read(SessionId.class); + break; + + case "uri": + uri = input.read(URI.class); + break; + + case "startTime": + start = input.read(Instant.class); + break; + + case "endTime": + end = input.read(Instant.class); + break; + + case "closeReason": + reason = input.read(String.class); + break; + + default: + input.skipValue(); + break; + } + } + input.endObject(); + + SessionMetadata metadata = + new SessionMetadata( + Require.nonNull("Session id", sessionId), end, Require.nonNull("Close reason", reason)); + if (uri != null || start != null) { + metadata = + new SessionMetadata( + sessionId, + uri, + start, + Require.nonNull("Session end time", end), + Require.nonNull("Close reason", reason)); + } + return metadata; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof SessionMetadata)) { + return false; + } + + SessionMetadata other = (SessionMetadata) o; + return Objects.equals(sessionId, other.sessionId) + && Objects.equals(uri, other.uri) + && Objects.equals(startTime, other.startTime) + && Objects.equals(endTime, other.endTime) + && Objects.equals(closeReason, other.closeReason); + } + + @Override + public int hashCode() { + return Objects.hash(sessionId, uri, startTime, endTime, closeReason); + } +} + diff --git a/java/src/org/openqa/selenium/grid/sessionmap/jdbc/JdbcBackedSessionMap.java b/java/src/org/openqa/selenium/grid/sessionmap/jdbc/JdbcBackedSessionMap.java index eb31465a1fbca..768ac7209d190 100644 --- a/java/src/org/openqa/selenium/grid/sessionmap/jdbc/JdbcBackedSessionMap.java +++ b/java/src/org/openqa/selenium/grid/sessionmap/jdbc/JdbcBackedSessionMap.java @@ -31,6 +31,8 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.time.Instant; +import java.util.ArrayList; +import java.util.List; import java.util.logging.Logger; import org.openqa.selenium.Capabilities; import org.openqa.selenium.ImmutableCapabilities; @@ -81,7 +83,9 @@ public JdbcBackedSessionMap(Tracer tracer, Connection jdbcConnection, EventBus b this.bus = Require.nonNull("Event bus", bus); this.connection = jdbcConnection; - this.bus.addListener(SessionClosedEvent.listener(this::remove)); + this.bus.addListener( + SessionClosedEvent.listener( + event -> this.remove(event.getSessionId(), event.getReason(), Instant.now()))); this.bus.addListener( NodeRemovedEvent.listener( @@ -89,11 +93,13 @@ public JdbcBackedSessionMap(Tracer tracer, Connection jdbcConnection, EventBus b nodeStatus.getSlots().stream() .filter(slot -> slot.getSession() != null) .map(slot -> slot.getSession().getId()) - .forEach(this::remove))); + .forEach( + sessionId -> this.remove(sessionId, REASON_NODE_REMOVED, Instant.now())))); bus.addListener( NodeRestartedEvent.listener( - previousNodeStatus -> this.removeByUri(previousNodeStatus.getExternalUri()))); + previousNodeStatus -> + this.removeByUri(previousNodeStatus.getExternalUri(), REASON_NODE_RESTARTED))); } public static SessionMap create(Config config) { @@ -169,6 +175,7 @@ public boolean add(Session session) { int rowCount = statement.executeUpdate(); attributeMap.put("rows.added", rowCount); span.addEvent("Inserted into the database", attributeMap); + trackSession(session); return rowCount >= 1; } catch (SQLException e) { span.setAttribute("error", true); @@ -286,7 +293,7 @@ public Session get(SessionId id) throws NoSuchSessionException { } @Override - public void remove(SessionId id) { + public void remove(SessionId id, String reason, Instant endedAt) { Require.nonNull("Session ID", id); try (Span span = tracer.getCurrentContext().createSpan("DELETE from sessions_map where session_ids = ?")) { @@ -310,6 +317,7 @@ public void remove(SessionId id) { int rowCount = statement.executeUpdate(); attributeMap.put("rows.deleted", rowCount); span.addEvent("Deleted session from the database", attributeMap); + recordSessionClosed(id, null, endedAt, reason); } catch (SQLException e) { span.setAttribute("error", true); @@ -324,12 +332,35 @@ public void remove(SessionId id) { } } - public void removeByUri(URI sessionUri) { + public void removeByUri(URI sessionUri, String reason) { Require.nonNull("Session URI", sessionUri); try (Span span = tracer.getCurrentContext().createSpan("DELETE from sessions_map where session_uri = ?")) { AttributeMap attributeMap = tracer.createAttributeMap(); + List sessionIds = new ArrayList<>(); + try (PreparedStatement selectStatement = + connection.prepareStatement( + String.format( + "select %1$s from %2$s where %3$s = ?", + SESSION_ID_COL, TABLE_NAME, SESSION_URI_COL))) { + selectStatement.setString(1, sessionUri.toString()); + try (ResultSet resultSet = selectStatement.executeQuery()) { + while (resultSet.next()) { + sessionIds.add(new SessionId(resultSet.getString(SESSION_ID_COL))); + } + } + } catch (SQLException e) { + span.setAttribute("error", true); + span.setStatus(Status.CANCELLED); + EXCEPTION.accept(attributeMap, e); + attributeMap.put( + AttributeKey.EXCEPTION_MESSAGE.getKey(), + "Unable to read session ids from the database: " + e.getMessage()); + span.addEvent(AttributeKey.EXCEPTION_EVENT.getKey(), attributeMap); + throw new JdbcException(e.getMessage()); + } + try (PreparedStatement statement = connection.prepareStatement( String.format("delete from %1$s where %2$s = ?", TABLE_NAME, SESSION_URI_COL))) { @@ -344,6 +375,8 @@ public void removeByUri(URI sessionUri) { int rowCount = statement.executeUpdate(); attributeMap.put("rows.deleted", rowCount); span.addEvent("Deleted session from the database", attributeMap); + Instant endedAt = Instant.now(); + sessionIds.forEach(sessionId -> recordSessionClosed(sessionId, null, endedAt, reason)); } catch (SQLException e) { span.setAttribute("error", true); diff --git a/java/src/org/openqa/selenium/grid/sessionmap/local/LocalSessionMap.java b/java/src/org/openqa/selenium/grid/sessionmap/local/LocalSessionMap.java index 4cdcbae6a05c5..eee09958b7eaf 100644 --- a/java/src/org/openqa/selenium/grid/sessionmap/local/LocalSessionMap.java +++ b/java/src/org/openqa/selenium/grid/sessionmap/local/LocalSessionMap.java @@ -21,6 +21,7 @@ import static org.openqa.selenium.remote.RemoteTags.SESSION_ID_EVENT; import java.net.URI; +import java.time.Instant; import java.util.Collection; import java.util.Collections; import java.util.HashMap; @@ -60,18 +61,24 @@ public LocalSessionMap(Tracer tracer, EventBus bus) { this.bus = Require.nonNull("Event bus", bus); - bus.addListener(SessionClosedEvent.listener(this::remove)); + bus.addListener( + SessionClosedEvent.listener( + event -> remove(event.getSessionId(), event.getReason(), Instant.now()))); bus.addListener( NodeRemovedEvent.listener( nodeStatus -> { - batchRemoveByUri(nodeStatus.getExternalUri(), NodeRemovedEvent.class); + batchRemoveByUri( + nodeStatus.getExternalUri(), NodeRemovedEvent.class, REASON_NODE_REMOVED); })); bus.addListener( NodeRestartedEvent.listener( previousNodeStatus -> { - batchRemoveByUri(previousNodeStatus.getExternalUri(), NodeRestartedEvent.class); + batchRemoveByUri( + previousNodeStatus.getExternalUri(), + NodeRestartedEvent.class, + REASON_NODE_RESTARTED); })); } @@ -93,6 +100,7 @@ public boolean add(Session session) { SessionId id = session.getId(); knownSessions.put(id, session); + trackSession(session); try (Span span = tracer.getCurrentContext().createSpan("local_sessionmap.add")) { AttributeMap attributeMap = tracer.createAttributeMap(); @@ -122,10 +130,11 @@ public Session get(SessionId id) { } @Override - public void remove(SessionId id) { + public void remove(SessionId id, String reason, Instant endedAt) { Require.nonNull("Session ID", id); Session removedSession = knownSessions.remove(id); + recordSessionClosed(id, removedSession, endedAt, reason); try (Span span = tracer.getCurrentContext().createSpan("local_sessionmap.remove")) { AttributeMap attributeMap = tracer.createAttributeMap(); @@ -135,22 +144,26 @@ public void remove(SessionId id) { String sessionDeletedMessage = String.format( - "Deleted session from local Session Map, Id: %s, Node: %s", + "Deleted session from local Session Map, Id: %s, Node: %s, Reason: %s", id, - removedSession != null ? String.valueOf(removedSession.getUri()) : "unidentified"); + removedSession != null ? String.valueOf(removedSession.getUri()) : "unidentified", + reason); span.addEvent(sessionDeletedMessage, attributeMap); LOG.info(sessionDeletedMessage); } } - private void batchRemoveByUri(URI externalUri, Class eventClass) { + private void batchRemoveByUri( + URI externalUri, Class eventClass, String reason) { Set sessionsToRemove = knownSessions.getSessionsByUri(externalUri); if (sessionsToRemove.isEmpty()) { return; // Early return for empty operations - no tracing overhead } - knownSessions.batchRemove(sessionsToRemove); + Map removed = knownSessions.batchRemove(sessionsToRemove); + Instant endedAt = Instant.now(); + removed.forEach((sessionId, session) -> recordSessionClosed(sessionId, session, endedAt, reason)); try (Span span = tracer.getCurrentContext().createSpan("local_sessionmap.batch_remove")) { AttributeMap attributeMap = tracer.createAttributeMap(); @@ -206,13 +219,17 @@ public Session remove(SessionId id) { } } - public void batchRemove(Set sessionIds) { + public Map batchRemove(Set sessionIds) { synchronized (coordinationLock) { Map> uriToSessionIds = new HashMap<>(); + Map removedSessions = new HashMap<>(); // Single loop: remove sessions and collect URI mappings in one pass for (SessionId id : sessionIds) { Session session = sessions.remove(id); + if (session != null) { + removedSessions.put(id, session); + } if (session != null && session.getUri() != null) { uriToSessionIds.computeIfAbsent(session.getUri(), k -> new HashSet<>()).add(id); } @@ -222,6 +239,8 @@ public void batchRemove(Set sessionIds) { for (Map.Entry> entry : uriToSessionIds.entrySet()) { cleanupUriIndex(entry.getKey(), entry.getValue()); } + + return removedSessions; } } diff --git a/java/src/org/openqa/selenium/grid/sessionmap/redis/RedisBackedSessionMap.java b/java/src/org/openqa/selenium/grid/sessionmap/redis/RedisBackedSessionMap.java index 62b1220edb21a..a2759344cb415 100644 --- a/java/src/org/openqa/selenium/grid/sessionmap/redis/RedisBackedSessionMap.java +++ b/java/src/org/openqa/selenium/grid/sessionmap/redis/RedisBackedSessionMap.java @@ -76,7 +76,9 @@ public RedisBackedSessionMap(Tracer tracer, URI serverUri, EventBus bus) { this.bus = Require.nonNull("Event bus", bus); this.connection = new GridRedisClient(serverUri); this.serverUri = serverUri; - this.bus.addListener(SessionClosedEvent.listener(this::remove)); + this.bus.addListener( + SessionClosedEvent.listener( + event -> this.remove(event.getSessionId(), event.getReason(), Instant.now()))); this.bus.addListener( NodeRemovedEvent.listener( @@ -84,11 +86,14 @@ public RedisBackedSessionMap(Tracer tracer, URI serverUri, EventBus bus) { nodeStatus.getSlots().stream() .filter(slot -> slot.getSession() != null) .map(slot -> slot.getSession().getId()) - .forEach(this::remove))); + .forEach( + sessionId -> + this.remove(sessionId, REASON_NODE_REMOVED, Instant.now())))); bus.addListener( NodeRestartedEvent.listener( - previousNodeStatus -> this.removeByUri(previousNodeStatus.getExternalUri()))); + previousNodeStatus -> + this.removeByUri(previousNodeStatus.getExternalUri(), REASON_NODE_RESTARTED))); } public static SessionMap create(Config config) { @@ -145,6 +150,8 @@ public boolean add(Session session) { capabilitiesKey, capabilitiesJson, startKey, startValue)); + trackSession(session); + return true; } } @@ -258,7 +265,7 @@ public URI getUri(SessionId id) throws NoSuchSessionException { } @Override - public void remove(SessionId id) { + public void remove(SessionId id, String reason, Instant endedAt) { Require.nonNull("Session ID", id); try (Span span = tracer.getCurrentContext().createSpan("DEL sessionUriKey capabilitiesKey")) { @@ -284,10 +291,11 @@ public void remove(SessionId id) { span.addEvent("Deleted session from the database", attributeMap); connection.del(uriKey, capabilitiesKey, stereotypeKey, startKey); + recordSessionClosed(id, null, endedAt, reason); } } - public void removeByUri(URI uri) { + public void removeByUri(URI uri, String reason) { List uriKeys = connection.getKeysByPattern("session:*:uri"); if (uriKeys.isEmpty()) { @@ -306,7 +314,7 @@ public void removeByUri(URI uri) { String[] sessionKey = key.split(":"); return new SessionId(sessionKey[1]); }) - .forEach(this::remove); + .forEach(sessionId -> this.remove(sessionId, reason, Instant.now())); } @Override diff --git a/java/src/org/openqa/selenium/grid/sessionmap/remote/RemoteSessionMap.java b/java/src/org/openqa/selenium/grid/sessionmap/remote/RemoteSessionMap.java index 5d49e2b6588c8..865a6edbc573b 100644 --- a/java/src/org/openqa/selenium/grid/sessionmap/remote/RemoteSessionMap.java +++ b/java/src/org/openqa/selenium/grid/sessionmap/remote/RemoteSessionMap.java @@ -26,12 +26,18 @@ import java.lang.reflect.Type; import java.net.MalformedURLException; import java.net.URI; +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; import org.openqa.selenium.NoSuchSessionException; import org.openqa.selenium.grid.config.Config; import org.openqa.selenium.grid.data.Session; import org.openqa.selenium.grid.log.LoggingOptions; import org.openqa.selenium.grid.server.NetworkOptions; +import org.openqa.selenium.grid.sessionmap.SessionHistoryFilters; import org.openqa.selenium.grid.sessionmap.SessionMap; +import org.openqa.selenium.grid.sessionmap.SessionMetadata; import org.openqa.selenium.grid.sessionmap.config.SessionMapOptions; import org.openqa.selenium.grid.web.Values; import org.openqa.selenium.internal.Require; @@ -106,9 +112,41 @@ public URI getUri(SessionId id) throws NoSuchSessionException { @Override public void remove(SessionId id) { + remove(id, REASON_HTTP_REQUEST, Instant.now()); + } + + @Override + public void remove(SessionId id, String reason, Instant endedAt) { Require.nonNull("Session ID", id); + Require.nonNull("End time", endedAt); + + HttpRequest request = new HttpRequest(DELETE, "/se/grid/session/" + id); + request.addQueryParameter("reason", normaliseReason(reason)); + request.addQueryParameter("endedAt", endedAt.toString()); + makeRequest(request, Void.class); + } - makeRequest(new HttpRequest(DELETE, "/se/grid/session/" + id), Void.class); + @Override + public List getSessionHistory(SessionHistoryFilters filters) { + return getSessionHistory( + filters.getSessionId(), filters.getCloseReason(), filters.getStartedAfter(), filters.getEndedAfter()); + } + + @Override + public List getSessionHistory( + Optional sessionId, + Optional reason, + Optional startedAfter, + Optional endedAfter) { + + HttpRequest request = new HttpRequest(GET, "/se/grid/sessions/history"); + sessionId.map(SessionId::toString).ifPresent(value -> request.addQueryParameter("sessionId", value)); + reason.map(this::normaliseReason).ifPresent(value -> request.addQueryParameter("reason", value)); + startedAfter.map(Instant::toString).ifPresent(value -> request.addQueryParameter("startedAfter", value)); + endedAfter.map(Instant::toString).ifPresent(value -> request.addQueryParameter("endedAfter", value)); + + SessionMetadata[] response = makeRequest(request, SessionMetadata[].class); + return response == null ? List.of() : Arrays.asList(response); } private T makeRequest(HttpRequest request, Type typeOfT) { diff --git a/java/test/org/openqa/selenium/grid/node/NodeTest.java b/java/test/org/openqa/selenium/grid/node/NodeTest.java index b41a62f2f117e..5aa897b53650a 100644 --- a/java/test/org/openqa/selenium/grid/node/NodeTest.java +++ b/java/test/org/openqa/selenium/grid/node/NodeTest.java @@ -476,7 +476,7 @@ void eachSessionShouldReportTheNodesUrl() throws URISyntaxException { @Test void quittingASessionShouldCauseASessionClosedEventToBeFired() { - AtomicReference obj = new AtomicReference<>(); + AtomicReference obj = new AtomicReference<>(); bus.addListener(SessionClosedEvent.listener(obj::set)); Either response = @@ -488,7 +488,8 @@ void quittingASessionShouldCauseASessionClosedEventToBeFired() { // Because we're using the event bus, we can't expect the event to fire instantly. We're using // an inproc bus, so in reality it's reasonable to expect the event to fire synchronously, but // let's play it safe. - Wait> wait = new FluentWait<>(obj).withTimeout(ofSeconds(2)); + Wait> wait = + new FluentWait<>(obj).withTimeout(ofSeconds(2)); wait.until(ref -> ref.get() != null); } diff --git a/java/test/org/openqa/selenium/grid/sessionmap/SessionMapTest.java b/java/test/org/openqa/selenium/grid/sessionmap/SessionMapTest.java index 18aee992c4077..07c98092622d1 100644 --- a/java/test/org/openqa/selenium/grid/sessionmap/SessionMapTest.java +++ b/java/test/org/openqa/selenium/grid/sessionmap/SessionMapTest.java @@ -26,6 +26,8 @@ import java.net.URI; import java.net.URISyntaxException; import java.time.Instant; +import java.util.List; +import java.util.Optional; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -35,6 +37,7 @@ import org.openqa.selenium.events.local.GuavaEventBus; import org.openqa.selenium.grid.data.Session; import org.openqa.selenium.grid.data.SessionClosedEvent; +import org.openqa.selenium.grid.sessionmap.SessionMetadata; import org.openqa.selenium.grid.sessionmap.local.LocalSessionMap; import org.openqa.selenium.grid.sessionmap.remote.RemoteSessionMap; import org.openqa.selenium.grid.testing.PassthroughHttpClient; @@ -109,6 +112,23 @@ void shouldAllowSessionsToBeRemoved() { assertThatExceptionOfType(NoSuchSessionException.class).isThrownBy(() -> remote.get(id)); } + @Test + void shouldExposeSessionHistory() { + local.add(expected); + + remote.remove(id); + + List history = + local.getSessionHistory(Optional.of(id), Optional.empty(), Optional.empty(), Optional.empty()); + + assertThat(history).hasSize(1); + SessionMetadata metadata = history.get(0); + assertThat(metadata.getSessionId()).isEqualTo(id); + assertThat(metadata.getStartTime()).isEqualTo(expected.getStartTime()); + assertThat(metadata.getCloseReason()).isEqualTo(SessionMap.REASON_HTTP_REQUEST); + assertThat(metadata.getEndTime()).isAfterOrEqualTo(metadata.getStartTime()); + } + /** This is because multiple areas within the grid may all try and remove a session. */ @Test void removingASessionThatDoesNotExistIsNotAnError() { @@ -136,5 +156,37 @@ void shouldAllowEntriesToBeRemovedByAMessage() { return true; } }); + + List history = + local.getSessionHistory( + Optional.of(expected.getId()), Optional.empty(), Optional.empty(), Optional.empty()); + + assertThat(history).hasSize(1); + assertThat(history.get(0).getCloseReason()).isEqualTo(SessionMap.REASON_SESSION_CLOSED_EVENT); + } + + @Test + void shouldRecordCloseReasonFromSessionClosedEvent() { + local.add(expected); + + bus.fire(new SessionClosedEvent(expected.getId(), SessionMap.REASON_SESSION_TIMEOUT)); + + Wait wait = new FluentWait<>(local).withTimeout(ofSeconds(2)); + wait.until( + sessions -> { + try { + sessions.get(expected.getId()); + return false; + } catch (NoSuchSessionException e) { + return true; + } + }); + + List history = + local.getSessionHistory( + Optional.of(expected.getId()), Optional.empty(), Optional.empty(), Optional.empty()); + + assertThat(history).hasSize(1); + assertThat(history.get(0).getCloseReason()).isEqualTo(SessionMap.REASON_SESSION_TIMEOUT); } }