Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions src/main/java/uk/ac/cam/cl/dtg/isaac/api/Constants.java
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ public enum IsaacClientLogType implements LogType {
VIDEO_PLAY,
VIDEO_PAUSE,
VIDEO_ENDED,
VIDEO_60_PERCENT_WATCHED,
VIEW_SUPERSEDED_BY_QUESTION,
CLONE_GAMEBOARD,
VIEW_HINT,
Expand Down
128 changes: 118 additions & 10 deletions src/main/java/uk/ac/cam/cl/dtg/segue/api/LogEventFacade.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import uk.ac.cam.cl.dtg.isaac.api.Constants.IsaacClientLogType;
import uk.ac.cam.cl.dtg.isaac.dto.SegueErrorResponse;
import uk.ac.cam.cl.dtg.isaac.dto.users.AbstractSegueUserDTO;
import uk.ac.cam.cl.dtg.isaac.dto.users.AnonymousUserDTO;
Expand All @@ -58,6 +59,13 @@
public class LogEventFacade extends AbstractSegueFacade {
private static final Logger log = LoggerFactory.getLogger(LogEventFacade.class);

private static final String VIDEO_ENGAGEMENT_EVENT_TYPE = IsaacClientLogType.VIDEO_60_PERCENT_WATCHED.name();
private static final String VIDEO_ID_FIELDNAME = "videoId";
private static final String VIDEO_DURATION_SECONDS_FIELDNAME = "videoDurationSeconds";
private static final String WATCHED_SECONDS_FIELDNAME = "watchedSeconds";
private static final String WATCH_PERCENT_FIELDNAME = "watchPercent";
private static final double VIDEO_WATCH_THRESHOLD = 0.6;

private final IMisuseMonitor misuseMonitor;

private final UserAccountManager userManager;
Expand Down Expand Up @@ -92,7 +100,10 @@ public LogEventFacade(final PropertiesLoader properties, final ILogManager logMa
description = "The 'type' field must be provided and must not be a reserved value.")
public Response postLog(@Context final HttpServletRequest httpRequest, final Map<String, Object> eventJSON) {
if (null == eventJSON || eventJSON.get(TYPE_FIELDNAME) == null) {
log.error("Error during log operation, no event type specified. Event: " + sanitiseExternalLogValue(eventJSON));
if (log.isErrorEnabled()) {
log.error("Error during log operation, no event type specified. Event: {}",
sanitiseExternalLogValue(eventJSON));
}
return new SegueErrorResponse(Status.BAD_REQUEST, "Unable to record log message as the log has no "
+ TYPE_FIELDNAME + " property.").toResponse();
}
Expand All @@ -107,21 +118,19 @@ public Response postLog(@Context final HttpServletRequest httpRequest, final Map

// Temporarily log log event types which are not included in our accepted list of client log types.
// After a few weeks we should fail on the case where it is an unknown type.
if (!ISAAC_CLIENT_LOG_TYPES.contains(eventType)) {
log.error(String.format("Warning: Log Event '%s' is not included in ISAAC_CLIENT_LOG_TYPES",
sanitiseExternalLogValue(eventType)));
if (!ISAAC_CLIENT_LOG_TYPES.contains(eventType) && log.isErrorEnabled()) {
log.error("Warning: Log Event '{}' is not included in ISAAC_CLIENT_LOG_TYPES",
sanitiseExternalLogValue(eventType));
}

try {
// implement arbitrary log size limit.
AbstractSegueUserDTO currentUser = userManager.getCurrentUser(httpRequest);
String uid;
if (currentUser instanceof AnonymousUserDTO) {
uid = ((AnonymousUserDTO) currentUser).getSessionId();
} else {
uid = ((RegisteredUserDTO) currentUser).getId().toString();
}
String uid = currentUser instanceof AnonymousUserDTO anonymousUser
? anonymousUser.getSessionId()
: ((RegisteredUserDTO) currentUser).getId().toString();

// Rate-limit every request (including video engagement events) before doing any further per-event work.
try {
misuseMonitor.notifyEvent(uid, LogEventMisuseHandler.class.getSimpleName(), httpRequest.getContentLength());
} catch (SegueResourceMisuseException e) {
Expand All @@ -133,6 +142,14 @@ public Response postLog(@Context final HttpServletRequest httpRequest, final Map
httpRequest.getContentLength()));
}

// The video engagement event requires a logged-in user, a validated payload and is deduplicated per video.
if (VIDEO_ENGAGEMENT_EVENT_TYPE.equals(eventType)) {
Response earlyVideoEventResponse = handleVideoEngagementEvent(eventJSON, currentUser, uid);
if (earlyVideoEventResponse != null) {
return earlyVideoEventResponse;
}
}

// remove the type information as we don't need it.
eventJSON.remove(TYPE_FIELDNAME);

Expand All @@ -147,4 +164,95 @@ public Response postLog(@Context final HttpServletRequest httpRequest, final Map
return error.toResponse();
}
}

/**
* Apply the extra rules for a video engagement event: the user must be logged in, the payload must be valid and
* the event is deduplicated per user and video.
*
* @param eventJSON the client supplied event details.
* @param currentUser the user making the request.
* @param uid the identifier used to attribute log events to the current user.
* @return an early {@link Response} (error, or a successful no-op for a duplicate), or null if the event should be
* logged as normal.
* @throws SegueDatabaseException if the deduplication lookup fails.
*/
private Response handleVideoEngagementEvent(final Map<String, Object> eventJSON,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 [checkstyle] <com.puppycrawl.tools.checkstyle.checks.naming.AbbreviationAsWordInNameCheck> reported by reviewdog 🐶
Abbreviation in name 'eventJSON' must contain no more than '1' consecutive capital letters.

final AbstractSegueUserDTO currentUser, final String uid) throws SegueDatabaseException {
if (!(currentUser instanceof RegisteredUserDTO)) {
return new SegueErrorResponse(Status.UNAUTHORIZED,
"You must be logged in to record a " + VIDEO_ENGAGEMENT_EVENT_TYPE + " event.").toResponse();
}

SegueErrorResponse validationError = validateVideoEngagementEvent(eventJSON);
if (validationError != null) {
// validateVideoEngagementEvent already warn-logs the specific reason.
return validationError.toResponse();
}

String videoId = (String) eventJSON.get(VIDEO_ID_FIELDNAME);
if (this.getLogManager().userHasLoggedEventWithDetail(uid, VIDEO_ENGAGEMENT_EVENT_TYPE,
VIDEO_ID_FIELDNAME, videoId)) {
// Already recorded for this user + video; treat as a successful no-op so the client need not special-case it.
return Response.ok().cacheControl(getCacheControl(NEVER_CACHE_WITHOUT_ETAG_CHECK, false)).build();
}

return null;
}

private SegueErrorResponse validateVideoEngagementEvent(final Map<String, Object> eventJSON) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 [checkstyle] <com.puppycrawl.tools.checkstyle.checks.naming.AbbreviationAsWordInNameCheck> reported by reviewdog 🐶
Abbreviation in name 'eventJSON' must contain no more than '1' consecutive capital letters.

Object videoIdValue = eventJSON.get(VIDEO_ID_FIELDNAME);
if (!(videoIdValue instanceof String videoId) || videoId.isBlank()) {
return videoEventValidationError("<missing>", "missing or empty " + VIDEO_ID_FIELDNAME);
}

Double videoDurationSeconds = getNumericField(eventJSON, VIDEO_DURATION_SECONDS_FIELDNAME);
if (videoDurationSeconds == null || videoDurationSeconds <= 0) {
return videoEventValidationError(videoId, VIDEO_DURATION_SECONDS_FIELDNAME + " must be greater than 0");
}

Double watchedSeconds = getNumericField(eventJSON, WATCHED_SECONDS_FIELDNAME);
if (watchedSeconds == null || watchedSeconds < 0) {
return videoEventValidationError(videoId, WATCHED_SECONDS_FIELDNAME + " must be greater than or equal to 0");
}

Double watchPercent = getNumericField(eventJSON, WATCH_PERCENT_FIELDNAME);
if (watchPercent == null || watchPercent < VIDEO_WATCH_THRESHOLD) {
return videoEventValidationError(videoId,
WATCH_PERCENT_FIELDNAME + " must be greater than or equal to " + VIDEO_WATCH_THRESHOLD);
}

return null;
}

/**
* Build a bad-request response for an invalid video engagement event and log a sanitised warning for observability.
*
* @param videoId the videoId the event referred to (or a placeholder if absent).
* @param reason a human-readable explanation of the validation failure.
* @return a {@link SegueErrorResponse} with {@link Status#BAD_REQUEST}.
*/
private SegueErrorResponse videoEventValidationError(final String videoId, final String reason) {
if (log.isWarnEnabled()) {
log.warn("Rejecting {} event (videoId: {}): {}", VIDEO_ENGAGEMENT_EVENT_TYPE,
sanitiseExternalLogValue(videoId), reason);
}
return new SegueErrorResponse(Status.BAD_REQUEST,
"Invalid " + VIDEO_ENGAGEMENT_EVENT_TYPE + " event: " + reason);
}

/**
* Read a numeric field from a client supplied event payload. JSON numbers are deserialized as {@link Integer} or
* {@link Double}, so this normalises any {@link Number} to a {@link Double}.
*
* @param eventJSON the client supplied event details.
* @param fieldName the field to read.
* @return the value as a Double, or null if the field is absent or not a number.
*/
private static Double getNumericField(final Map<String, Object> eventJSON, final String fieldName) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 [checkstyle] <com.puppycrawl.tools.checkstyle.checks.naming.AbbreviationAsWordInNameCheck> reported by reviewdog 🐶
Abbreviation in name 'eventJSON' must contain no more than '1' consecutive capital letters.

Object value = eventJSON.get(fieldName);
if (value instanceof Number) {
return ((Number) value).doubleValue();
}
return null;
}
}
14 changes: 14 additions & 0 deletions src/main/java/uk/ac/cam/cl/dtg/segue/dao/ILogManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -154,4 +154,18 @@ Map<String, Map<LocalDate, Long>> getLogCountByDate(Collection<String> eventType
* @throws SegueDatabaseException if there is a problem contacting the underlying database
*/
Set<String> getAllEventTypes() throws SegueDatabaseException;

/**
* Checks whether a given registered user has already logged an event of a particular type containing a specific
* value at a top-level key in its event_details JSONB. Used to deduplicate idempotent client events.
*
* @param userId the registered user id to check for.
* @param eventType the event type to match.
* @param detailKey the top-level key within event_details to match against.
* @param detailValue the value the event_details key must equal.
* @return true if at least one matching event already exists, false otherwise.
* @throws SegueDatabaseException if there is a problem contacting the underlying database
*/
boolean userHasLoggedEventWithDetail(String userId, String eventType, String detailKey, String detailValue)
throws SegueDatabaseException;
}
Original file line number Diff line number Diff line change
Expand Up @@ -177,4 +177,12 @@ public Set<String> getAllEventTypes() throws SegueDatabaseException {
return this.logManager.getAllEventTypes();

}

@Override
public boolean userHasLoggedEventWithDetail(final String userId, final String eventType, final String detailKey,
final String detailValue) throws SegueDatabaseException {

return this.logManager.userHasLoggedEventWithDetail(userId, eventType, detailKey, detailValue);

}
}
44 changes: 33 additions & 11 deletions src/main/java/uk/ac/cam/cl/dtg/segue/dao/PgLogManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,27 @@ public Collection<LogEvent> getLogsByType(final String type, final Instant fromD
return this.getLogsByUserAndType(type, fromDate, toDate, usersIdsList);
}

@Override
public boolean userHasLoggedEventWithDetail(final String userId, final String eventType, final String detailKey,
final String detailValue) throws SegueDatabaseException {
String query = "SELECT 1 FROM logged_events WHERE user_id = ? AND event_type = ?"
+ " AND event_details->>? = ? LIMIT 1;";
try (Connection conn = database.getDatabaseConnection();
PreparedStatement pst = conn.prepareStatement(query)
) {
pst.setString(FIELD_USER_HAS_LOGGED_EVENT_USER_ID, userId);
pst.setString(FIELD_USER_HAS_LOGGED_EVENT_EVENT_TYPE, eventType);
pst.setString(FIELD_USER_HAS_LOGGED_EVENT_DETAIL_KEY, detailKey);
pst.setString(FIELD_USER_HAS_LOGGED_EVENT_DETAIL_VALUE, detailValue);

try (ResultSet results = pst.executeQuery()) {
return results.next();
}
} catch (SQLException e) {
throw new SegueDatabaseException("Postgres exception: Unable to check for existing log event", e);
}
}

@Override
public Long getLogCountByType(final String type) throws SegueDatabaseException {
String query = "SELECT COUNT(*) AS TOTAL FROM logged_events WHERE event_type = ?";
Expand Down Expand Up @@ -212,7 +233,7 @@ public Map<String, Map<LocalDate, Long>> getLogCountByDate(final Collection<Stri
usersIdsList);

if (!result.containsKey(typeOfInterest)) {
result.put(typeOfInterest, new HashMap<LocalDate, Long>());
result.put(typeOfInterest, new HashMap<>());
}

for (Entry<Instant, Long> le : rs.entrySet()) {
Expand Down Expand Up @@ -330,13 +351,10 @@ private Map<Instant, Long> getLogsCountByMonthFilteredByUserAndType(final String
StringBuilder queryToBuild = new StringBuilder();
queryToBuild.append("WITH filtered_logs AS (SELECT * FROM logged_events WHERE event_type=?");
if (userIds != null && !userIds.isEmpty()) {
StringBuilder inParams = new StringBuilder();
inParams.append("?");
for (int i = 1; i < userIds.size(); i++) {
inParams.append(",?");
}
String inParams = "?" +

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚫 [checkstyle] <com.puppycrawl.tools.checkstyle.checks.whitespace.OperatorWrapCheck> reported by reviewdog 🐶
'+' should be on a new line.

",?".repeat(userIds.size() - 1);

queryToBuild.append(String.format(" AND user_id IN (%s)", inParams.toString()));
queryToBuild.append(String.format(" AND user_id IN (%s)", inParams));

}
queryToBuild.append(") ");
Expand Down Expand Up @@ -411,11 +429,9 @@ private Collection<LogEvent> getLogsByUserAndType(final String type, final Insta
if (userIds != null && !userIds.isEmpty()) {
StringBuilder inParams = new StringBuilder();
inParams.append("?");
for (int i = 1; i < userIds.size(); i++) {
inParams.append(",?");
}
inParams.append(",?".repeat(userIds.size() - 1));

query += String.format(" AND user_id IN (%s)", inParams.toString());
query += String.format(" AND user_id IN (%s)", inParams);

}

Expand Down Expand Up @@ -559,6 +575,12 @@ private LogEvent buildLogEvent(final String userId, final String anonymousUserId
// getLogCountByType
private static final int FIELD_GET_LOG_COUNT_EVENT_TYPE = 1;

// userHasLoggedEventWithDetail
private static final int FIELD_USER_HAS_LOGGED_EVENT_USER_ID = 1;
private static final int FIELD_USER_HAS_LOGGED_EVENT_EVENT_TYPE = 2;
private static final int FIELD_USER_HAS_LOGGED_EVENT_DETAIL_KEY = 3;
private static final int FIELD_USER_HAS_LOGGED_EVENT_DETAIL_VALUE = 4;

// getLastLogDateForAllUsers
private static final int FIELD_GET_LOG_DATE_EVENT_TYPE = 1;

Expand Down
Loading
Loading