Skip to content

Commit 4bb2a38

Browse files
committed
aksd: fix session-start drop on mid-session telemetry grant
Route trackSessionStart through emitInternal so it bypasses the consent gate the same way emitConsentEvent does; its only production caller (initTelemetry) already runs inside a consent transition and is gated on telemetryEnabled, so the ordinary-event gate was silently dropping session-start on every mid-session opt-in. Also resolve (rather than abandon) the app-version promise on TelemetryBoot's effect cleanup, so a StrictMode double-mount cannot leave the promise pending forever and silently disable telemetry, and reword a consent.ts comment that overstated what revoke actually clears. Add an integration test composing the real consent.ts against the real index.ts, which fails without the trackSessionStart fix.
1 parent d530751 commit 4bb2a38

5 files changed

Lines changed: 195 additions & 10 deletions

File tree

plugins/aks-desktop/src/components/TelemetryBoot.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -92,11 +92,15 @@ function startAppVersionResolution(): { promise: Promise<string>; cleanup: () =>
9292
}, APP_CONFIG_RESPONSE_TIMEOUT_MS);
9393
}
9494

95+
// Resolves rather than abandoning the promise: under a StrictMode
96+
// double-invoke, effect B (the consent effect below) can already be
97+
// awaiting this promise when effect A's cleanup fires. Leaving it
98+
// unsettled would hang that await forever, and because
99+
// previousEnabledRef is no longer null on the re-run, the re-invoked
100+
// effect A/B pair would never call initTelemetry either — silently
101+
// disabling telemetry for the whole session.
95102
const cleanup = () => {
96-
settled = true;
97-
window.clearTimeout(requestTimer);
98-
window.clearTimeout(fallbackTimer);
99-
stopListening();
103+
settle('unknown');
100104
};
101105
return { promise, cleanup };
102106
}
@@ -106,7 +110,9 @@ function startAppVersionResolution(): { promise: Promise<string>; cleanup: () =>
106110
* for the rest of the process — toggling the setting takes effect
107111
* immediately via `grantConsent`/`revokeConsent`, no restart required.
108112
* Renders nothing. StrictMode double-mount is handled by initTelemetry's
109-
* internal initAttempted guard.
113+
* internal initAttempted guard, plus resolving (rather than abandoning)
114+
* the app-version promise on cleanup so a re-invoked mount can still
115+
* proceed to call initTelemetry at all.
110116
*/
111117
export default function TelemetryBoot(): null {
112118
const { enabled } = useTelemetryConfig();
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the Apache 2.0.
3+
4+
// Every other test mocks one side of the consent/index seam: consent.test.ts
5+
// mocks `./index` wholesale, and index.test.ts never composes with
6+
// `consent.ts`. This file composes the REAL consent.ts against the REAL
7+
// index.ts, mocking only the App Insights SDK transport (the same
8+
// vi.hoisted pattern index.test.ts uses), and asserts on the event names
9+
// that actually reach that transport across a full revoke -> grant cycle.
10+
11+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
12+
13+
const aiMocks = vi.hoisted(() => {
14+
const trackEvent = vi.fn();
15+
const loadAppInsights = vi.fn();
16+
const addTelemetryInitializer = vi.fn();
17+
const unload = vi.fn();
18+
const config: Record<string, unknown> = {};
19+
const context = { location: {}, user: {} };
20+
const ApplicationInsightsCtor = vi.fn().mockImplementation(() => ({
21+
trackEvent,
22+
loadAppInsights,
23+
addTelemetryInitializer,
24+
unload,
25+
config,
26+
context,
27+
}));
28+
return {
29+
trackEvent,
30+
loadAppInsights,
31+
addTelemetryInitializer,
32+
unload,
33+
config,
34+
context,
35+
ApplicationInsightsCtor,
36+
};
37+
});
38+
const {
39+
trackEvent,
40+
loadAppInsights,
41+
addTelemetryInitializer,
42+
unload,
43+
config,
44+
context,
45+
ApplicationInsightsCtor,
46+
} = aiMocks;
47+
48+
vi.mock('@microsoft/applicationinsights-web', () => ({
49+
ApplicationInsights: aiMocks.ApplicationInsightsCtor,
50+
}));
51+
52+
const registerMock = vi.hoisted(() => ({ registerHeadlampEventCallback: vi.fn() }));
53+
vi.mock('@kinvolk/headlamp-plugin/lib', async () => {
54+
const actual = await vi.importActual<any>('@kinvolk/headlamp-plugin/lib');
55+
return { ...actual, registerHeadlampEventCallback: registerMock.registerHeadlampEventCallback };
56+
});
57+
58+
// Imported AFTER mocks are registered. Both the orchestrator (consent.ts)
59+
// and the producers/gate (index.ts) are real — nothing under test here is
60+
// mocked.
61+
import { grantConsent, revokeConsent } from './consent';
62+
import { __resetForTests, initTelemetry, setTelemetryEnabled, trackFeature } from './index';
63+
64+
const VALID_INSTALL_ID = '11111111-1111-4111-8111-111111111111';
65+
const SESSION_PROPS = {
66+
appVersion: '1.0.0',
67+
locale: 'en-US',
68+
os: 'linux' as const,
69+
arch: 'x64',
70+
electronVersion: '32.1.0',
71+
headlampVersion: '0.30.0',
72+
};
73+
74+
function realInitialize(): void {
75+
initTelemetry({
76+
connectionString: 'InstrumentationKey=test',
77+
installId: VALID_INSTALL_ID,
78+
sessionProps: SESSION_PROPS,
79+
});
80+
}
81+
82+
function eventNames(): string[] {
83+
return trackEvent.mock.calls.map(([envelope]) => envelope.name);
84+
}
85+
86+
beforeEach(() => {
87+
trackEvent.mockClear();
88+
loadAppInsights.mockClear();
89+
addTelemetryInitializer.mockClear();
90+
unload.mockClear();
91+
for (const key of Object.keys(config)) delete config[key];
92+
ApplicationInsightsCtor.mockReset();
93+
ApplicationInsightsCtor.mockImplementation(() => ({
94+
trackEvent,
95+
loadAppInsights,
96+
addTelemetryInitializer,
97+
unload,
98+
config,
99+
context,
100+
}));
101+
context.location = {};
102+
context.user = {};
103+
registerMock.registerHeadlampEventCallback.mockClear();
104+
__resetForTests();
105+
setTelemetryEnabled(true);
106+
realInitialize();
107+
trackEvent.mockClear();
108+
});
109+
110+
afterEach(() => {
111+
// Don't call vi.restoreAllMocks(): it would wipe the ApplicationInsightsCtor
112+
// mockImplementation re-applied in beforeEach.
113+
});
114+
115+
describe('consent <-> index integration: revoke -> grant cycle', () => {
116+
it('revoke delivers the revoked event and suppresses an ordinary event during the transition', async () => {
117+
const revokePromise = revokeConsent();
118+
// An ordinary event fired mid-transition (gate closed by beginConsentTransition)
119+
// must not reach the transport.
120+
trackFeature({ feature: 'headlamp.logs', status: 'opened' });
121+
await revokePromise;
122+
123+
expect(eventNames()).toContain('headlamp.telemetry-consent');
124+
expect(eventNames()).not.toContain('headlamp.feature');
125+
const consentCall = trackEvent.mock.calls.find(
126+
([e]) => e.name === 'headlamp.telemetry-consent'
127+
);
128+
expect(consentCall?.[0].properties.consent).toBe('revoked');
129+
});
130+
131+
it('grant delivers both session-start and the granted consent event', async () => {
132+
// Simulate a real disable (as revokeConsent would have left things),
133+
// then grant. `initialize` here is the real initTelemetry, exactly as
134+
// TelemetryBoot wires it up.
135+
await revokeConsent();
136+
trackEvent.mockClear();
137+
138+
await grantConsent(() => {
139+
realInitialize();
140+
});
141+
142+
// This is the assertion that would fail before the session-start fix:
143+
// grantConsent closes the gate via beginConsentTransition before calling
144+
// initialize, and initTelemetry's trackSessionStart used to route
145+
// through the gated `emit`, so it was dropped every time. With
146+
// trackSessionStart routed through emitInternal (bypassing the gate,
147+
// the same carve-out emitConsentEvent already takes), it reaches the
148+
// transport even while the gate is still closed during initialize().
149+
expect(eventNames()).toContain('headlamp.session-start');
150+
expect(eventNames()).toContain('headlamp.telemetry-consent');
151+
const consentCall = trackEvent.mock.calls.find(
152+
([e]) => e.name === 'headlamp.telemetry-consent'
153+
);
154+
expect(consentCall?.[0].properties.consent).toBe('granted');
155+
});
156+
});

plugins/aks-desktop/src/telemetry/consent.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,13 @@ export async function revokeConsent(): Promise<void> {
6565
// would kill the freshly (re-)enabled SDK. Leave state untouched.
6666
return;
6767
}
68-
// Revoke clears everything derived from an enabled session. The crash
69-
// marker (workstream 2) and the feature timing map (workstream 3) must
70-
// also be cleared here once they exist, or a later grant would report
71-
// activity belonging to an opted-out period.
68+
// setTelemetryEnabled(false) clears pendingEvents and unloads the SDK,
69+
// but leaves session-derived state such as emittedShapeFor and
70+
// errorCounts untouched across the opted-out period. The crash marker
71+
// (workstream 2) and the feature timing map (workstream 3) will need
72+
// explicit clearing calls added here once they exist, or a later grant
73+
// would report activity belonging to an opted-out period — this
74+
// function does not already clear session-derived state for them.
7275
setTelemetryEnabled(false);
7376
endConsentTransition(gen);
7477
}

plugins/aks-desktop/src/telemetry/index.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,6 +667,18 @@ describe('consent transition primitives', () => {
667667
expect(trackEvent).toHaveBeenCalledWith(expect.objectContaining({ name: 'headlamp.feature' }));
668668
});
669669

670+
it('trackSessionStart still emits while the consent gate is closed', () => {
671+
// trackSessionStart is initTelemetry's last step, and the grant side of
672+
// a consent transition closes the gate before calling initTelemetry.
673+
// If trackSessionStart routed through the gated `emit`, every
674+
// mid-session grant would silently drop session-start.
675+
beginConsentTransition();
676+
trackSessionStart(SESSION_PROPS);
677+
expect(trackEvent).toHaveBeenCalledWith(
678+
expect.objectContaining({ name: 'headlamp.session-start' })
679+
);
680+
});
681+
670682
it('emitConsentEvent is a no-op when telemetry is disabled', () => {
671683
setTelemetryEnabled(false);
672684
emitConsentEvent('revoked');

plugins/aks-desktop/src/telemetry/index.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,8 +369,16 @@ export function emitConsentEvent(consent: TelemetryConsent): void {
369369
emitInternal('headlamp.telemetry-consent', { consent: safeConsent });
370370
}
371371

372+
/**
373+
* Bypasses the ordinary-event gate. Its one production call site is
374+
* initTelemetry, which runs inside a consent transition (the grant side
375+
* closes the gate before re-initializing) and is already gated on
376+
* telemetryEnabled above. Routing this through `emit` instead would drop
377+
* session-start on every mid-session grant, since the gate does not reopen
378+
* until after initTelemetry returns. Do not "fix" this back to `emit`.
379+
*/
372380
export function trackSessionStart(p: SessionStartProps): void {
373-
emit('headlamp.session-start', {
381+
emitInternal('headlamp.session-start', {
374382
appVersion: p.appVersion,
375383
locale: localeLanguage(p.locale),
376384
os: p.os,

0 commit comments

Comments
 (0)