Skip to content

Commit d20babc

Browse files
Merge pull request #1177 from browserstack/fix/sdk-7399-o11y-flush-skips-tests
fix(o11y): batch the event flush so command-heavy specs stop hitting spec_timeout [SDK-7399]
2 parents f4a4da2 + 456295d commit d20babc

4 files changed

Lines changed: 309 additions & 16 deletions

File tree

bin/testObservability/cypress/index.js

Lines changed: 81 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ const shouldSkipCommand = (command) => {
5050
if (!Cypress.env('BROWSERSTACK_O11Y_LOGS')) {
5151
return true;
5252
}
53-
return command.attributes.name == 'log' || (command.attributes.name == 'task' && (['test_observability_platform_details', 'test_observability_step', 'test_observability_command', 'browserstack_log', 'test_observability_log'].some(event => command.attributes.args.includes(event))));
53+
/* the batch task is filtered too, else each dispatch refills the queue */
54+
return command.attributes.name == 'log' || (command.attributes.name == 'task' && (['test_observability_platform_details', 'test_observability_step', 'test_observability_command', 'test_observability_batch', 'browserstack_log', 'test_observability_log'].some(event => command.attributes.args.includes(event))));
5455
}
5556

5657
Cypress.on('log:changed', (attrs) => {
@@ -339,20 +340,91 @@ Cypress.Commands.add('fatal', (message, file) => {
339340
});
340341
});
341342

343+
/* console.warn, not cy.task — a diagnostic must not use the mechanism it reports on */
344+
const warnFlushFailure = (stage, err) => {
345+
try {
346+
console.warn(`BrowserStack Test Observability: suppressed ${stage} error, event(s) dropped: ${err && err.message ? err.message : err}`);
347+
} catch (e) { /* logging must never throw either */ }
348+
};
349+
350+
/*
351+
* [SDK-7399] One cy.task per drain, not per event. Each round-trip costs ~0.8s on a
352+
* remote terminal, so a command-heavy spec spent minutes in afterEach, exceeded
353+
* spec_timeout and was killed — unrun tests then reported as skipped, with nothing
354+
* thrown. 600 events: 581s as 600 calls, 109s as one.
355+
* Stays on cy.task: cy.now('task') throws on Cypress 14, so it would "fix" this by
356+
* delivering nothing. The try/catch is only a backstop for synchronous throws — a
357+
* cy.task failure surfaces later, while the command queue drains.
358+
*/
359+
360+
/* Remote terminal: a single cy.task payload of 768KB succeeds, 1MB fails.
361+
* Split well under that; drop only what cannot be sent at all. The two must stay
362+
* distinct — an event between them still sends on its own. */
363+
const MAX_BATCH_CHARS = 512 * 1024;
364+
const MAX_EVENT_CHARS = 768 * 1024;
365+
366+
const flushEventsQueue = () => {
367+
try {
368+
const queued = eventsQueue;
369+
eventsQueue = []; /* cleared before dispatch so a throw cannot replay these events */
370+
if (queued.length === 0) return;
371+
372+
let batch = [];
373+
let batchChars = 0;
374+
375+
const sendBatch = () => {
376+
if (batch.length === 0) return;
377+
const toSend = batch;
378+
batch = [];
379+
batchChars = 0;
380+
try {
381+
/* every push site uses { log: false }, so per-event options are not forwarded */
382+
cy.task('test_observability_batch', toSend, { log: false });
383+
} catch (e) {
384+
warnFlushFailure(`batch dispatch of ${toSend.length} event(s)`, e);
385+
}
386+
};
387+
388+
queued.forEach(event => {
389+
try {
390+
const payload = sanitizeForTask(event.data);
391+
if (payload === null) {
392+
warnFlushFailure(`unserializable payload for '${event.task}'`,
393+
new Error('event skipped'));
394+
return;
395+
}
396+
const size = JSON.stringify(payload).length;
397+
if (size > MAX_EVENT_CHARS) {
398+
/* past the largest size measured to send; 768KB-1MB is untested, so skip */
399+
warnFlushFailure(`event too large to send for '${event.task}' (${size} chars)`,
400+
new Error('event skipped'));
401+
return;
402+
}
403+
/* oversized-but-sendable: let it travel alone */
404+
if (batch.length > 0 && batchChars + size > MAX_BATCH_CHARS) sendBatch();
405+
batch.push({ task: event.task, data: payload });
406+
batchChars += size;
407+
if (batchChars >= MAX_BATCH_CHARS) sendBatch();
408+
} catch (e) {
409+
warnFlushFailure(`preparing '${event.task}'`, e); /* skip one event, not the rest */
410+
}
411+
});
412+
413+
sendBatch();
414+
} catch (e) {
415+
warnFlushFailure('queue flush', e);
416+
eventsQueue = [];
417+
}
418+
};
419+
342420
beforeEach(() => {
343421
/* browserstack internal helper hook */
344422

345423
if (!Cypress.env('BROWSERSTACK_O11Y_LOGS')) {
346424
return;
347425
}
348426

349-
if (eventsQueue.length > 0) {
350-
eventsQueue.forEach(event => {
351-
const payload = sanitizeForTask(event.data);
352-
if (payload !== null) cy.task(event.task, payload, event.options);
353-
});
354-
}
355-
eventsQueue = [];
427+
flushEventsQueue();
356428
testRunStarted = true;
357429
});
358430

@@ -362,13 +434,6 @@ afterEach(function() {
362434
return;
363435
}
364436

365-
if (eventsQueue.length > 0) {
366-
eventsQueue.forEach(event => {
367-
const payload = sanitizeForTask(event.data);
368-
if (payload !== null) cy.task(event.task, payload, event.options);
369-
});
370-
}
371-
372-
eventsQueue = [];
437+
flushEventsQueue();
373438
testRunStarted = false;
374439
});

bin/testObservability/plugin/index.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ const browserstackTestObservabilityPlugin = (on, config, callbacks) => {
1111

1212
connectIPCClient(config);
1313

14+
const IPC_EVENT_FOR_TASK = {
15+
test_observability_log: IPC_EVENTS.LOG,
16+
test_observability_command: IPC_EVENTS.COMMAND,
17+
test_observability_platform_details: IPC_EVENTS.PLATFORM_DETAILS,
18+
test_observability_step: IPC_EVENTS.CUCUMBER,
19+
};
20+
1421
on('task', {
1522
test_observability_log(log) {
1623
ipc.of.browserstackTestObservability.emit(IPC_EVENTS.LOG, log);
@@ -27,6 +34,21 @@ const browserstackTestObservabilityPlugin = (on, config, callbacks) => {
2734
test_observability_step(log) {
2835
ipc.of.browserstackTestObservability.emit(IPC_EVENTS.CUCUMBER, log);
2936
return null;
37+
},
38+
/* [SDK-7399] One task per flush instead of one per event — see cypress/index.js.
39+
* Fans out to the same IPC events; the per-event tasks stay for back-compat. */
40+
test_observability_batch(events) {
41+
if (!Array.isArray(events)) return null;
42+
events.forEach((event) => {
43+
try {
44+
const ipcEvent = event && IPC_EVENT_FOR_TASK[event.task];
45+
if (!ipcEvent) return;
46+
ipc.of.browserstackTestObservability.emit(ipcEvent, event.data);
47+
} catch (e) {
48+
/* one bad entry must not drop the rest */
49+
}
50+
});
51+
return null;
3052
}
3153
});
3254

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
'use strict';
2+
const chai = require('chai');
3+
const expect = chai.expect;
4+
const sinon = require('sinon');
5+
const proxyquire = require('proxyquire');
6+
7+
// Regression guard for SDK-7399. The flush used to issue one cy.task per queued event,
8+
// and each round-trip costs ~0.8s on a remote terminal, so a command-heavy spec spent
9+
// minutes in afterEach, exceeded spec_timeout and was killed — every test that had not
10+
// run yet was then reported as skipped. Nothing threw, so a passing spec alone cannot
11+
// distinguish "delivered" from "silently dropped"; these tests pin the fan-out instead.
12+
describe('SDK-7399 batched observability flush', () => {
13+
let emit, ipcStub, tasks, plugin;
14+
15+
beforeEach(() => {
16+
emit = sinon.stub();
17+
ipcStub = { of: { browserstackTestObservability: { emit } } };
18+
plugin = proxyquire('../../../../bin/testObservability/plugin', {
19+
'node-ipc': ipcStub,
20+
'./ipcClient': { connectIPCClient: () => {} },
21+
});
22+
tasks = null;
23+
const on = (name, handlers) => { if (name === 'task') tasks = handlers; };
24+
plugin(on, { env: {} });
25+
});
26+
27+
afterEach(() => sinon.restore());
28+
29+
it('registers the batch task alongside the per-event tasks', () => {
30+
expect(tasks).to.have.property('test_observability_batch');
31+
// per-event tasks must stay registered: an older browser bundle may still call them
32+
expect(tasks).to.have.property('test_observability_log');
33+
expect(tasks).to.have.property('test_observability_command');
34+
expect(tasks).to.have.property('test_observability_platform_details');
35+
expect(tasks).to.have.property('test_observability_step');
36+
});
37+
38+
it('fans every queued task type out to its own IPC event, in order', () => {
39+
tasks.test_observability_batch([
40+
{ task: 'test_observability_log', data: { m: 1 } },
41+
{ task: 'test_observability_command', data: { m: 2 } },
42+
{ task: 'test_observability_platform_details', data: { m: 3 } },
43+
{ task: 'test_observability_step', data: { m: 4 } },
44+
]);
45+
46+
expect(emit.callCount).to.equal(4);
47+
expect(emit.getCalls().map(c => c.args[1].m)).to.deep.equal([1, 2, 3, 4]);
48+
// four distinct IPC events, i.e. no type collapsed onto another
49+
expect(new Set(emit.getCalls().map(c => c.args[0])).size).to.equal(4);
50+
});
51+
52+
it('emits one IPC event per entry for a large batch', () => {
53+
const batch = [];
54+
for (let i = 0; i < 600; i++) {
55+
batch.push({ task: 'test_observability_log', data: { i } });
56+
}
57+
tasks.test_observability_batch(batch);
58+
expect(emit.callCount).to.equal(600);
59+
});
60+
61+
it('skips an unknown task name but still delivers the rest of the batch', () => {
62+
tasks.test_observability_batch([
63+
{ task: 'not_a_real_task', data: { m: 'x' } },
64+
{ task: 'test_observability_log', data: { m: 'kept' } },
65+
]);
66+
expect(emit.callCount).to.equal(1);
67+
expect(emit.firstCall.args[1].m).to.equal('kept');
68+
});
69+
70+
it('never throws on malformed input', () => {
71+
expect(() => tasks.test_observability_batch(undefined)).to.not.throw();
72+
expect(() => tasks.test_observability_batch({})).to.not.throw();
73+
expect(() => tasks.test_observability_batch([null, undefined, 1, 'x'])).to.not.throw();
74+
expect(emit.callCount).to.equal(0);
75+
});
76+
77+
it('one failing emit does not drop the remaining entries', () => {
78+
emit.onFirstCall().throws(new Error('ipc down'));
79+
tasks.test_observability_batch([
80+
{ task: 'test_observability_log', data: { m: 1 } },
81+
{ task: 'test_observability_log', data: { m: 2 } },
82+
]);
83+
expect(emit.callCount).to.equal(2);
84+
});
85+
});
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
'use strict';
2+
const chai = require('chai');
3+
const expect = chai.expect;
4+
const sinon = require('sinon');
5+
6+
// Guards the threshold arithmetic in bin/testObservability/cypress/index.js.
7+
// SDK-7399 review finding: the batch-split figure (512KB) had been reused as the
8+
// single-event drop ceiling, so events in the 512-768KB band — which a single cy.task
9+
// was measured to carry — were silently discarded. These cases pin the split, the
10+
// oversized-but-sendable path, and the drop boundary.
11+
describe('SDK-7399 flush thresholds', () => {
12+
const KB = 1024;
13+
let taskSpy, afterEachCb, commandStartCb;
14+
15+
// The browser-side file registers listeners and hooks at require time, so the Cypress
16+
// globals have to exist first. Capture the pieces the flush needs.
17+
const loadBrowserSide = () => {
18+
const listeners = {};
19+
taskSpy = sinon.stub().returns(undefined);
20+
21+
global.cy = { task: taskSpy, now: sinon.stub() };
22+
global.Cypress = {
23+
on: (evt, cb) => { listeners[evt] = cb; },
24+
env: (k) => (k === 'BROWSERSTACK_O11Y_LOGS' ? 'true' : undefined),
25+
Commands: { add: () => {}, overwrite: () => {} },
26+
browser: { name: 'chrome', majorVersion: '136' },
27+
platform: 'win32',
28+
version: '14.3.3',
29+
mocha: { getRunner: () => ({ suite: { ctx: { currentTest: { title: 't' } } } }) },
30+
};
31+
global.beforeEach = () => {};
32+
global.afterEach = (cb) => { afterEachCb = cb; };
33+
34+
delete require.cache[require.resolve('../../../../bin/testObservability/cypress')];
35+
require('../../../../bin/testObservability/cypress');
36+
commandStartCb = listeners['command:start'];
37+
};
38+
39+
// One queued event whose serialized payload is ~sizeKB, via a command arg.
40+
const queueEventOfSize = (sizeKB) => {
41+
commandStartCb({ attributes: { id: 'c1', name: 'type', args: ['x'.repeat(sizeKB * KB)] } });
42+
};
43+
44+
beforeEach(loadBrowserSide);
45+
46+
afterEach(() => {
47+
sinon.restore();
48+
delete global.cy; delete global.Cypress;
49+
delete global.beforeEach; delete global.afterEach;
50+
});
51+
52+
const batchesSent = () =>
53+
taskSpy.getCalls()
54+
.filter(c => c.args[0] === 'test_observability_batch')
55+
.map(c => c.args[1]);
56+
57+
it('sends small events together in a single batch', () => {
58+
queueEventOfSize(1);
59+
queueEventOfSize(1);
60+
afterEachCb();
61+
62+
const batches = batchesSent();
63+
expect(batches.length).to.equal(1);
64+
expect(batches[0].length).to.be.greaterThan(1);
65+
});
66+
67+
it('dispatches a 600KB event alone rather than dropping it (the regression)', () => {
68+
// command:start queues two events: the command itself, plus small platform details —
69+
// so the big one is expected in a batch of its own, with the small one following.
70+
queueEventOfSize(600);
71+
afterEachCb();
72+
73+
const batches = batchesSent();
74+
const carrying = batches.filter(b =>
75+
b.some(e => JSON.stringify(e.data).length > 512 * KB));
76+
expect(carrying.length, 'the 512-768KB band must still be delivered').to.equal(1);
77+
expect(carrying[0].length, 'oversized-but-sendable event travels alone').to.equal(1);
78+
});
79+
80+
it('keeps a 600KB event out of the batch holding the small ones', () => {
81+
queueEventOfSize(1);
82+
queueEventOfSize(600);
83+
afterEachCb();
84+
85+
const batches = batchesSent();
86+
expect(batches.length).to.be.greaterThan(1);
87+
batches.forEach(b => {
88+
const chars = b.reduce((n, e) => n + JSON.stringify(e.data).length, 0);
89+
// a multi-event batch stays under the split figure; a lone event may exceed it
90+
if (b.length > 1) expect(chars).to.be.at.most(512 * KB);
91+
});
92+
});
93+
94+
it('skips an event past the largest measured-safe size', () => {
95+
queueEventOfSize(900);
96+
afterEachCb();
97+
98+
batchesSent().forEach(b => {
99+
b.forEach(e => {
100+
expect(JSON.stringify(e.data).length).to.be.at.most(768 * KB);
101+
});
102+
});
103+
});
104+
105+
it('never assembles a multi-event batch beyond the split figure', () => {
106+
for (let i = 0; i < 12; i++) queueEventOfSize(64);
107+
afterEachCb();
108+
109+
batchesSent().forEach(b => {
110+
if (b.length > 1) {
111+
const chars = b.reduce((n, e) => n + JSON.stringify(e.data).length, 0);
112+
expect(chars).to.be.at.most(512 * KB);
113+
}
114+
});
115+
});
116+
117+
it('sends nothing when the queue is empty', () => {
118+
afterEachCb();
119+
expect(batchesSent().length).to.equal(0);
120+
});
121+
});

0 commit comments

Comments
 (0)