diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ecadd2..e0b241d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## XX.XX.XX +## NEXT RELEASE * Added web push notification support. Give the SDK the new `push` consent and call `enable_push_notifications` once from a user gesture, passing the VAPID public key from your application settings; from then on it keeps the subscription token in sync and records notification clicks on its own. `disable_push_notifications` is remembered across page loads, so the automatic registration never undoes it; only another explicit `enable_push_notifications` subscribes again. Sites that already run a service worker import `countly_sw.js` into it (see `examples/example_custom_sw.js`) and pass their registration as `push_service_worker_registration`; the imported handlers only act on Countly's own pushes and notifications, and `self.COUNTLY_PUSH_LIFECYCLE = false` keeps the host worker's install/activate behaviour. * Added `push_notification_listener` (also `set_push_notification_listener`) to be told when a notification is received, clicked (which button, its title and URL) or closed, together with the message's custom payload. "closed" is best effort and differs by browser and OS; Safari and desktop Firefox show no action buttons, and both can report "closed" right after "clicked". @@ -6,6 +6,7 @@ * A click while no page of the site is open (a closed browser, or a button that leads to another site) is now recorded by the service worker itself, using the server details the page hands it; when that is not possible the click waits for the next page, in IndexedDB rather than in worker memory, so it survives the browser stopping the worker. The next page still gets the "clicked" listener callback for it. * `enable_push_notifications` gives up with reason "timeout" when the browser never finishes creating the subscription (seen on iOS 18.7), so a later call can retry instead of joining the stuck one; `push_subscribe_timeout` (milliseconds, default 30000) sets the wait. The silent registration at load now logs its outcome. * With `debug: true` the service worker logs every push, click and close too, and forwards the lines to the page as `[SW]` entries. A host worker turns this on with `self.COUNTLY_PUSH_DEBUG = true`. +* Added support for server initiated connection tests. When the server asks for one, the SDK probes the endpoints it depends on and reports which of them are reachable. ## 26.1.3 diff --git a/cypress/e2e/connection_test.cy.js b/cypress/e2e/connection_test.cy.js new file mode 100644 index 0000000..0a2461d --- /dev/null +++ b/cypress/e2e/connection_test.cy.js @@ -0,0 +1,565 @@ +/* eslint-disable require-jsdoc */ +var ct = require("../../modules/ConnectionTest.js"); +var Countly = require("../../Countly.js"); +var hp = require("../support/helper"); + +var ROW_ORDER = [ + "core", "core-write", "sc", "rc", "ab", "feedback", "feedback-widget", + "feedback-submit", "content", "feedback-page", "feedback-assets", "content-page" +]; + +// Deliver the server config through the SDK's own request machinery, armed or not, +// exactly as a real sc response would arrive. +function initWithServerConfig(armed) { + Countly.init({ + app_key: hp.appKey, + url: "https://test.count.ly", + test_mode: true, + debug: true, + fake_request_handler: function(req) { + if (req.functionName === "server_config") { + var config = { v: 2, t: 1786273877636, c: {} }; + if (armed) { + config.ct = 1; + } + return { status: 200, responseText: JSON.stringify(config) }; + } + return { status: 200, responseText: '{"result":"Success"}' }; + } + }); +} + +describe("Connection test URL building", () => { + it("keeps the path prefix of a reverse-proxied server url", () => { + expect(ct.buildProbeUrl("https://x.com/countly", "/o/ping")).to.equal("https://x.com/countly/o/ping"); + }); + + it("strips a trailing slash from the server url", () => { + expect(ct.buildProbeUrl("https://x.com/countly/", "/o/ping")).to.equal("https://x.com/countly/o/ping"); + }); + + it("keeps the query string of a probe path", () => { + expect(ct.buildProbeUrl("https://x.com", "/o/sdk?method=rc")).to.equal("https://x.com/o/sdk?method=rc"); + }); +}); + +describe("Connection test grading", () => { + it("grades a 2xx as reachable with no detail", () => { + expect(ct.gradeProbe({ status: 200 })).to.deep.equal({ ok: true, st: 200 }); + }); + + it("grades an application 400 as reachable, since the app answered", () => { + expect(ct.gradeProbe({ status: 400 })).to.deep.equal({ ok: true, st: 400 }); + }); + + it("grades a 403 as unreachable even though it is a 4xx", () => { + expect(ct.gradeProbe({ status: 403 })).to.deep.equal({ + ok: false, + st: 403, + e: "Expected the Countly application to answer, but a proxy rejected the request with HTTP 403 before it got there." + }); + }); + + it("grades a 5xx as unreachable", () => { + expect(ct.gradeProbe({ status: 502 })).to.deep.equal({ + ok: false, + st: 502, + e: "Expected the Countly application to answer, but the gateway or an upstream service failed with HTTP 502." + }); + }); + + it("grades a readable redirect status as unreachable", () => { + expect(ct.gradeProbe({ status: 302 })).to.deep.equal({ + ok: false, + st: 302, + e: "Expected a direct answer from Countly, but the request was redirected away with HTTP 302." + }); + }); + + it("grades a rejected request as a transport error", () => { + expect(ct.gradeProbe({ rejected: true })).to.deep.equal({ + ok: false, + st: 0, + e: "Expected the server to answer, but the connection failed before any response arrived (DNS, TLS, certificate pinning, or the request was blocked)." + }); + }); + + it("distinguishes a timeout from a generic transport error", () => { + expect(ct.gradeProbe({ rejected: true, timedOut: true })).to.deep.equal({ + ok: false, + st: 0, + e: "Expected an answer within 10s, but the request never completed." + }); + }); + + it("grades an opaque-redirect response as redirected, not as a transport error", () => { + expect(ct.gradeProbe({ redirected: true })).to.deep.equal({ + ok: false, + st: 0, + e: "Expected a direct answer from Countly, but the request was redirected away; the browser hides the redirect status." + }); + }); + + it("grades a resolved opaque response as reachable, flagged as header stripping", () => { + expect(ct.gradeProbe({ opaque: true })).to.deep.equal({ + ok: true, + st: 0, + e: ct.CT_DETAIL.OPAQUE + }); + }); + + it("fails a strict-2xx row on a 404, so a ping answering DB Error is not green", () => { + expect(ct.gradeProbe({ status: 404 }, { strict2xx: true })).to.deep.equal({ + ok: false, + st: 404, + e: "Expected a 2xx from this route, which has no legitimate 4xx, but got HTTP 404." + }); + }); + + it("passes a strict-2xx row on a real 2xx", () => { + expect(ct.gradeProbe({ status: 200 }, { strict2xx: true })).to.deep.equal({ ok: true, st: 200 }); + }); + + it("does not apply the strict-2xx rule to an opaque probe", () => { + expect(ct.gradeProbe({ opaque: true }, { strict2xx: true })).to.deep.equal({ + ok: true, + st: 0, + e: ct.CT_DETAIL.OPAQUE + }); + }); + + it("keeps every explanation inside the 256 character field limit", () => { + Object.keys(ct.CT_DETAIL).forEach((key) => { + var detail = ct.CT_DETAIL[key]; + if (typeof detail === "string") { + expect(detail.length, key).to.be.at.most(256); + } + }); + }); +}); + +describe("Connection test multi-path rows", () => { + it("reports the first status and the summed latency when every path answered", () => { + var row = ct.combineRow([ + { ok: true, st: 400, ms: 10 }, + { ok: true, st: 404, ms: 20 }, + { ok: true, st: 400, ms: 30 } + ]); + expect(row).to.deep.equal({ ok: true, st: 400, ms: 60, n: 3 }); + }); + + it("fails the whole row when one path is blocked, reporting that path's status", () => { + var row = ct.combineRow([ + { ok: true, st: 400, ms: 10 }, + { ok: false, st: 403, ms: 20, e: "blocked" }, + { ok: true, st: 400, ms: 30 } + ]); + expect(row).to.deep.equal({ ok: false, st: 403, ms: 60, n: 3, e: "blocked" }); + }); + + it("carries the opaque qualifier when every path was probed opaquely", () => { + var row = ct.combineRow([ + { ok: true, st: 0, ms: 10, e: ct.CT_DETAIL.OPAQUE }, + { ok: true, st: 0, ms: 20, e: ct.CT_DETAIL.OPAQUE } + ]); + expect(row).to.deep.equal({ ok: true, st: 0, ms: 30, n: 2, e: ct.CT_DETAIL.OPAQUE }); + }); + + it("prefers the failing path's reason over the opaque qualifier", () => { + var row = ct.combineRow([ + { ok: true, st: 0, ms: 10, e: ct.CT_DETAIL.OPAQUE }, + { ok: false, st: 0, ms: 20, e: "dead" } + ]); + expect(row).to.deep.equal({ ok: false, st: 0, ms: 30, n: 2, e: "dead" }); + }); +}); + +// A stub transport: the network is the one thing that cannot be exercised deterministically, +// so probes are answered from a table while every assertion below is on what the battery +// itself did — which URLs it built, in what order, and how it shaped the report. +function stubProbe(byPath, calls) { + return function(url, opts) { + var mode = opts && opts.mode; + calls.push({ url: url, mode: mode }); + var outcome = { status: 400, ms: 5 }; + Object.keys(byPath).forEach((path) => { + if (url.indexOf(path) !== -1) { + outcome = byPath[path]; + } + }); + // a table entry may answer differently per attempt mode, which is how the + // CORS-first / opaque-fallback sequence is exercised + if (typeof outcome === "function") { + outcome = outcome(mode); + } + return Promise.resolve(outcome); + }; +} + +function baseContext(overrides) { + var ctx = { + url: "https://x.com/countly", + sdkName: "javascript_native_web", + sdkVersion: "26.1.3", + sc: { status: 200, ms: 112 }, + tier2: "unsupported" + }; + Object.keys(overrides || {}).forEach((k) => { + ctx[k] = overrides[k]; + }); + return ctx; +} + +describe("Connection test battery", () => { + it("reports every row in table order", () => { + var calls = []; + var ctx = baseContext({ probe: stubProbe({ "/o/ping": { status: 200, ms: 5 } }, calls) }); + return ct.runConnectionTest(ctx).then((report) => { + expect(report.results.map((r) => r.f)).to.deep.equal([ + "core", "core-write", "sc", "rc", "ab", "feedback", "feedback-widget", + "feedback-submit", "content", "feedback-page", "feedback-assets", "content-page" + ]); + }); + }); + + it("identifies the SDK and stamps the device clock", () => { + var calls = []; + var ctx = baseContext({ probe: stubProbe({ "/o/ping": { status: 200, ms: 5 } }, calls) }); + return ct.runConnectionTest(ctx).then((report) => { + expect(report.sdk).to.deep.equal({ name: "javascript_native_web", version: "26.1.3" }); + expect(report.ts).to.be.a("number"); + }); + }); + + it("reuses the sc fetch latency instead of issuing a request for row 3", () => { + var calls = []; + var ctx = baseContext({ probe: stubProbe({ "/o/ping": { status: 200, ms: 5 } }, calls) }); + return ct.runConnectionTest(ctx).then((report) => { + var sc = report.results.find((r) => r.f === "sc"); + expect(sc).to.deep.equal({ f: "sc", ok: true, st: 200, ms: 112 }); + expect(calls.map((c) => c.url).join(" ")).to.not.contain("method=sc"); + }); + }); + + it("probes every Tier 1 path against the configured url, prefix included", () => { + var calls = []; + var ctx = baseContext({ probe: stubProbe({ "/o/ping": { status: 200, ms: 5 } }, calls) }); + return ct.runConnectionTest(ctx).then(() => { + expect(calls.length).to.equal(10); + calls.forEach((c) => expect(c.url).to.contain("https://x.com/countly/")); + expect(calls.some((c) => c.url.indexOf("/countly/o/ping") !== -1)).to.be.true; + expect(calls.some((c) => c.url.indexOf("/countly/o/surveys/nps/widget") !== -1)).to.be.true; + }); + }); + + it("skips Tier 2 as unsupported without probing it", () => { + var calls = []; + var ctx = baseContext({ probe: stubProbe({ "/o/ping": { status: 200, ms: 5 } }, calls) }); + return ct.runConnectionTest(ctx).then((report) => { + var page = report.results.find((r) => r.f === "feedback-page"); + expect(page).to.deep.equal({ f: "feedback-page", sk: "unsupported" }); + expect(calls.some((c) => c.url.indexOf("/feedback/nps") !== -1)).to.be.false; + }); + }); + + it("runs the probes sequentially", () => { + var inFlight = 0; + var maxInFlight = 0; + var ctx = baseContext({ + probe: function() { + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + return new Promise((resolve) => { + setTimeout(() => { + inFlight--; + resolve({ status: 200, ms: 1 }); + }, 1); + }); + } + }); + return ct.runConnectionTest(ctx).then(() => { + expect(maxInFlight).to.equal(1); + }); + }); + + it("applies the strict-2xx rule to core, so a ping answering DB Error is red", () => { + var calls = []; + var ctx = baseContext({ probe: stubProbe({ "/o/ping": { status: 404, ms: 5 } }, calls) }); + return ct.runConnectionTest(ctx).then((report) => { + var core = report.results.find((r) => r.f === "core"); + expect(core).to.deep.equal({ + f: "core", + ok: false, + st: 404, + ms: 5, + e: "Expected a 2xx from this route, which has no legitimate 4xx, but got HTTP 404." + }); + }); + }); + + it("caps the battery over attempted requests, not over rows", () => { + expect(ct.batteryCapMs(16)).to.equal(190000); + expect(ct.batteryCapMs(10)).to.equal(130000); + }); + + it("marks rows it never reached once the battery cap elapses", () => { + var calls = []; + var clock = 0; + var ctx = baseContext({ + now: function() { + return clock; + }, + probe: function(url) { + calls.push(url); + clock = 999999; // the first probe alone blows the whole battery budget + return Promise.resolve({ status: 200, ms: 5 }); + } + }); + return ct.runConnectionTest(ctx).then((report) => { + expect(calls.length).to.equal(1); + var byKey = {}; + report.results.forEach((r) => { + byKey[r.f] = r; + }); + expect(byKey.core).to.deep.equal({ f: "core", ok: true, st: 200, ms: 5 }); + expect(byKey["core-write"]).to.deep.equal({ f: "core-write", ok: false, st: 0, e: ct.CT_DETAIL.NOT_RUN }); + expect(byKey.content).to.deep.equal({ f: "content", ok: false, st: 0, e: ct.CT_DETAIL.NOT_RUN }); + }); + }); + + it("still reports the free rows after the cap, since neither costs a request", () => { + var clock = 0; + var ctx = baseContext({ + now: function() { + return clock; + }, + probe: function() { + clock = 999999; + return Promise.resolve({ status: 200, ms: 5 }); + } + }); + return ct.runConnectionTest(ctx).then((report) => { + var byKey = {}; + report.results.forEach((r) => { + byKey[r.f] = r; + }); + expect(byKey.sc).to.deep.equal({ f: "sc", ok: true, st: 200, ms: 112 }); + expect(byKey["feedback-page"]).to.deep.equal({ f: "feedback-page", sk: "unsupported" }); + }); + }); + + it("folds a multi-path row that fell back on every path into one opaque verdict", () => { + var calls = []; + var strippedHeaders = (mode) => (mode === "cors" ? { rejected: true, ms: 2 } : { opaque: true, ms: 5 }); + var ctx = baseContext({ + tier2: "cors-first", + probe: stubProbe({ + "/o/ping": { status: 200, ms: 5 }, + "/feedback/nps": strippedHeaders, + "/feedback/survey": strippedHeaders, + "/feedback/rating": strippedHeaders + }, calls) + }); + return ct.runConnectionTest(ctx).then((report) => { + var page = report.results.filter((r) => r.f === "feedback-page")[0]; + // each path costs two attempts, so ms covers all six + expect(page).to.deep.equal({ f: "feedback-page", ok: true, st: 0, ms: 21, n: 3, e: ct.CT_DETAIL.OPAQUE }); + expect(calls.filter((c) => c.url.indexOf("/countly/feedback/") !== -1).length).to.equal(6); + }); + }); +}); + +describe("Connection test integration", () => { + beforeEach(() => { + // the probes are real requests from the SDK, answered here the way a healthy + // server answers a parameterless GET + cy.intercept({ url: "https://test.count.ly/**" }, { statusCode: 400, body: { result: "Missing parameter" } }); + cy.intercept("GET", "https://test.count.ly/o/ping*", { statusCode: 200, body: { result: "Success" } }); + }); + + it("runs the battery and queues a ct_results report when the server arms it", () => { + hp.haltAndClearStorage(() => { + initWithServerConfig(true); + cy.wait(3000).then(() => { + cy.fetch_local_request_queue().then((rq) => { + var reports = rq.filter((r) => r.ct_results); + expect(reports.length).to.equal(1); + var report = JSON.parse(reports[0].ct_results); + expect(report.results.map((r) => r.f)).to.deep.equal(ROW_ORDER); + expect(report.sdk.name).to.equal("javascript_native_web"); + expect(report.ts).to.be.a("number"); + }); + }); + }); + }); + + it("never persists ct into the cached config", () => { + hp.haltAndClearStorage(() => { + initWithServerConfig(true); + cy.wait(3000).then(() => { + cy.fetch_from_storage(hp.appKey, "cly_config").then((cached) => { + var config = typeof cached === "string" ? JSON.parse(cached) : cached; + expect(config).to.not.have.property("ct"); + expect(config).to.have.property("v"); + }); + }); + }); + }); + + it("does nothing at all when the server config is not armed", () => { + hp.haltAndClearStorage(() => { + initWithServerConfig(false); + cy.wait(3000).then(() => { + cy.fetch_local_request_queue().then((rq) => { + expect(rq.filter((r) => r.ct_results).length).to.equal(0); + }); + }); + }); + }); +}); + +describe("Connection test report caps", () => { + function reportOf(results) { + return { ts: 1, sdk: { name: "javascript_native_web", version: "26.1.3" }, results: results }; + } + + it("truncates a long detail to 256 characters", () => { + var capped = ct.capReport(reportOf([{ f: "core", ok: false, st: 0, ms: 1, e: new Array(600).join("x") }])); + expect(capped.results[0].e.length).to.equal(256); + }); + + it("never reports more than 32 rows", () => { + var many = []; + for (var i = 0; i < 40; i++) { + many.push({ f: "row" + i, ok: true, st: 200, ms: 1 }); + } + expect(ct.capReport(reportOf(many)).results.length).to.equal(32); + }); + + it("drops failure details to fit the size cap but keeps the opaque qualifier", () => { + var rows = []; + for (var i = 0; i < 32; i++) { + rows.push({ f: "row" + i, ok: false, st: 500, ms: 1, e: new Array(257).join("y") }); + } + rows[0] = { f: "opaque-row", ok: true, st: 0, ms: 1, e: ct.CT_DETAIL.OPAQUE }; + var capped = ct.capReport(reportOf(rows)); + expect(JSON.stringify(capped).length).to.be.at.most(8192); + expect(capped.results[0].e).to.equal(ct.CT_DETAIL.OPAQUE); + expect(capped.results[1]).to.not.have.property("e"); + }); +}); + +describe("Connection test probe requests", () => { + it("probes parameterlessly, marked and cache-busted", () => { + var probes = []; + cy.intercept({ url: "https://test.count.ly/**" }, (req) => { + probes.push(req.url); + req.reply({ statusCode: 400, body: { result: "Missing parameter" } }); + }); + hp.haltAndClearStorage(() => { + initWithServerConfig(true); + cy.wait(3000).then(() => { + var ping = probes.filter((u) => u.indexOf("/o/ping") !== -1)[0]; + expect(ping, "the core probe should have been issued").to.be.a("string"); + expect(ping).to.contain("ct=1"); + expect(ping).to.match(/[?&]_=\d+/); + probes.forEach((url) => { + expect(url, "probes carry no identity").to.not.contain("app_key"); + expect(url, "probes carry no identity").to.not.contain("device_id"); + }); + }); + }); + }); +}); + +describe("Connection test Tier 2 CORS-first policy", () => { + function corsFirstContext(byPath, calls) { + return baseContext({ tier2: "cors-first", probe: stubProbe(byPath, calls) }); + } + + function rowOf(report, key) { + return report.results.filter((r) => r.f === key)[0]; + } + + it("counts a possible fallback retry toward the attempted request budget", () => { + expect(ct.attemptedRequests("cors-first")).to.equal(22); + expect(ct.attemptedRequests("probe")).to.equal(16); + expect(ct.attemptedRequests("unsupported")).to.equal(10); + expect(ct.batteryCapMs(22)).to.equal(250000); + }); + + it("attempts Tier 2 with CORS first, and grades a readable status like Tier 1", () => { + var calls = []; + return ct.runConnectionTest(corsFirstContext({ + "/feedback/nps": { status: 400, ms: 4 }, + "/feedback/survey": { status: 400, ms: 4 }, + "/feedback/rating": { status: 400, ms: 4 } + }, calls)).then((report) => { + expect(rowOf(report, "feedback-page")).to.deep.equal({ + f: "feedback-page", ok: true, st: 400, ms: 12, n: 3 + }); + var nps = calls.filter((c) => c.url.indexOf("/feedback/nps") !== -1); + expect(nps.length, "no retry when the first attempt is readable").to.equal(1); + expect(nps[0].mode).to.equal("cors"); + }); + }); + + it("applies the strict-2xx rule to the asset row now that its status is readable", () => { + var calls = []; + return ct.runConnectionTest(corsFirstContext({ + "/surveys/images/ct-probe.png": { status: 404, ms: 5 }, + "/star-rating/images/ct-probe.png": { status: 200, ms: 5 } + }, calls)).then((report) => { + expect(rowOf(report, "feedback-assets")).to.deep.equal({ + f: "feedback-assets", + ok: false, + st: 404, + ms: 10, + n: 2, + e: "Expected a 2xx from this route, which has no legitimate 4xx, but got HTTP 404." + }); + }); + }); + + it("retries once without CORS when the CORS attempt is rejected", () => { + var calls = []; + var byMode = (mode) => (mode === "cors" ? { rejected: true, ms: 3 } : { opaque: true, ms: 7 }); + return ct.runConnectionTest(corsFirstContext({ + "/_external/content/": byMode + }, calls)).then((report) => { + expect(rowOf(report, "content-page")).to.deep.equal({ + f: "content-page", ok: true, st: 0, ms: 10, e: ct.CT_DETAIL.OPAQUE + }); + var attempts = calls.filter((c) => c.url.indexOf("/_external/content/") !== -1); + expect(attempts.map((c) => c.mode)).to.deep.equal(["cors", "no-cors"]); + }); + }); + + it("reads the opaque fallback as middlebox interference, not a browser limitation", () => { + expect(ct.CT_DETAIL.OPAQUE).to.equal("Reachable, but the response arrived without Countly's CORS headers, so something between the device and the server is stripping or rewriting them. The status could not be read."); + }); + + it("reports a transport error when the fallback is rejected as well", () => { + var calls = []; + return ct.runConnectionTest(corsFirstContext({ + "/_external/content/": { rejected: true, ms: 6 } + }, calls)).then((report) => { + expect(rowOf(report, "content-page")).to.deep.equal({ + f: "content-page", ok: false, st: 0, ms: 12, e: ct.CT_DETAIL.TRANSPORT + }); + }); + }); + + it("never falls back on Tier 1, where CORS is guaranteed", () => { + var calls = []; + return ct.runConnectionTest(corsFirstContext({ + "/o/ping": { rejected: true, ms: 6 } + }, calls)).then((report) => { + var pings = calls.filter((c) => c.url.indexOf("/o/ping") !== -1); + expect(pings.length, "a Tier 1 rejection is a real failure, not a CORS problem").to.equal(1); + expect(rowOf(report, "core")).to.deep.equal({ + f: "core", ok: false, st: 0, ms: 6, e: ct.CT_DETAIL.TRANSPORT + }); + }); + }); +}); diff --git a/modules/ConnectionTest.js b/modules/ConnectionTest.js new file mode 100644 index 0000000..286cfbe --- /dev/null +++ b/modules/ConnectionTest.js @@ -0,0 +1,391 @@ +import { stripTrailingSlash } from "./Utils.js"; + +/** Per-probe deadline. */ +var CT_PROBE_TIMEOUT = 10000; + +/** Server-side limits on the report. */ +var CT_MAX_ROWS = 32; +var CT_MAX_BYTES = 8192; +var CT_MAX_DETAIL = 256; + +/** + * Resolve a probe path against the SDK's configured server URL. + * The configured URL may carry a path prefix for reverse-proxied deployments, + * so the path is appended to it rather than to the bare origin. + * @param {String} baseUrl - the SDK's configured server URL + * @param {String} path - probe path, always starting with a slash + * @returns {String} absolute probe URL + */ +function buildProbeUrl(baseUrl, path) { + return stripTrailingSlash(baseUrl) + path; +} + +/** + * Explanations carried in the report's `e` field. Each says what was expected and what + * arrived instead, so an operator reading a single row does not have to know the grading + * rules to understand the verdict. + */ +var CT_DETAIL = { + TIMEOUT: "Expected an answer within " + (CT_PROBE_TIMEOUT / 1000) + "s, but the request never completed.", + TRANSPORT: "Expected the server to answer, but the connection failed before any response arrived (DNS, TLS, certificate pinning, or the request was blocked).", + REDIRECTED_HIDDEN: "Expected a direct answer from Countly, but the request was redirected away; the browser hides the redirect status.", + OPAQUE: "Reachable, but the response arrived without Countly's CORS headers, so something between the device and the server is stripping or rewriting them. The status could not be read.", + NOT_RUN: "Not run, because the battery deadline elapsed before this row was reached." +}; + +/** + * Explain a failing status in terms of what was expected of it. + * @param {Number} status - the HTTP status observed + * @param {Boolean} strict2xx - whether this row has no legitimate 4xx + * @returns {String} the explanation for the `e` field + */ +function detailForStatus(status, strict2xx) { + if (status >= 300 && status < 400) { + return "Expected a direct answer from Countly, but the request was redirected away with HTTP " + status + "."; + } + if (status === 403) { + return "Expected the Countly application to answer, but a proxy rejected the request with HTTP " + status + " before it got there."; + } + if (status >= 500) { + return "Expected the Countly application to answer, but the gateway or an upstream service failed with HTTP " + status + "."; + } + if (strict2xx) { + return "Expected a 2xx from this route, which has no legitimate 4xx, but got HTTP " + status + "."; + } + return "Expected the Countly application to answer, but it returned HTTP " + status + "."; +} + +/** + * Grade a single probe outcome into a report row fragment. + * The question is "did the Countly application answer", not "did it succeed", so an + * application-level rejection counts as reachable while a proxy block does not. + * @param {Object} outcome - what the transport observed: {status}, {rejected, timedOut}, + * {redirected} for an opaque redirect, {opaque} for a resolved + * opaque response + * @param {Object} [opts] - {strict2xx} for rows that have no legitimate 4xx + * @returns {Object} {ok, st} plus {e} when there is a detail worth carrying + */ +function gradeProbe(outcome, opts) { + opts = opts || {}; + + if (outcome.rejected) { + return { ok: false, st: 0, e: outcome.timedOut ? CT_DETAIL.TIMEOUT : CT_DETAIL.TRANSPORT }; + } + if (outcome.redirected) { + return { ok: false, st: 0, e: CT_DETAIL.REDIRECTED_HIDDEN }; + } + // an opaque response cannot be inspected, so the strict-2xx rule cannot apply to it + if (outcome.opaque) { + return { ok: true, st: 0, e: CT_DETAIL.OPAQUE }; + } + + var status = outcome.status; + if (status >= 200 && status < 300) { + return { ok: true, st: status }; + } + if (status >= 400 && status < 500 && status !== 403 && !opts.strict2xx) { + return { ok: true, st: status }; + } + return { ok: false, st: status, e: detailForStatus(status, opts.strict2xx) }; +} + +/** + * Fold the probes of a multi-path row into the single row the report carries. + * Each path is a separate nginx location that can be blocked on its own, so the row + * is only reachable when every one of them answered. + * @param {Array} results - graded probes, each {ok, st, ms} plus optional {e} + * @returns {Object} combined row {ok, st, ms, n} plus optional {e} + */ +function combineRow(results) { + var firstFailure = null; + var total = 0; + var allOpaque = true; + + for (var i = 0; i < results.length; i++) { + var result = results[i]; + total += result.ms; + if (!result.ok && !firstFailure) { + firstFailure = result; + } + if (result.e !== CT_DETAIL.OPAQUE) { + allOpaque = false; + } + } + + var row = { + ok: !firstFailure, + st: firstFailure ? firstFailure.st : results[0].st, + ms: total, + n: results.length + }; + if (firstFailure) { + row.e = firstFailure.e; + } + else if (allOpaque) { + row.e = CT_DETAIL.OPAQUE; + } + return row; +} + +/** + * The probe list lives here rather than on the wire, which is why the server's armed flag + * never has to change when this list evolves. Rows with several paths hit separate nginx + * locations that can be blocked independently. + */ +var CT_ROWS = [ + { f: "core", tier: 1, paths: ["/o/ping"], strict2xx: true }, + { f: "core-write", tier: 1, paths: ["/i"] }, + { f: "sc", tier: 1, paths: [] }, + { f: "rc", tier: 1, paths: ["/o/sdk?method=rc"] }, + { f: "ab", tier: 1, paths: ["/o/sdk?method=ab_fetch_variants"] }, + { f: "feedback", tier: 1, paths: ["/o/sdk?method=feedback"] }, + { f: "feedback-widget", tier: 1, paths: ["/o/surveys/nps/widget", "/o/surveys/survey/widget", "/o/feedback/widget"] }, + { f: "feedback-submit", tier: 1, paths: ["/i/feedback/inputs"] }, + { f: "content", tier: 1, paths: ["/o/sdk/content"] }, + { f: "feedback-page", tier: 2, paths: ["/feedback/nps", "/feedback/survey", "/feedback/rating"] }, + { f: "feedback-assets", tier: 2, paths: ["/surveys/images/ct-probe.png", "/star-rating/images/ct-probe.png"], strict2xx: true }, + { f: "content-page", tier: 2, paths: ["/_external/content/"] } +]; + +/** + * How many probes the battery may issue. A Tier 2 path can cost two requests when the + * CORS attempt is rejected and the opaque fallback runs, and the deadline has to allow + * for the worst case rather than the happy path. + * @param {String} tier2 - "cors-first", "probe" or "unsupported" + * @returns {Number} worst-case request count + */ +function attemptedRequests(tier2) { + return CT_ROWS.reduce((total, row) => { + if (row.tier !== 2) { + return total + row.paths.length; + } + if (tier2 === "unsupported") { + return total; + } + return total + (row.paths.length * (tier2 === "cors-first" ? 2 : 1)); + }, 0); +} + +/** + * Deadline for the whole battery, measured over the requests actually attempted rather + * than the rows in the table — a row with three paths costs three requests. + * @param {Number} attemptedRequests - how many probes the battery intends to issue + * @returns {Number} cap in milliseconds + */ +function batteryCapMs(attemptedRequests) { + return (attemptedRequests * CT_PROBE_TIMEOUT) + 30000; +} + +/** + * Stamp a graded result with its feature key, in the field order the report documents, + * so an operator reading the raw JSON sees which row it is before anything else. + * @param {String} f - feature key + * @param {Object} result - graded row body + * @returns {Object} report row + */ +function toReportRow(f, result) { + var row = { f: f }; + ["ok", "st", "ms", "n", "e", "sk"].forEach((key) => { + if (typeof result[key] !== "undefined") { + row[key] = result[key]; + } + }); + return row; +} + +/** + * Probe a single path, falling back to an opaque attempt where that is meaningful. + * @param {Object} ctx - battery context + * @param {Object} row - row definition from CT_ROWS + * @param {String} path - the path to probe + * @returns {Promise} resolves to the outcome, with ms covering every attempt made + */ +function probePath(ctx, row, path) { + var url = buildProbeUrl(ctx.url, path); + var mayFallBack = row.tier === 2 && ctx.tier2 === "cors-first"; + + return ctx.probe(url, { mode: "cors" }).then((first) => { + if (!first.rejected || !mayFallBack) { + return first; + } + // On Tier 1 a rejection is simply a failure. On Tier 2 it can also mean a middlebox + // stripped the CORS headers in transit, so one opaque retry separates "unreachable" + // from "reachable but tampered with". + return ctx.probe(url, { mode: "no-cors" }).then((second) => { + var merged = { ms: first.ms + second.ms }; + ["status", "rejected", "timedOut", "redirected", "opaque"].forEach((key) => { + if (typeof second[key] !== "undefined") { + merged[key] = second[key]; + } + }); + return merged; + }); + }); +} + +/** + * Probe one row's paths in sequence and fold them into a single report row. + * @param {Object} ctx - battery context + * @param {Object} row - row definition from CT_ROWS + * @returns {Promise} resolves to the report row + */ +function runRow(ctx, row) { + var graded = []; + + var chain = row.paths.reduce((previous, path) => { + return previous.then(() => { + return probePath(ctx, row, path).then((outcome) => { + var result = gradeProbe(outcome, { strict2xx: row.strict2xx }); + result.ms = outcome.ms; + graded.push(result); + }); + }); + }, Promise.resolve()); + + return chain.then(() => { + return toReportRow(row.f, row.paths.length > 1 ? combineRow(graded) : graded[0]); + }); +} + +/** + * Run the connection test battery once and build the report. + * Probes are sequential and carry no identity, so nothing here depends on consent + * and nothing the server does with them can write. + * @param {Object} ctx - {url, sdkName, sdkVersion, sc: {status, ms}, probe, tier2} + * where probe(url, opts) resolves to an outcome carrying its own ms, + * and tier2 is "probe", "opaque" or "unsupported" + * @returns {Promise} resolves to the ct_results report object + */ +function runConnectionTest(ctx) { + var results = []; + var now = ctx.now || Date.now; + var deadline = now() + batteryCapMs(attemptedRequests(ctx.tier2)); + + var chain = CT_ROWS.reduce((previous, row) => { + return previous.then(() => { + if (row.f === "sc") { + var sc = gradeProbe({ status: ctx.sc.status }); + sc.ms = ctx.sc.ms; + results.push(toReportRow(row.f, sc)); + return null; + } + if (row.tier === 2 && ctx.tier2 === "unsupported") { + results.push({ f: row.f, sk: "unsupported" }); + return null; + } + // the cap only governs rows that would cost a request; the free rows above + // are reported either way + if (now() >= deadline) { + results.push({ f: row.f, ok: false, st: 0, e: CT_DETAIL.NOT_RUN }); + return null; + } + return runRow(ctx, row).then((result) => { + results.push(result); + }); + }); + }, Promise.resolve()); + + return chain.then(() => { + return { + ts: Date.now(), + sdk: { name: ctx.sdkName, version: ctx.sdkVersion }, + results: results + }; + }); +} + +/** + * Bring a report inside the server's limits before it is queued. + * The opaque qualifier survives every reduction: it is not diagnostic detail but the mark + * that keeps a low-confidence green row from reading as authoritative. + * @param {Object} report - the assembled report + * @returns {Object} the same report, trimmed to at most 32 rows and 8 KB + */ +function capReport(report) { + report.results = report.results.slice(0, CT_MAX_ROWS); + report.results.forEach((row) => { + if (typeof row.e === "string" && row.e.length > CT_MAX_DETAIL) { + row.e = row.e.substring(0, CT_MAX_DETAIL); + } + }); + + if (JSON.stringify(report).length > CT_MAX_BYTES) { + report.results.forEach((row) => { + if (row.e && row.e !== CT_DETAIL.OPAQUE) { + delete row.e; + } + }); + } + return report; +} + +/** + * Issue one probe: a bare GET carrying no app_key, no device_id and no payload, so the + * server rejects it on its first validation check without ever reaching application work. + * No request headers are set, because a non-safelisted header would turn this into a + * preflighted request that these endpoints do not answer. + * @param {String} url - absolute probe URL + * @param {Object} opts - {mode} "cors" for a readable status, "no-cors" for an opaque + * fallback when the CORS attempt was rejected + * @returns {Promise} resolves to an outcome object carrying its own ms + */ +function probeViaFetch(url, opts) { + var start = Date.now(); + // ct=1 marks probe traffic in server logs; the cache-buster keeps a CDN or the browser + // cache from answering on the origin's behalf and reporting a dead server as healthy + var target = url + (url.indexOf("?") === -1 ? "?" : "&") + "ct=1&_=" + start; + var timedOut = false; + var controller = typeof AbortController !== "undefined" ? new AbortController() : null; + var timer = setTimeout(() => { + timedOut = true; + if (controller) { + controller.abort(); + } + }, CT_PROBE_TIMEOUT); + + var init = { + method: "GET", + cache: "no-store", + credentials: "omit" + }; + if (controller) { + init.signal = controller.signal; + } + if (opts && opts.mode === "no-cors") { + // no-cors forbids any redirect mode but follow, and every response is opaque anyway + init.mode = "no-cors"; + } + else { + init.mode = "cors"; + init.redirect = "manual"; + } + + return fetch(target, init).then((response) => { + clearTimeout(timer); + var ms = Date.now() - start; + if (response.type === "opaqueredirect") { + return { redirected: true, ms: ms }; + } + if (response.type === "opaque") { + return { opaque: true, ms: ms }; + } + return { status: response.status, ms: ms }; + }).catch(() => { + clearTimeout(timer); + return { rejected: true, timedOut: timedOut, ms: Date.now() - start }; + }); +} + +export { + buildProbeUrl, + gradeProbe, + combineRow, + runConnectionTest, + batteryCapMs, + attemptedRequests, + probeViaFetch, + capReport, + CT_DETAIL, + CT_ROWS, + CT_PROBE_TIMEOUT +}; diff --git a/modules/CountlyClass.js b/modules/CountlyClass.js index 2e12e4d..18a76e9 100644 --- a/modules/CountlyClass.js +++ b/modules/CountlyClass.js @@ -1,6 +1,5 @@ - - import { DeviceIdTypeInternalEnums, SDK_NAME, SDK_VERSION, configurationDefaultValues, featureEnums, healthCheckCounterEnum, internalEventKeyEnums, internalEventKeyEnumsArray, logLevelEnums, pushConstants, pushMessageTypes, pushStorageKeys, pushWorkerParams, urlParseRE } from "./Constants.js"; +import { runConnectionTest, probeViaFetch, capReport } from "./ConnectionTest.js"; import { getMultiSelectValues, secureRandom, @@ -129,6 +128,7 @@ class CountlyClass { #initContentSent; #initTimestamp; #isSCDisabled; + #connectionTestRunning = false; #lastRequestDuration; #backoffEndTime; #isInBackoff; @@ -425,6 +425,7 @@ class CountlyClass { params.dow = date.getDay(); params.av = this.app_version; params.method = "sc"; + var scStart = Date.now(); this.#makeNetworkRequest("server_config", this.url + this.#readPath, params, (err, params, responseText) => { if (err) { // error has been logged by the request function @@ -433,10 +434,20 @@ class CountlyClass { try { var config = JSON.parse(responseText); this.#log(logLevelEnums.INFO, "server_config, Config fetched successfully:[" + JSON.stringify(config) + "]"); + // the connection test flag is read here, from a live response, and stripped + // before anything caches or mirrors the config. It is never persisted, so a + // page load or another tab can never replay a battery + var armed = !!(config && config.ct); + if (config && typeof config.ct !== "undefined") { + delete config.ct; + } if (config) { this.#populateServerConfig(config); } this.#setValueInStorage("cly_config", JSON.stringify(config)); + if (armed) { + this.#runConnectionTest(Date.now() - scStart); + } } catch (ex) { this.#log(logLevelEnums.ERROR, "server_config, Had an issue while parsing the response: " + ex); @@ -447,6 +458,41 @@ class CountlyClass { }, this.#SCInterval * 60 * 60 * 1000); } + /** + * Run the connection test battery the server armed, then report through the request queue. + * Probes are bare parameterless GETs that never reach application work, so this needs no + * consent and can write nothing. Nothing about the run is persisted: the only guard is the + * in-memory flag below, which stops a second armed response from starting a parallel run. + * @private + * @param {Number} scMs - latency of the config fetch that delivered the flag + */ + #runConnectionTest = (scMs) => { + if (this.#connectionTestRunning) { + this.#log(logLevelEnums.DEBUG, "connection_test, A battery is already in flight, ignoring this delivery"); + return; + } + this.#log(logLevelEnums.INFO, "connection_test, Server armed a connection test, running the battery"); + this.#connectionTestRunning = true; + + runConnectionTest({ + url: this.url, + sdkName: this.#sdkName, + sdkVersion: this.#sdkVersion, + sc: { status: 200, ms: scMs }, + // the Tier 2 routes send no CORS headers, so a browser can only reach them + // opaquely; elsewhere they are irrelevant + tier2: isBrowser ? "cors-first" : "unsupported", + probe: probeViaFetch + }).then((report) => { + this.#connectionTestRunning = false; + this.#log(logLevelEnums.INFO, "connection_test, Battery finished, queueing report:[" + JSON.stringify(report) + "]"); + this.#toRequestQueue({ ct_results: JSON.stringify(capReport(report)) }); + }).catch((error) => { + this.#connectionTestRunning = false; + this.#log(logLevelEnums.ERROR, "connection_test, Battery failed: " + error); + }); + } + /** * Populate server configuration with received settings * Updates internal server configuration settings based on server response