From 78b67f42e56726b392203269f06c5bc74677c1ec Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Fri, 23 Oct 2026 10:51:17 +0300 Subject: [PATCH 1/3] feat: add set_location for setting the location after init --- CHANGELOG.md | 3 + lib/countly.js | 59 +++++++++++++++ test/helpers/scripts/cluster_app.js | 9 ++- test/tests_cluster.js | 15 ++++ test/tests_location.js | 113 ++++++++++++++++++++++++++++ 5 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 test/tests_location.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fc3230..2661341 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## 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..34aa553 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); } @@ -33,6 +33,13 @@ else if (mode === "worker_switch") { }, 500); setInterval(() => { }, 1000); } +else if (mode === "worker_location") { + Countly.init(config); + Countly.begin_session(); + Countly.set_location("TR", "Izmir", "38.4237,27.1428", null); + Countly.add_event({ key: "worker_event" }); + setInterval(() => { }, 1000); +} else { config.standalone_worker = mode === "standalone"; if (mode === "standalone") { diff --git a/test/tests_cluster.js b/test/tests_cluster.js index 7d284ce..c3bee10 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(located.some((r) => r.params.events && JSON.parse(r.params.events).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..4b3c445 --- /dev/null +++ b/test/tests_location.js @@ -0,0 +1,113 @@ +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, + }); +} + +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.equal(rq[0].country_code, undefined); + assert.equal(rq[0].city, undefined); + assert.equal(rq[0].location, undefined); + assert.equal(rq[0].ip_address, undefined); + for (var i = 1; i < 3; i++) { + hp.requestBaseParamValidator(rq[i]); + assert.equal(rq[i].country_code, "TR"); + assert.equal(rq[i].city, "Izmir"); + assert.equal(rq[i].location, "38.4237,27.1428"); + assert.equal(rq[i].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.equal(rq[0].country_code, "TR"); + assert.equal(rq[0].city, "Izmir"); + assert.equal(rq[0].location, "38.4237,27.1428"); + assert.equal(rq[0].ip_address, undefined); + 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.equal(rq[0].country_code, "DE"); + assert.equal(rq[0].city, "Berlin"); + assert.equal(rq[0].ip_address, "10.0.0.2"); + assert.equal(rq[1].country_code, "TR"); + assert.equal(rq[1].city, "Izmir"); + assert.equal(rq[1].location, undefined); + assert.equal(rq[1].ip_address, undefined); + assert.equal(rq[2].country_code, undefined); + assert.equal(rq[2].city, undefined); + assert.equal(rq[2].location, undefined); + assert.equal(rq[2].ip_address, undefined); + 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.equal(located[0].country_code, "TR"); + 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.equal(r.city, undefined); + assert.equal(r.location, ""); + }); + done(); + }, hp.sWait); + }); +}); From 75a3556d9ef9dd26fab8d1c887fe4afc6e39d74f Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 27 Oct 2026 10:15:40 +0300 Subject: [PATCH 2/3] chore: simplify the location tests and wrap the changelog entry --- CHANGELOG.md | 4 ++- test/helpers/scripts/cluster_app.js | 10 ++---- test/tests_cluster.js | 2 +- test/tests_location.js | 49 ++++++++++++++--------------- 4 files changed, 30 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2661341..11d9147 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## 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 +- 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: diff --git a/test/helpers/scripts/cluster_app.js b/test/helpers/scripts/cluster_app.js index 34aa553..cc64f8f 100644 --- a/test/helpers/scripts/cluster_app.js +++ b/test/helpers/scripts/cluster_app.js @@ -33,13 +33,6 @@ else if (mode === "worker_switch") { }, 500); setInterval(() => { }, 1000); } -else if (mode === "worker_location") { - Countly.init(config); - Countly.begin_session(); - Countly.set_location("TR", "Izmir", "38.4237,27.1428", null); - Countly.add_event({ key: "worker_event" }); - setInterval(() => { }, 1000); -} else { config.standalone_worker = mode === "standalone"; if (mode === "standalone") { @@ -47,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 c3bee10..2a5cc8f 100644 --- a/test/tests_cluster.js +++ b/test/tests_cluster.js @@ -102,7 +102,7 @@ describe("Cluster tests", () => { 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(located.some((r) => r.params.events && JSON.parse(r.params.events).some((e) => e.key === "worker_event")), "the worker's event was sent without 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"); diff --git a/test/tests_location.js b/test/tests_location.js index 4b3c445..ee63e7a 100644 --- a/test/tests_location.js +++ b/test/tests_location.js @@ -16,6 +16,21 @@ function initMain(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(); @@ -29,16 +44,10 @@ describe("Location tests", () => { var rq = hp.readRequestQueue(); assert.equal(rq.length, 3); assert.equal(rq[0].begin_session, 1); - assert.equal(rq[0].country_code, undefined); - assert.equal(rq[0].city, undefined); - assert.equal(rq[0].location, undefined); - assert.equal(rq[0].ip_address, undefined); + assert.deepStrictEqual(locationOf(rq[0]), {}); for (var i = 1; i < 3; i++) { hp.requestBaseParamValidator(rq[i]); - assert.equal(rq[i].country_code, "TR"); - assert.equal(rq[i].city, "Izmir"); - assert.equal(rq[i].location, "38.4237,27.1428"); - assert.equal(rq[i].ip_address, "10.0.0.1"); + 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); @@ -53,10 +62,7 @@ describe("Location tests", () => { var rq = hp.readRequestQueue(); assert.equal(rq.length, 1); assert.equal(rq[0].begin_session, 1); - assert.equal(rq[0].country_code, "TR"); - assert.equal(rq[0].city, "Izmir"); - assert.equal(rq[0].location, "38.4237,27.1428"); - assert.equal(rq[0].ip_address, undefined); + assert.deepStrictEqual(locationOf(rq[0]), { country_code: "TR", city: "Izmir", location: "38.4237,27.1428" }); done(); }, hp.sWait); }); @@ -68,17 +74,9 @@ describe("Location tests", () => { setTimeout(() => { var rq = hp.readRequestQueue(); assert.equal(rq.length, 3); - assert.equal(rq[0].country_code, "DE"); - assert.equal(rq[0].city, "Berlin"); - assert.equal(rq[0].ip_address, "10.0.0.2"); - assert.equal(rq[1].country_code, "TR"); - assert.equal(rq[1].city, "Izmir"); - assert.equal(rq[1].location, undefined); - assert.equal(rq[1].ip_address, undefined); - assert.equal(rq[2].country_code, undefined); - assert.equal(rq[2].city, undefined); - assert.equal(rq[2].location, undefined); - assert.equal(rq[2].ip_address, undefined); + 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); }); @@ -89,7 +87,7 @@ describe("Location tests", () => { setTimeout(() => { var located = hp.readRequestQueue().filter((r) => r.city === "Izmir"); assert.equal(located.length, 1); - assert.equal(located[0].country_code, "TR"); + assert.deepStrictEqual(locationOf(located[0]), { country_code: "TR", city: "Izmir" }); done(); }, hp.sWait); }); @@ -104,8 +102,7 @@ describe("Location tests", () => { var rq = hp.readRequestQueue(); assert.ok(rq.length > 0); rq.forEach((r) => { - assert.equal(r.city, undefined); - assert.equal(r.location, ""); + assert.deepStrictEqual(locationOf(r), { location: "" }); }); done(); }, hp.sWait); From 99b3e708ba3bcbe91233e2392c17664dc21e653e Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 27 Oct 2026 10:25:17 +0300 Subject: [PATCH 3/3] fix: test --- test/tests_storage_safety.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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); });