diff --git a/lib/controllers/v1/computervision_controller.js b/lib/controllers/v1/computervision_controller.js index 5949958f..c04eaf70 100644 --- a/lib/controllers/v1/computervision_controller.js +++ b/lib/controllers/v1/computervision_controller.js @@ -228,9 +228,17 @@ const ComputervisionController = class ComputervisionController { formData.append( "lat", req.body.lat ); formData.append( "lng", req.body.lng ); } - if ( req.userSession?.isAdmin && ( - req.body.include_representative_photos || req.query.include_representative_photos - ) ) { + + if ( config.imageProcesing.representativePhotoRolloutPercentage ) { + req.inat.isInRepresentativePhotosTestGroup = util.inRandomGrouping( + config.imageProcesing.representativePhotoRolloutPercentage + ); + } + if ( ( + req.userSession?.isAdmin && ( + req.body.include_representative_photos || req.query.include_representative_photos + ) ) || req.inat.isInRepresentativePhotosTestGroup + ) { formData.append( "return_embedding", "true" ); } @@ -268,7 +276,7 @@ const ComputervisionController = class ComputervisionController { return scores; } - static async addRepresentativePhotos( results, embedding ) { + static async addRepresentativePhotos( req, results, embedding ) { if ( _.isEmpty( embedding ) ) { return; } @@ -322,6 +330,11 @@ const ComputervisionController = class ComputervisionController { await pool.start( ); await ObservationPreload.assignObservationPhotoPhotos( representativeTaxonPhotos ); + if ( req.inat.isInRepresentativePhotosTestGroup ) { + // if the request is in the test group, return after performing all queries, + // but before modifying the contents of the results + return; + } _.each( representativeTaxonPhotos, taxonPhoto => { if ( taxonPhoto?.photo?.url ) { taxonPhoto.taxon.representative_photo = taxonPhoto.photo; @@ -387,10 +400,14 @@ const ComputervisionController = class ComputervisionController { } ); } - if ( req.userSession?.isAdmin && ( - req.body.include_representative_photos || req.query.include_representative_photos - ) ) { + if ( ( + req.userSession?.isAdmin && ( + req.body.include_representative_photos || req.query.include_representative_photos + ) ) || req.inat.isInRepresentativePhotosTestGroup + ) { + req.inat.requestContext = "representativePhotos"; await ComputervisionController.addRepresentativePhotos( + req, withTaxa, visionApiResponse.embedding ); diff --git a/lib/logstasher.js b/lib/logstasher.js index 95622ac7..2c984dd0 100644 --- a/lib/logstasher.js +++ b/lib/logstasher.js @@ -138,6 +138,8 @@ const Logstasher = class Logstasher { payload.subtype = "ClientMessage"; payload.error_message = req.body.message; } + } else if ( req.inat.requestContext ) { + payload.context = req.inat.requestContext; } return payload; } diff --git a/lib/util.js b/lib/util.js index e34913f3..a069a324 100644 --- a/lib/util.js +++ b/lib/util.js @@ -663,6 +663,21 @@ const util = class util { } return null; } + + // accepts a `percentage` parameter which is a number from 0 to 100. A `percentage` + // of 0 will always return false. A `percentage` of 100 will always return true. All + // other values will return true `percentage`% of the time + static inRandomGrouping( percentage = 0 ) { + if ( !_.isNumber( percentage ) || percentage <= 0 ) { + return false; + } + if ( percentage >= 100 ) { + return true; + } + // return true if the requested value is less than or equal to a random + // number from 1 to 100 inclusive + return percentage >= ( _.random( 99 ) + 1 ); + } }; util.iucnValues = { diff --git a/test/logstasher.js b/test/logstasher.js index 7a90ac9c..c04cce7d 100644 --- a/test/logstasher.js +++ b/test/logstasher.js @@ -58,4 +58,22 @@ describe( "Logstasher", ( ) => { { headers: { "x-real-ip": "127.0.0.1, 192.168.1.1" } } ) ).to.eq( "192.168.1.1" ); } ); + + describe( "afterRequestPayload", ( ) => { + it( "includes request context", ( ) => { + const req = { inat: { requestContext: "inatContext" } }; + expect( Logstasher.afterRequestPayload( req ).context ).to.eq( "inatContext" ); + } ); + + it( "prioritizes context from log response bodies", ( ) => { + const req = { + _logClientError: true, + inat: { requestContext: "inatContext" }, + body: { + context: "logContext" + } + }; + expect( Logstasher.afterRequestPayload( req ).context ).to.eq( "logContext" ); + } ); + } ); } ); diff --git a/test/util.js b/test/util.js index f0fb1d33..f1dc90c8 100644 --- a/test/util.js +++ b/test/util.js @@ -1,3 +1,4 @@ +const _ = require( "lodash" ); const { expect } = require( "chai" ); const crypto = require( "crypto" ); const util = require( "../lib/util" ); @@ -306,4 +307,31 @@ describe( "util", ( ) => { } ) ).to.eq( `ObservationsController.search-fields-${fieldsHash}` ); } ); } ); + + describe( "inRandomGrouping", ( ) => { + it( "returns true when percentage is at or above 100", ( ) => { + let samples = _.times( 10000, util.inRandomGrouping( 100 ) ); + expect( _.every( samples, sample => sample === true ) ); + samples = _.times( 10000, util.inRandomGrouping( 110 ) ); + expect( _.every( samples, sample => sample === true ) ); + } ); + + it( "returns false when percentage is at or below 0", ( ) => { + let samples = _.times( 10000, util.inRandomGrouping( 0 ) ); + expect( _.every( samples, sample => sample === false ) ); + samples = _.times( 10000, util.inRandomGrouping( -1 ) ); + expect( _.every( samples, sample => sample === false ) ); + } ); + + it( "returns true roughly `percentage` percent of the time", ( ) => { + const testPercentages = [1, 5, 10, 20, 30, 40, 50, 60, 70, 80, 99]; + _.each( testPercentages, testPercentage => { + const samples = _.times( 100000, ( ) => util.inRandomGrouping( testPercentage ) ); + const countTrue = _.filter( samples, sample => sample === true ).length; + const truePercentage = ( countTrue / samples.length ) * 100; + expect( truePercentage ).to.be.below( testPercentage + 2 ); + expect( truePercentage ).to.be.above( testPercentage - 2 ); + } ); + } ); + } ); } );