Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
703f752
refactor(logging): introduce Beacon transport contract for TelemetryC…
nbbeeken Jul 22, 2026
579c608
feat(logging): add fire-and-forget beacon MONGOSH-3454
nbbeeken Jul 22, 2026
794e170
fix(logging): resolve error outcome instead of rejecting for unsuppor…
nbbeeken Jul 22, 2026
fb520fe
feat(logging): adaptive timeout and circuit breaker for fire-and-forg…
nbbeeken Jul 22, 2026
8cbd63a
fix(logging): make circuit-breaker half-open probe race-safe under co…
nbbeeken Jul 22, 2026
b808b4f
feat(logging): DNS cache and connection warm-up for fire-and-forget b…
nbbeeken Jul 22, 2026
2997234
feat(logging): persist TLS session tickets for cross-session resumpti…
nbbeeken Jul 22, 2026
eeead92
fix(logging): tolerate non-object JSON in the TLS session store MONGO…
nbbeeken Jul 22, 2026
e035ae8
chore(logging): add beacon benchmark script MONGOSH-3454
nbbeeken Jul 22, 2026
d957986
feat(cli-repl): opt-in fire-and-forget telemetry transport via MONGOS…
nbbeeken Jul 22, 2026
b43a29d
fix(cli-repl): only compute telemetry User-Agent when the fire-and-fo…
nbbeeken Jul 22, 2026
7d7f34e
fix(logging): serialize TLS session-ticket writes to avoid out-of-ord…
nbbeeken Jul 22, 2026
838925e
fix(logging): require a completed handshake before granting the flush…
nbbeeken Jul 22, 2026
144f1f9
docs(logging): clarify the telemetry-client catch comment MONGOSH-3454
nbbeeken Jul 22, 2026
669e86d
fix(cli-repl): resolve the telemetry agent against the endpoint befor…
nbbeeken Jul 22, 2026
00902ee
fix(cli-repl): tolerate malformed telemetry endpoints when resolving …
nbbeeken Jul 22, 2026
26014bd
wip
nbbeeken Jul 27, 2026
949d757
wip
nbbeeken Jul 27, 2026
9f05d40
wip
nbbeeken Jul 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/cli-repl/src/cli-repl-telemetry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,26 @@ describe('CliRepl telemetry (integration)', function () {
expect(payload.api_deprecation_errors).to.equal(true);
});
});

it('deliver HEAD beacons without waiting for slow responses', async function () {
const testStartMs = Date.now();
// The server delays responses by 5s. The fire-and-forget transport
// dispatches on write-finish and exits immediately; a transport that
// waited for responses would sit out the 2s flush timeout and bust
// the 1s post-start budget below.
setTelemetryDelay(5000);
await cliRepl.start(await testServer.connectionString(), {});
this.timeout(Date.now() - testStartMs + 1000); // Exclude connection time from the 1s budget
input.write('use somedb;\n');
input.write('exit\n');
await waitBus(cliRepl.bus, 'mongosh:closed');
// The fake server records requests on receipt, before delaying the
// response, so these are observable even though no response has
// been sent yet.
expect(requests).to.have.lengthOf.at.least(1);
expect(requests[0].req.method).to.equal('HEAD');
expect(requests[0].req.headers['user-agent']).to.match(/^mongosh\//);
});
});

context('without network connectivity', function () {
Expand Down
44 changes: 35 additions & 9 deletions packages/cli-repl/src/cli-repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import type {
} from '@mongodb-js/devtools-proxy-support';
import {
createFetch,
systemCA,
useOrCreateAgent,
} from '@mongodb-js/devtools-proxy-support';
import { fullDepthInspectOptions } from './format-output';
Expand All @@ -62,6 +63,16 @@ import { getDeviceIdForMongosh } from './device-id';
*/
const CONNECTING = 'cli-repl.cli-repl.connecting';

/**
* Sanitizes a list of User-Agent tag values (dropping nullish entries to
* '') and joins them the way the telemetry endpoint expects.
*/
function formatUserAgentTags(tags: (string | undefined)[]): string {
return tags
.map((s) => (s ?? '').replace(/[^a-zA-Z0-9./_-]/g, '_'))
.join('; ');
}

/**
* The set of options taken by CliRepl instances.
*/
Expand Down Expand Up @@ -191,17 +202,15 @@ export class CliRepl implements MongoshIOProvider {
return await this.getDeviceId();
})(),
]);
const userAgentTags = [
const userAgentTags = formatUserAgentTags([
`mongosh/${version}`,
os_type,
os_release,
os_arch,
os_linux_dist ?? '',
os_linux_release ?? '',
os_linux_dist,
os_linux_release,
deviceId,
]
.map((s) => s.replace(/[^a-zA-Z0-9./_-]/g, '_'))
.join('; ');
]);
return baseFetch(url, {
...init,
headers: {
Expand Down Expand Up @@ -689,13 +698,30 @@ export class CliRepl implements MongoshIOProvider {
}

async setupAnalytics(): Promise<void> {
const { version }: { version: string } = require('../package.json');
const { os_type, os_release, os_arch, os_linux_dist, os_linux_release } =
await this.getOsInfo();
const { analytics, telemetryEndpoint } = setupTelemetryAnalytics({
// `telemetryEndpoint` user config carries the production default.
configuredTelemetryEndpoint: await this.getConfig('telemetryEndpoint'),
// includeDeviceId: false — device_id is already in the event payload,
// no need to duplicate it in the User-Agent header.
fetch: this.fetch({ includeDeviceId: false }),
metadataPath: this.shellHomeDirectory.paths.shellLocalDataPath,
agent: this.agent,
// device_id is already in the event payload, so the User-Agent carries
// 'disabled' in the device slot instead of duplicating it.
userAgent: formatUserAgentTags([
`mongosh/${version}`,
os_type,
os_release,
os_arch,
os_linux_dist,
os_linux_release,
'disabled',
]),
// The beacon builds its own agents, which would otherwise only trust
// Node's bundled roots; hand it the merged system CA list so custom-CA
// environments (corporate proxies, test sinks) keep working. systemCA()
// is pre-warmed at startup, so this is a cache hit.
tlsCa: (await systemCA()).ca,
});
this.toggleableAnalytics = analytics;
// Record the resolved endpoint so logging can decide whether to log full
Expand Down
186 changes: 119 additions & 67 deletions packages/cli-repl/src/setup-analytics.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import {
ToggleableAnalytics,
} from '@mongosh/logging';
import type { TelemetryEvent } from '@mongosh/logging';
import { setupTelemetryAnalytics } from './setup-analytics';
import type { AgentWithInitialize } from '@mongodb-js/devtools-proxy-support';
import { useOrCreateAgent } from '@mongodb-js/devtools-proxy-support';
import {
resolveTelemetryAgent,
setupTelemetryAnalytics,
} from './setup-analytics';

const identifyEvent: TelemetryEvent = {
name: 'Identify',
Expand All @@ -30,86 +35,133 @@ const identifyEvent: TelemetryEvent = {
},
};

describe('setupTelemetryAnalytics', function () {
const metadataPath = os.tmpdir();
// A fetch stub; these tests never actually track()/send, they only inspect
// how the analytics sink is constructed.
const fetch = () => Promise.resolve(new Response());
describe('setup-analytics', function () {
describe('setupTelemetryAnalytics', function () {
const metadataPath = os.tmpdir();

let savedEnvEndpoint: string | undefined;
beforeEach(function () {
savedEnvEndpoint = process.env.MONGOSH_TELEMETRY_ENDPOINT;
delete process.env.MONGOSH_TELEMETRY_ENDPOINT;
});
afterEach(function () {
if (savedEnvEndpoint === undefined) {
let savedEnvEndpoint: string | undefined;
beforeEach(function () {
savedEnvEndpoint = process.env.MONGOSH_TELEMETRY_ENDPOINT;
delete process.env.MONGOSH_TELEMETRY_ENDPOINT;
} else {
process.env.MONGOSH_TELEMETRY_ENDPOINT = savedEnvEndpoint;
});
afterEach(function () {
if (savedEnvEndpoint === undefined) {
delete process.env.MONGOSH_TELEMETRY_ENDPOINT;
} else {
process.env.MONGOSH_TELEMETRY_ENDPOINT = savedEnvEndpoint;
}
});

function setup(
params: Partial<Parameters<typeof setupTelemetryAnalytics>[0]> = {}
) {
return setupTelemetryAnalytics({
configuredTelemetryEndpoint: '',
metadataPath,
...params,
});
}
});

function setup(
params: Partial<Parameters<typeof setupTelemetryAnalytics>[0]> = {}
) {
return setupTelemetryAnalytics({
configuredTelemetryEndpoint: '',
fetch: fetch as any,
metadataPath,
...params,
it('returns a no-op sink when no endpoint is configured', function () {
const { analytics, telemetryEndpoint } = setup();
expect(telemetryEndpoint).to.equal('');
expect(analytics).to.be.instanceOf(ToggleableAnalytics);
// No endpoint -> nothing to send to. Telemetry is not disabled here;
// events are still logged locally, they just have no destination.
expect(analytics._target).to.be.instanceOf(NoopAnalytics);
});
}

it('returns a no-op sink when no endpoint is configured', function () {
const { analytics, telemetryEndpoint } = setup();
expect(telemetryEndpoint).to.equal('');
expect(analytics).to.be.instanceOf(ToggleableAnalytics);
// No endpoint -> nothing to send to. Telemetry is not disabled here;
// events are still logged locally, they just have no destination.
expect(analytics._target).to.be.instanceOf(NoopAnalytics);
});
it('creates a telemetry client when an endpoint is configured via user config', function () {
const { analytics, telemetryEndpoint } = setup({
configuredTelemetryEndpoint: 'https://config.example/events',
});
expect(telemetryEndpoint).to.equal('https://config.example/events');
expect(analytics._target).to.be.instanceOf(ThrottledAnalytics);
});

it('creates a telemetry client when an endpoint is configured via user config', function () {
const { analytics, telemetryEndpoint } = setup({
configuredTelemetryEndpoint: 'https://config.example/events',
it('uses MONGOSH_TELEMETRY_ENDPOINT over the configured default', function () {
process.env.MONGOSH_TELEMETRY_ENDPOINT = 'https://env.example/events';
const { telemetryEndpoint, analytics } = setup({
configuredTelemetryEndpoint: 'https://config.example/events',
});
expect(telemetryEndpoint).to.equal('https://env.example/events');
expect(analytics._target).to.be.instanceOf(ThrottledAnalytics);
});
expect(telemetryEndpoint).to.equal('https://config.example/events');
expect(analytics._target).to.be.instanceOf(ThrottledAnalytics);
});

it('uses MONGOSH_TELEMETRY_ENDPOINT over the configured default', function () {
process.env.MONGOSH_TELEMETRY_ENDPOINT = 'https://env.example/events';
const { telemetryEndpoint, analytics } = setup({
configuredTelemetryEndpoint: 'https://config.example/events',
it('is disabled when every source resolves to an empty endpoint', function () {
process.env.MONGOSH_TELEMETRY_ENDPOINT = '';
const { telemetryEndpoint, analytics } = setup({
configuredTelemetryEndpoint: '',
});
expect(telemetryEndpoint).to.equal('');
expect(analytics._target).to.be.instanceOf(NoopAnalytics);
});
expect(telemetryEndpoint).to.equal('https://env.example/events');
expect(analytics._target).to.be.instanceOf(ThrottledAnalytics);
});

it('is disabled when every source resolves to an empty endpoint', function () {
process.env.MONGOSH_TELEMETRY_ENDPOINT = '';
const { telemetryEndpoint, analytics } = setup({
configuredTelemetryEndpoint: '',
it('never constructs a network-capable sink when no endpoint is configured', async function () {
const { analytics } = setup({
configuredTelemetryEndpoint: '',
});
// With no endpoint the target is a NoopAnalytics — no beacon exists,
// so tracking and flushing can never produce a network request.
expect(analytics._target).to.be.instanceOf(NoopAnalytics);
analytics.enable();
analytics.track(identifyEvent);
await analytics.flush(); // must not throw
expect(analytics._target).to.be.instanceOf(NoopAnalytics);
});
expect(telemetryEndpoint).to.equal('');
expect(analytics._target).to.be.instanceOf(NoopAnalytics);
});

it('never calls fetch when no endpoint is configured', async function () {
let fetchCount = 0;
const { analytics } = setup({
configuredTelemetryEndpoint: '',
fetch: (() => {
fetchCount++;
return Promise.resolve(new Response());
}) as any,
describe('resolveTelemetryAgent', function () {
const createdAgents: (AgentWithInitialize | undefined)[] = [];

afterEach(function () {
// Mirrors cli-repl.ts (which destroys its shared agent without
// awaiting); none of these agents ever open a real connection.
for (const agent of createdAgents.splice(0)) {
agent?.destroy();
}
});

it('return undefined when the agent has no proxy configured for the endpoint', function () {
const agent = useOrCreateAgent({});
createdAgents.push(agent);
const resolved = resolveTelemetryAgent(
agent,
'https://telemetry.example.com'
);
expect(resolved).to.equal(undefined);
});

it('return the agent unchanged when a proxy is configured for the endpoint', function () {
const agent = useOrCreateAgent({
proxy: 'http://proxy.example.com:8080',
});
createdAgents.push(agent);
const resolved = resolveTelemetryAgent(
agent,
'https://telemetry.example.com'
);
expect(resolved).to.equal(agent);
});

it('return undefined when there is no agent to resolve', function () {
const resolved = resolveTelemetryAgent(
undefined,
'https://telemetry.example.com'
);
expect(resolved).to.equal(undefined);
});

it('return undefined for an unparsable telemetry endpoint', function () {
// Use an agent with proxy configured to trigger the code path that
// parses the target URL in proxyForUrl(). With a malformed endpoint,
// this should throw; the fix wraps it in try/catch and returns undefined.
const agent = useOrCreateAgent({
proxy: 'http://proxy.example.com:8080',
});
createdAgents.push(agent);
const resolved = resolveTelemetryAgent(agent, 'not a url');
expect(resolved).to.equal(undefined);
});
// Enable the queue so tracked events are forwarded to the target, then
// flush — with no endpoint the target is a NoopAnalytics, so no request
// is ever made.
analytics.enable();
analytics.track(identifyEvent);
await analytics.flush();
expect(fetchCount).to.equal(0);
});
});
Loading
Loading