From a5700c9988e9c34f1d1cf60455f3a2b3ce866762 Mon Sep 17 00:00:00 2001 From: turtledreams <62231246+turtledreams@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:19:51 +0900 Subject: [PATCH] Connection Tester --- CHANGELOG.md | 3 +- cypress/e2e/connection_test.cy.js | 565 ++++++++++++++++++++++++++++++ modules/ConnectionTest.js | 391 +++++++++++++++++++++ modules/CountlyClass.js | 48 +++ 4 files changed, 1006 insertions(+), 1 deletion(-) create mode 100644 cypress/e2e/connection_test.cy.js create mode 100644 modules/ConnectionTest.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dac7b3..02aec6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ -## 26.1.3 +## NEXT RELEASE +* 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. * Added support for Feedback Widgets and Content working with certain proxy configurations. ## 26.1.2 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 e86a65d..96ff6f4 100644 --- a/modules/CountlyClass.js +++ b/modules/CountlyClass.js @@ -1,6 +1,7 @@ import { DeviceIdTypeInternalEnums, SDK_NAME, SDK_VERSION, configurationDefaultValues, featureEnums, healthCheckCounterEnum, internalEventKeyEnums, internalEventKeyEnumsArray, logLevelEnums, urlParseRE } from "./Constants.js"; +import { runConnectionTest, probeViaFetch, capReport } from "./ConnectionTest.js"; import { getMultiSelectValues, secureRandom, @@ -129,6 +130,7 @@ class CountlyClass { #initContentSent; #initTimestamp; #isSCDisabled; + #connectionTestRunning = false; #lastRequestDuration; #backoffEndTime; #isInBackoff; @@ -421,6 +423,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 @@ -429,10 +432,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); @@ -443,6 +456,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