diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fc3230..11d9147 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## XX.XX.XX +- Added the method `set_location` that sets the user's country code, city, GPS coordinates and IP address after init: + - When the session has already begun, or sessions are not tracked, the location is sent right away, otherwise with the session start + - Every later request carries it, also those that cluster workers hand to the main process + ## 26.8.0 - ! Minor breaking change ! SDK behavior settings are now enabled by default. Changes made in SDK Manager on your server affect the SDK's behavior directly and take precedence over the matching init options: - The SDK fetches the settings at start, every 4 hours by default and after a device ID change, also before consent is given, and stores them for the next start diff --git a/lib/countly.js b/lib/countly.js index c5a1197..665c03e 100644 --- a/lib/countly.js +++ b/lib/countly.js @@ -75,6 +75,7 @@ Countly.Bulk = Bulk; var maxStackTraceLinesPerThread = 30; var maxStackTraceLineLength = 200; var deviceIdType = null; + var gpsCoordinates = null; var heartBeatTimer = null; var uncaughtExceptionHandler = null; var unhandledRejectionHandler = null; @@ -414,6 +415,7 @@ Countly.Bulk = Bulk; maxStackTraceLinesPerThread = 30; maxStackTraceLineLength = 200; deviceIdType = null; + gpsCoordinates = null; if (heartBeatTimer) { clearInterval(heartBeatTimer); heartBeatTimer = null; @@ -777,6 +779,46 @@ Countly.Bulk = Bulk; } }; + /** + * Set the user's location after init. The values replace the ones given at init, and every request from then on carries them. + * When the session has already begun, or sessions are not tracked, they are also sent right away in a request of their own; + * otherwise they are sent with begin_session. An empty value clears that part of the location. + * Needs the "location" consent, and is ignored while location tracking is turned off in the SDK behavior settings. + * @param {string=} country_code - ISO code of the user's country, for example "TR". Set it together with city + * @param {string=} city - name of the user's city. Set it together with country_code + * @param {string=} gps_coordinates - latitude and longitude separated by a comma, for example "56.42345,123.45325" + * @param {string=} ip_address - IP address of the user, for example "192.168.88.33" + * @example + * Countly.set_location("TR", "Izmir", "38.4237,27.1428", null); + */ + Countly.set_location = function(country_code, city, gps_coordinates, ip_address) { + cc.log(cc.logLevelEnums.INFO, `set_location, Setting the location. Country code: [${country_code}], city: [${city}], GPS coordinates: [${gps_coordinates}], IP address: [${ip_address}].`); + if (!Countly.check_consent("location")) { + cc.log(cc.logLevelEnums.WARNING, "set_location, No consent given for location. The location was not set."); + return; + } + if (!setting("lt")) { + cc.log(cc.logLevelEnums.INFO, "set_location, Location tracking is turned off in the SDK behavior settings. The location was not set."); + return; + } + var location = { + country_code: country_code || null, + city: city || null, + gps_coordinates: gps_coordinates || null, + ip_address: ip_address || null, + }; + if ((location.country_code === null) !== (location.city === null)) { + cc.log(cc.logLevelEnums.WARNING, "set_location, Country code and city should be set together, the server may ignore one without the other."); + } + setLocationValues(location); + if (!ownsQueue()) { + process.send({ cly: { location: location } }); + } + if (sessionStarted || !Countly.check_consent("sessions") || !setting("st")) { + toRequestQueue({}); + } + }; + /** * Check and return the current device id type * @returns {number} a number that indicates the device id type @@ -1754,6 +1796,9 @@ Countly.Bulk = Bulk; if (Countly.ip_address !== null) { request.ip_address = Countly.ip_address; } + if (gpsCoordinates) { + request.location = gpsCoordinates; + } } else { request.location = ""; @@ -1766,6 +1811,17 @@ Countly.Bulk = Bulk; request.tz = -date.getTimezoneOffset(); } + /** + * Store the location values that prepareRequest adds to every request + * @param {Object} location - country_code, city, gps_coordinates and ip_address, each a string or null + */ + function setLocationValues(location) { + Countly.country_code = location.country_code; + Countly.city = location.city; + Countly.ip_address = location.ip_address; + gpsCoordinates = location.gps_coordinates; + } + /** * Add request to request queue * @param {Object} request - object with request parameters @@ -2757,6 +2813,9 @@ Countly.Bulk = Bulk; else if (msg.cly.change_id) { Countly.change_id(msg.cly.change_id, msg.cly.merge); } + else if (msg.cly.location) { + setLocationValues(msg.cly.location); + } else if (msg.cly.event) { Countly.add_event(msg.cly.event); } diff --git a/test/helpers/scripts/cluster_app.js b/test/helpers/scripts/cluster_app.js index c2fce5b..cc64f8f 100644 --- a/test/helpers/scripts/cluster_app.js +++ b/test/helpers/scripts/cluster_app.js @@ -12,7 +12,7 @@ const config = { }; if (cluster.isMaster) { - if (mode === "primary_sdk" || mode === "worker_switch") { + if (mode === "primary_sdk" || mode === "worker_switch" || mode === "worker_location") { config.device_id = "cluster-primary"; Countly.init(config); } @@ -40,6 +40,9 @@ else { } Countly.init(config); Countly.begin_session(); + if (mode === "worker_location") { + Countly.set_location("TR", "Izmir", "38.4237,27.1428", null); + } Countly.add_event({ key: "worker_event" }); console.log(`worker device id: ${Countly.get_device_id()}`); setInterval(() => { }, 1000); diff --git a/test/tests_cluster.js b/test/tests_cluster.js index 7d284ce..2a5cc8f 100644 --- a/test/tests_cluster.js +++ b/test/tests_cluster.js @@ -94,4 +94,19 @@ describe("Cluster tests", () => { assert.equal(receivedEvents(result.requests).filter((e) => e.key === "worker_event").length, 0, "the worker's custom event was recorded although custom events are turned off"); assert.equal(result.requests.filter((r) => r.params.begin_session).length, 0, "the worker's session was recorded although sessions are turned off"); }); + + it("9- a location set in a worker is sent with the data the main process sends after it", async() => { + const result = await runCluster("worker_location"); + const begin = result.requests.find((r) => r.params.begin_session === "1"); + assert.ok(begin, "the worker's session did not reach the main process"); + assert.equal(begin.params.city, undefined); + const located = result.requests.filter((r) => r.params.city === "Izmir"); + assert.ok(located.length >= 2, "the location request and the event request do not both carry the location"); + assert.ok(receivedEvents(located).some((e) => e.key === "worker_event"), "the worker's event was sent without the location"); + located.forEach((r) => { + assert.equal(r.params.device_id, "cluster-primary"); + assert.equal(r.params.country_code, "TR"); + assert.equal(r.params.location, "38.4237,27.1428"); + }); + }); }); diff --git a/test/tests_location.js b/test/tests_location.js new file mode 100644 index 0000000..ee63e7a --- /dev/null +++ b/test/tests_location.js @@ -0,0 +1,110 @@ +var assert = require("assert"); +var Countly = require("../lib/countly"); +var hp = require("./helpers/helper_functions"); + +/** + * Initializes the SDK with a fixed device ID + * @param {Object=} extra - init options to add + */ +function initMain(extra) { + Countly.init({ + app_key: "YOUR_APP_KEY", + url: "https://try.count.ly", + device_id: "location-user", + max_events: -1, + ...extra, + }); +} + +/** + * Returns the location parameters that a queued request carries + * @param {Object} request - request read from the request queue + * @returns {Object} the request's country_code, city, location and ip_address, without the ones it does not carry + */ +function locationOf(request) { + var result = {}; + ["country_code", "city", "location", "ip_address"].forEach((key) => { + if (key in request) { + result[key] = request[key]; + } + }); + return result; +} + +describe("Location tests", () => { + beforeEach(async() => { + await hp.clearStorage(); + }); + it("1- after begin_session, sends the location right away and with every later request", (done) => { + initMain(); + Countly.begin_session(); + Countly.set_location("TR", "Izmir", "38.4237,27.1428", "10.0.0.1"); + Countly.end_session(10); + setTimeout(() => { + var rq = hp.readRequestQueue(); + assert.equal(rq.length, 3); + assert.equal(rq[0].begin_session, 1); + assert.deepStrictEqual(locationOf(rq[0]), {}); + for (var i = 1; i < 3; i++) { + hp.requestBaseParamValidator(rq[i]); + assert.deepStrictEqual(locationOf(rq[i]), { country_code: "TR", city: "Izmir", location: "38.4237,27.1428", ip_address: "10.0.0.1" }); + } + assert.equal(rq[1].begin_session, undefined); + assert.equal(rq[2].end_session, 1); + done(); + }, hp.sWait); + }); + it("2- before begin_session, sends the location with begin_session only", (done) => { + initMain(); + Countly.set_location("TR", "Izmir", "38.4237,27.1428", null); + Countly.begin_session(); + setTimeout(() => { + var rq = hp.readRequestQueue(); + assert.equal(rq.length, 1); + assert.equal(rq[0].begin_session, 1); + assert.deepStrictEqual(locationOf(rq[0]), { country_code: "TR", city: "Izmir", location: "38.4237,27.1428" }); + done(); + }, hp.sWait); + }); + it("3- replaces the location given at init, and empty values clear it", (done) => { + initMain({ country_code: "DE", city: "Berlin", ip_address: "10.0.0.2" }); + Countly.begin_session(); + Countly.set_location("TR", "Izmir"); + Countly.set_location(null, "", undefined, null); + setTimeout(() => { + var rq = hp.readRequestQueue(); + assert.equal(rq.length, 3); + assert.deepStrictEqual(locationOf(rq[0]), { country_code: "DE", city: "Berlin", ip_address: "10.0.0.2" }); + assert.deepStrictEqual(locationOf(rq[1]), { country_code: "TR", city: "Izmir" }); + assert.deepStrictEqual(locationOf(rq[2]), {}); + done(); + }, hp.sWait); + }); + it("4- without the sessions consent, sends the location right away", (done) => { + initMain({ require_consent: true }); + Countly.add_consent("location"); + Countly.set_location("TR", "Izmir"); + setTimeout(() => { + var located = hp.readRequestQueue().filter((r) => r.city === "Izmir"); + assert.equal(located.length, 1); + assert.deepStrictEqual(locationOf(located[0]), { country_code: "TR", city: "Izmir" }); + done(); + }, hp.sWait); + }); + it("5- without the location consent, the location is not set", (done) => { + initMain({ require_consent: true }); + Countly.add_consent("sessions"); + Countly.begin_session(); + Countly.set_location("TR", "Izmir", "38.4237,27.1428", "10.0.0.1"); + setTimeout(() => { + assert.equal(Countly.country_code, null); + assert.equal(Countly.city, null); + var rq = hp.readRequestQueue(); + assert.ok(rq.length > 0); + rq.forEach((r) => { + assert.deepStrictEqual(locationOf(r), { location: "" }); + }); + done(); + }, hp.sWait); + }); +}); diff --git a/test/tests_storage_safety.js b/test/tests_storage_safety.js index 6fe71ff..e0be05e 100644 --- a/test/tests_storage_safety.js +++ b/test/tests_storage_safety.js @@ -102,7 +102,8 @@ describe("Storage safety tests", () => { const cwd = makeTempDir("default-location"); const run = await runScript("storage_location.js", { DEVICE_ID: "default-user" }, 20000, cwd); const output = lastLine(run.stdout); - assert.equal(output.storagePath, path.join(cwd, ".countly-storage")); + // the script sees its working folder with symlinks resolved, /private/var instead of /var on macOS + assert.equal(output.storagePath, path.join(fs.realpathSync(cwd), ".countly-storage")); assert.equal(readStoredFile(path.join(cwd, ".countly-storage", "__cly_id.json"), "cly_id"), "default-user"); assert.equal(fs.existsSync(path.join(oldDefaultDir, "__cly_id.json")), false); });