@theia/telemetry 1.74.0-next.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +66 -0
  2. package/lib/browser/browser-telemetry-service.d.ts +16 -0
  3. package/lib/browser/browser-telemetry-service.d.ts.map +1 -0
  4. package/lib/browser/browser-telemetry-service.js +68 -0
  5. package/lib/browser/browser-telemetry-service.js.map +1 -0
  6. package/lib/browser/browser-telemetry-service.spec.d.ts +2 -0
  7. package/lib/browser/browser-telemetry-service.spec.d.ts.map +1 -0
  8. package/lib/browser/browser-telemetry-service.spec.js +163 -0
  9. package/lib/browser/browser-telemetry-service.spec.js.map +1 -0
  10. package/lib/browser/index.d.ts +2 -0
  11. package/lib/browser/index.d.ts.map +1 -0
  12. package/lib/browser/index.js +20 -0
  13. package/lib/browser/index.js.map +1 -0
  14. package/lib/browser/telemetry-frontend-module.d.ts +4 -0
  15. package/lib/browser/telemetry-frontend-module.d.ts.map +1 -0
  16. package/lib/browser/telemetry-frontend-module.js +35 -0
  17. package/lib/browser/telemetry-frontend-module.js.map +1 -0
  18. package/lib/browser-only/browser-only-telemetry-service.d.ts +5 -0
  19. package/lib/browser-only/browser-only-telemetry-service.d.ts.map +1 -0
  20. package/lib/browser-only/browser-only-telemetry-service.js +28 -0
  21. package/lib/browser-only/browser-only-telemetry-service.js.map +1 -0
  22. package/lib/browser-only/browser-only-telemetry-service.spec.d.ts +2 -0
  23. package/lib/browser-only/browser-only-telemetry-service.spec.d.ts.map +1 -0
  24. package/lib/browser-only/browser-only-telemetry-service.spec.js +42 -0
  25. package/lib/browser-only/browser-only-telemetry-service.spec.js.map +1 -0
  26. package/lib/browser-only/telemetry-frontend-only-module.d.ts +4 -0
  27. package/lib/browser-only/telemetry-frontend-only-module.d.ts.map +1 -0
  28. package/lib/browser-only/telemetry-frontend-only-module.js +30 -0
  29. package/lib/browser-only/telemetry-frontend-only-module.js.map +1 -0
  30. package/lib/common/index.d.ts +7 -0
  31. package/lib/common/index.d.ts.map +1 -0
  32. package/lib/common/index.js +25 -0
  33. package/lib/common/index.js.map +1 -0
  34. package/lib/common/telemetry-consent-provider.d.ts +24 -0
  35. package/lib/common/telemetry-consent-provider.d.ts.map +1 -0
  36. package/lib/common/telemetry-consent-provider.js +73 -0
  37. package/lib/common/telemetry-consent-provider.js.map +1 -0
  38. package/lib/common/telemetry-consent-provider.spec.d.ts +2 -0
  39. package/lib/common/telemetry-consent-provider.spec.d.ts.map +1 -0
  40. package/lib/common/telemetry-consent-provider.spec.js +87 -0
  41. package/lib/common/telemetry-consent-provider.spec.js.map +1 -0
  42. package/lib/common/telemetry-preferences.d.ts +29 -0
  43. package/lib/common/telemetry-preferences.d.ts.map +1 -0
  44. package/lib/common/telemetry-preferences.js +77 -0
  45. package/lib/common/telemetry-preferences.js.map +1 -0
  46. package/lib/common/telemetry-preferences.spec.d.ts +2 -0
  47. package/lib/common/telemetry-preferences.spec.d.ts.map +1 -0
  48. package/lib/common/telemetry-preferences.spec.js +50 -0
  49. package/lib/common/telemetry-preferences.spec.js.map +1 -0
  50. package/lib/common/telemetry-protocol.d.ts +30 -0
  51. package/lib/common/telemetry-protocol.d.ts.map +1 -0
  52. package/lib/common/telemetry-protocol.js +78 -0
  53. package/lib/common/telemetry-protocol.js.map +1 -0
  54. package/lib/common/telemetry-service.d.ts +28 -0
  55. package/lib/common/telemetry-service.d.ts.map +1 -0
  56. package/lib/common/telemetry-service.js +65 -0
  57. package/lib/common/telemetry-service.js.map +1 -0
  58. package/lib/common/telemetry-service.spec.d.ts +2 -0
  59. package/lib/common/telemetry-service.spec.d.ts.map +1 -0
  60. package/lib/common/telemetry-service.spec.js +128 -0
  61. package/lib/common/telemetry-service.spec.js.map +1 -0
  62. package/lib/common/telemetry-topic.d.ts +9 -0
  63. package/lib/common/telemetry-topic.d.ts.map +1 -0
  64. package/lib/common/telemetry-topic.js +61 -0
  65. package/lib/common/telemetry-topic.js.map +1 -0
  66. package/lib/common/telemetry-topic.spec.d.ts +2 -0
  67. package/lib/common/telemetry-topic.spec.d.ts.map +1 -0
  68. package/lib/common/telemetry-topic.spec.js +67 -0
  69. package/lib/common/telemetry-topic.spec.js.map +1 -0
  70. package/lib/common/telemetry-types.d.ts +5 -0
  71. package/lib/common/telemetry-types.d.ts.map +1 -0
  72. package/lib/common/telemetry-types.js +21 -0
  73. package/lib/common/telemetry-types.js.map +1 -0
  74. package/lib/common/test/recording-logger.d.ts +9 -0
  75. package/lib/common/test/recording-logger.d.ts.map +1 -0
  76. package/lib/common/test/recording-logger.js +36 -0
  77. package/lib/common/test/recording-logger.js.map +1 -0
  78. package/lib/node/index.d.ts +3 -0
  79. package/lib/node/index.d.ts.map +1 -0
  80. package/lib/node/index.js +21 -0
  81. package/lib/node/index.js.map +1 -0
  82. package/lib/node/telemetry-backend-module.d.ts +4 -0
  83. package/lib/node/telemetry-backend-module.d.ts.map +1 -0
  84. package/lib/node/telemetry-backend-module.js +37 -0
  85. package/lib/node/telemetry-backend-module.js.map +1 -0
  86. package/lib/node/telemetry-service-impl.d.ts +36 -0
  87. package/lib/node/telemetry-service-impl.d.ts.map +1 -0
  88. package/lib/node/telemetry-service-impl.js +208 -0
  89. package/lib/node/telemetry-service-impl.js.map +1 -0
  90. package/lib/node/telemetry-service-impl.spec.d.ts +2 -0
  91. package/lib/node/telemetry-service-impl.spec.d.ts.map +1 -0
  92. package/lib/node/telemetry-service-impl.spec.js +411 -0
  93. package/lib/node/telemetry-service-impl.spec.js.map +1 -0
  94. package/lib/node/telemetry-sink.d.ts +31 -0
  95. package/lib/node/telemetry-sink.d.ts.map +1 -0
  96. package/lib/node/telemetry-sink.js +21 -0
  97. package/lib/node/telemetry-sink.js.map +1 -0
  98. package/package.json +52 -0
  99. package/src/browser/browser-telemetry-service.spec.ts +193 -0
  100. package/src/browser/browser-telemetry-service.ts +69 -0
  101. package/src/browser/index.ts +17 -0
  102. package/src/browser/telemetry-frontend-module.ts +34 -0
  103. package/src/browser-only/browser-only-telemetry-service.spec.ts +44 -0
  104. package/src/browser-only/browser-only-telemetry-service.ts +23 -0
  105. package/src/browser-only/telemetry-frontend-only-module.ts +28 -0
  106. package/src/common/index.ts +22 -0
  107. package/src/common/telemetry-consent-provider.spec.ts +95 -0
  108. package/src/common/telemetry-consent-provider.ts +72 -0
  109. package/src/common/telemetry-preferences.spec.ts +56 -0
  110. package/src/common/telemetry-preferences.ts +91 -0
  111. package/src/common/telemetry-protocol.ts +100 -0
  112. package/src/common/telemetry-service.spec.ts +152 -0
  113. package/src/common/telemetry-service.ts +90 -0
  114. package/src/common/telemetry-topic.spec.ts +78 -0
  115. package/src/common/telemetry-topic.ts +60 -0
  116. package/src/common/telemetry-types.ts +21 -0
  117. package/src/common/test/recording-logger.ts +33 -0
  118. package/src/node/index.ts +18 -0
  119. package/src/node/telemetry-backend-module.ts +40 -0
  120. package/src/node/telemetry-service-impl.spec.ts +500 -0
  121. package/src/node/telemetry-service-impl.ts +214 -0
  122. package/src/node/telemetry-sink.ts +48 -0
@@ -0,0 +1,500 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 STMicroelectronics and others.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import { expect } from 'chai';
18
+ import { ContributionProvider, Emitter, ILogger } from '@theia/core/lib/common';
19
+ import * as sinon from 'sinon';
20
+ import { Container } from '@theia/core/shared/inversify';
21
+ import { TelemetryConsentProvider } from '../common/telemetry-consent-provider';
22
+ import { TELEMETRY_FILTERS, TELEMETRY_LEVEL, TelemetryPreferences } from '../common/telemetry-preferences';
23
+ import { TelemetryEvent } from '../common/telemetry-protocol';
24
+ import { RecordingLogger } from '../common/test/recording-logger';
25
+ import { TelemetryServiceImpl } from './telemetry-service-impl';
26
+ import { BACKEND_TELEMETRY_SESSION, TelemetryLevel } from '../common/telemetry-types';
27
+ import { TelemetrySink } from './telemetry-sink';
28
+
29
+ interface TestPreferences extends TelemetryPreferences {
30
+ setFilters(filters: Record<string, string[]>): void;
31
+ }
32
+
33
+ function createPreferences(initialFilters: Record<string, string[]>, ready = Promise.resolve()): TestPreferences {
34
+ const emitter = new Emitter<never>();
35
+ let filters = initialFilters;
36
+ return {
37
+ [TELEMETRY_LEVEL]: 'off',
38
+ get [TELEMETRY_FILTERS](): Record<string, string[]> {
39
+ return filters;
40
+ },
41
+ ready,
42
+ onPreferenceChanged: emitter.event,
43
+ setFilters: (newFilters: Record<string, string[]>) => {
44
+ filters = newFilters;
45
+ emitter.fire({ preferenceName: TELEMETRY_FILTERS, affects: () => true } as never);
46
+ }
47
+ } as unknown as TestPreferences;
48
+ }
49
+
50
+ function createSink(
51
+ id: string,
52
+ interests: readonly string[] = ['*'],
53
+ scope?: 'local' | 'remote'
54
+ ): TelemetrySink & { events: TelemetryEvent[] } {
55
+ const events: TelemetryEvent[] = [];
56
+ return {
57
+ id,
58
+ interests,
59
+ scope,
60
+ events,
61
+ handle: event => {
62
+ events.push(event);
63
+ }
64
+ };
65
+ }
66
+
67
+ function createConsentProvider(level: TelemetryLevel): TelemetryConsentProvider {
68
+ return { level, onDidChangeTelemetryLevel: new Emitter<TelemetryLevel>().event };
69
+ }
70
+
71
+ function createServiceWithPreferences(
72
+ preferences: TelemetryPreferences,
73
+ sinks: TelemetrySink[],
74
+ logger = new RecordingLogger(),
75
+ level: TelemetryLevel = 'all'
76
+ ): TelemetryServiceImpl {
77
+ const provider: ContributionProvider<TelemetrySink> = { getContributions: () => sinks };
78
+ const container = new Container();
79
+ container.bind(TelemetryConsentProvider).toConstantValue(createConsentProvider(level));
80
+ container.bind(TelemetryPreferences).toConstantValue(preferences);
81
+ container.bind(ContributionProvider).toConstantValue(provider).whenTargetNamed(TelemetrySink);
82
+ container.bind(ILogger).toConstantValue(logger).whenTargetNamed('telemetry:TelemetryServiceImpl');
83
+ container.bind(TelemetryServiceImpl).toSelf();
84
+ return container.get(TelemetryServiceImpl);
85
+ }
86
+
87
+ function createService(level: TelemetryLevel, filters: Record<string, string[]>, sinks: TelemetrySink[], logger = new RecordingLogger()): TelemetryServiceImpl {
88
+ return createServiceWithPreferences(createPreferences(filters), sinks, logger, level);
89
+ }
90
+
91
+ async function flushDispatch(): Promise<void> {
92
+ await Promise.resolve();
93
+ await Promise.resolve();
94
+ }
95
+
96
+ async function reportEvent(
97
+ service: TelemetryServiceImpl,
98
+ event: Pick<TelemetryEvent, 'topic' | 'timestamp'> & Partial<TelemetryEvent> = { topic: 'company/action', timestamp: 42 }
99
+ ): Promise<void> {
100
+ service.notifyEvent({ kind: 'usage', session: 'frontend-session', ...event } as TelemetryEvent);
101
+ await flushDispatch();
102
+ }
103
+
104
+ describe('TelemetryServiceImpl', () => {
105
+ let clock: sinon.SinonFakeTimers;
106
+
107
+ beforeEach(() => {
108
+ clock = sinon.useFakeTimers({ now: 1234 });
109
+ });
110
+
111
+ afterEach(() => clock.restore());
112
+
113
+ it('treats undefined filters as no configured filters', async () => {
114
+ const sink = createSink('company/sink');
115
+ const preferences = createPreferences(undefined as unknown as Record<string, string[]>);
116
+
117
+ await reportEvent(createServiceWithPreferences(preferences, [sink]));
118
+
119
+ expect(sink.events).to.have.length(1);
120
+ });
121
+
122
+ it('allows missing filters and treats an empty filter as disabling the sink', async () => {
123
+ const allowed = createSink('company/allowed');
124
+ const disabled = createSink('company/disabled');
125
+ await reportEvent(createService('all', { 'company/disabled': [] }, [allowed, disabled]));
126
+ expect(allowed.events).to.have.length(1);
127
+ expect(disabled.events).to.be.empty;
128
+ });
129
+
130
+ it('gates remote sinks by level while local sinks bypass consent', async () => {
131
+ const remote = createSink('company/remote');
132
+ const local = createSink('company/local', ['*'], 'local');
133
+ const service = createService('error', {}, [remote, local]);
134
+
135
+ await reportEvent(service);
136
+ await reportEvent(service, { topic: 'company/error', kind: 'error', timestamp: 43 });
137
+ await reportEvent(service, { topic: 'company/crash', kind: 'crash', timestamp: 44 });
138
+
139
+ expect(remote.events.map(event => event.kind)).to.deep.equal(['error', 'crash']);
140
+ expect(local.events.map(event => event.kind)).to.deep.equal(['usage', 'error', 'crash']);
141
+ });
142
+
143
+ it('requires both the sink-specific filter and declared interest to match', async () => {
144
+ const first = createSink('company/first', ['company/*']);
145
+ const second = createSink('company/second', ['other/*']);
146
+ const third = createSink('company/third', ['company/*']);
147
+ const service = createService('all', {
148
+ 'company/first': ['company/action'],
149
+ 'company/second': ['*'],
150
+ 'company/third': ['other/*']
151
+ }, [first, second, third]);
152
+
153
+ await reportEvent(service);
154
+
155
+ expect(first.events).to.have.length(1);
156
+ expect(second.events).to.be.empty;
157
+ expect(third.events).to.be.empty;
158
+ });
159
+
160
+ it('supports exact, terminal wildcard, and global filter/interest intersections', async () => {
161
+ const exact = createSink('company/exact', ['company/action']);
162
+ const prefix = createSink('company/prefix', ['company/*']);
163
+ const global = createSink('company/global', ['*']);
164
+ const service = createService('all', {
165
+ 'company/exact': ['company/action'],
166
+ 'company/prefix': ['company/*'],
167
+ 'company/global': ['*']
168
+ }, [exact, prefix, global]);
169
+
170
+ await reportEvent(service);
171
+ await reportEvent(service, { topic: 'company/action/deep', timestamp: 43 });
172
+ await reportEvent(service, { topic: 'other/action', timestamp: 44 });
173
+
174
+ expect(exact.events.map(event => event.timestamp)).to.deep.equal([42]);
175
+ expect(prefix.events.map(event => event.timestamp)).to.deep.equal([42, 43]);
176
+ expect(global.events).to.have.length(3);
177
+ });
178
+
179
+ it('warns once per invalid filter until filters change', async () => {
180
+ const sink = createSink('company/sink');
181
+ const logger = new RecordingLogger();
182
+ const preferences = createPreferences({ 'company/sink': ['company/**', '*/action', 'company/action'] });
183
+ const service = createServiceWithPreferences(preferences, [sink], logger);
184
+
185
+ await reportEvent(service, { topic: 'other/action', timestamp: 42 });
186
+ await reportEvent(service);
187
+ expect(sink.events).to.have.length(1);
188
+ expect(logger.warnings).to.have.length(2);
189
+
190
+ preferences.setFilters({ 'company/sink': ['invalid/**', '*'] });
191
+ await reportEvent(service, { topic: 'other/action', timestamp: 43 });
192
+ expect(sink.events).to.have.length(2);
193
+ expect(logger.warnings).to.have.length(3);
194
+ });
195
+
196
+ it('rejects invalid and duplicate sink metadata', async () => {
197
+ const invalidId = createSink('invalid');
198
+ const emptyInterests = createSink('company/empty', []);
199
+ const invalidInterests = createSink('company/interests', ['company/**']);
200
+ const duplicateOne = createSink('company/duplicate');
201
+ const duplicateTwo = createSink('company/duplicate');
202
+ const valid = createSink('company/valid');
203
+ const logger = new RecordingLogger();
204
+ const service = createService('all', {
205
+ 'company/empty': ['*'],
206
+ 'company/interests': ['*'],
207
+ 'company/duplicate': ['*'],
208
+ 'company/valid': ['*']
209
+ }, [invalidId, emptyInterests, invalidInterests, duplicateOne, duplicateTwo, valid], logger);
210
+
211
+ await reportEvent(service);
212
+
213
+ expect(invalidId.events).to.be.empty;
214
+ expect(emptyInterests.events).to.be.empty;
215
+ expect(invalidInterests.events).to.be.empty;
216
+ expect(duplicateOne.events).to.be.empty;
217
+ expect(duplicateTwo.events).to.be.empty;
218
+ expect(valid.events).to.have.length(1);
219
+ expect(logger.errors).to.have.length(5);
220
+ });
221
+
222
+ it('returns the deduplicated interests of valid local sinks', async () => {
223
+ const local = createSink('company/local', ['company/*', 'shared/*'], 'local');
224
+ const secondLocal = createSink('company/second-local', ['shared/*', 'other/action'], 'local');
225
+ const remote = createSink('company/remote', ['remote/*']);
226
+ const invalid = createSink('invalid', ['invalid/*'], 'local');
227
+ const logger = new RecordingLogger();
228
+ const service = createService('off', {}, [local, secondLocal, remote, invalid], logger);
229
+
230
+ expect(await service.getLocalSinkInterests()).to.deep.equal(['company/*', 'shared/*', 'other/action']);
231
+ expect(logger.errors).to.have.length(1);
232
+ });
233
+
234
+ it('flushes valid sinks on stop and contains flush failures', async () => {
235
+ const logger = new RecordingLogger();
236
+ const flushed: string[] = [];
237
+ const successful = createSink('company/successful');
238
+ successful.flush = async () => { flushed.push(successful.id); };
239
+ const throwing = createSink('company/throwing');
240
+ throwing.flush = () => { throw new Error('payload-secret'); };
241
+ const rejecting = createSink('company/rejecting');
242
+ rejecting.flush = () => Promise.reject(new Error('payload-secret'));
243
+ const withoutFlush = createSink('company/without-flush');
244
+ const invalid = createSink('invalid');
245
+ invalid.flush = async () => { flushed.push(invalid.id); };
246
+ const service = createService('all', {}, [successful, throwing, rejecting, withoutFlush, invalid], logger);
247
+
248
+ await service.onStop();
249
+
250
+ expect(flushed).to.deep.equal(['company/successful']);
251
+ expect(logger.errors).to.have.length(3);
252
+ expect(logger.errors.join(' ')).not.to.contain('payload-secret');
253
+ });
254
+
255
+ it('uses a snapshot of validated sink metadata', async () => {
256
+ const sink = createSink('company/sink', ['company/action']);
257
+ const service = createService('all', { 'company/sink': ['*'] }, [sink]);
258
+
259
+ await reportEvent(service);
260
+ (sink as { interests: readonly string[] }).interests = ['other/*'];
261
+ await reportEvent(service);
262
+ await reportEvent(service, { topic: 'other/action', timestamp: 43 });
263
+
264
+ expect(sink.events.map(event => event.topic)).to.deep.equal(['company/action', 'company/action']);
265
+ });
266
+
267
+ it('dispatches queued events in FIFO order after preference readiness', async () => {
268
+ let resolveReady: () => void;
269
+ const ready = new Promise<void>(resolve => resolveReady = resolve);
270
+ const sink = createSink('company/sink');
271
+ const service = createServiceWithPreferences(createPreferences({ 'company/sink': ['*'] }, ready), [sink]);
272
+
273
+ service.report('company/first');
274
+ service.report('company/second');
275
+ service.report('company/third');
276
+ expect(sink.events).to.be.empty;
277
+
278
+ resolveReady!();
279
+ await flushDispatch();
280
+
281
+ expect(sink.events.map(event => event.topic)).to.deep.equal(['company/first', 'company/second', 'company/third']);
282
+ });
283
+
284
+ it('bounds queued events, discards the oldest, preserves FIFO order, and warns once', async () => {
285
+ let resolveReady: () => void;
286
+ const ready = new Promise<void>(resolve => resolveReady = resolve);
287
+ const logger = new RecordingLogger();
288
+ const sink = createSink('company/sink');
289
+ const service = createServiceWithPreferences(createPreferences({}, ready), [sink], logger);
290
+
291
+ for (let index = 0; index < 1_002; index++) {
292
+ service.report(`company/${index}`);
293
+ }
294
+ expect(logger.warnings).to.have.length(1);
295
+
296
+ resolveReady!();
297
+ await flushDispatch();
298
+
299
+ expect(sink.events).to.have.length(1_000);
300
+ expect(sink.events.map(event => event.topic)).to.deep.equal(
301
+ Array.from({ length: 1_000 }, (_, index) => `company/${index + 2}`)
302
+ );
303
+ expect(logger.warnings).to.have.length(1);
304
+ });
305
+
306
+ it('timestamps direct reports and snapshots payloads before deferred dispatch', async () => {
307
+ let resolveReady: () => void;
308
+ const ready = new Promise<void>(resolve => resolveReady = resolve);
309
+ const data = { action: 'open', durations: [1, 2], states: [true, false] };
310
+ const attributes = { source: 'backend', labels: ['stable'] };
311
+ const sink = createSink('company/sink');
312
+ const service = createServiceWithPreferences(createPreferences({ 'company/sink': ['*'] }, ready), [sink]);
313
+
314
+ service.report('company/action', data, { kind: 'error', attributes });
315
+ data.action = 'changed';
316
+ attributes.source = 'changed';
317
+ attributes.labels.push('changed');
318
+ data.durations.push(3);
319
+ resolveReady!();
320
+ await flushDispatch();
321
+
322
+ expect(sink.events).to.have.length(1);
323
+ expect(sink.events[0].data).to.deep.equal({ action: 'open', durations: [1, 2], states: [true, false] });
324
+ expect(sink.events[0].data).not.to.equal(data);
325
+ expect(sink.events[0].kind).to.equal('error');
326
+ expect(sink.events[0].attributes).to.deep.equal({ source: 'backend', labels: ['stable'] });
327
+ expect(sink.events[0].session).to.equal(BACKEND_TELEMETRY_SESSION);
328
+ expect(Object.isFrozen(sink.events[0])).to.be.true;
329
+ expect(Object.isFrozen(sink.events[0].data)).to.be.true;
330
+ expect(Object.isFrozen(sink.events[0].data?.durations)).to.be.true;
331
+ expect(Object.isFrozen(sink.events[0].attributes)).to.be.true;
332
+ expect(Object.isFrozen(sink.events[0].attributes?.labels)).to.be.true;
333
+ expect(sink.events[0].timestamp).to.equal(1234);
334
+ });
335
+
336
+ it('preserves RPC timestamps and isolates immutable payloads between sinks', async () => {
337
+ const data = { action: 'open', durations: [1, 2] };
338
+ const event = { topic: 'company/action', kind: 'error' as const, data, attributes: { source: 'rpc', labels: ['stable'] }, session: 'rpc-session', timestamp: 987 };
339
+ const observed: TelemetryEvent[] = [];
340
+ const mutating: TelemetrySink = {
341
+ id: 'company/mutating',
342
+ interests: ['*'],
343
+ handle: received => {
344
+ expect(() => Object.assign(received.data!, { action: 'changed' })).to.throw();
345
+ expect(() => (received.data!.durations as number[]).push(3)).to.throw();
346
+ expect(() => Object.assign(received.attributes!, { source: 'changed' })).to.throw();
347
+ expect(() => (received.attributes!.labels as string[]).push('changed')).to.throw();
348
+ }
349
+ };
350
+ const recording: TelemetrySink = {
351
+ id: 'company/recording',
352
+ interests: ['*'],
353
+ handle: received => {
354
+ observed.push(received);
355
+ }
356
+ };
357
+ const service = createService('all', { 'company/mutating': ['*'], 'company/recording': ['*'] }, [mutating, recording]);
358
+
359
+ await reportEvent(service, event);
360
+ data.action = 'changed';
361
+ data.durations.push(3);
362
+ event.attributes.source = 'changed';
363
+ event.attributes.labels.push('changed');
364
+
365
+ expect(observed[0].data).to.deep.equal({ action: 'open', durations: [1, 2] });
366
+ expect(observed[0].kind).to.equal('error');
367
+ expect(observed[0].attributes).to.deep.equal({ source: 'rpc', labels: ['stable'] });
368
+ expect(observed[0].session).to.equal('frontend/rpc-session');
369
+ expect(observed[0].timestamp).to.equal(987);
370
+ });
371
+
372
+ it('strips unknown top-level properties from RPC events', async () => {
373
+ const sink = createSink('company/sink');
374
+ const service = createService('all', {}, [sink]);
375
+
376
+ service.notifyEvent({
377
+ topic: 'company/action',
378
+ kind: 'usage',
379
+ session: 'frontend-session',
380
+ timestamp: 42,
381
+ unknown: 'value'
382
+ } as TelemetryEvent);
383
+ await flushDispatch();
384
+
385
+ expect(sink.events).to.have.length(1);
386
+ expect(Object.keys(sink.events[0])).to.deep.equal(['topic', 'kind', 'data', 'attributes', 'session', 'timestamp']);
387
+ });
388
+
389
+ it('drops malformed reports and RPC values without logging payload values', async () => {
390
+ const sink = createSink('company/sink');
391
+ const logger = new RecordingLogger();
392
+ const service = createService('all', { 'company/sink': ['*'] }, [sink], logger);
393
+
394
+ service.report('invalid', { secret: 'payload-secret' });
395
+ service.report('company/action', { mixed: [1, 'payload-secret'] } as never);
396
+ service.report('company/action', undefined, { kind: 'invalid' as never });
397
+ service.report('company/action', undefined, { attributes: { nested: { secret: 'payload-secret' } } as never });
398
+ const validEnvelope = { topic: 'company/action', kind: 'usage', session: 'frontend-session', timestamp: 42 };
399
+ service.notifyEvent({ ...validEnvelope, data: { nested: { secret: 'payload-secret' } } as never });
400
+ service.notifyEvent({ ...validEnvelope, attributes: { nested: { secret: 'payload-secret' } } as never });
401
+ service.notifyEvent({ ...validEnvelope, kind: 'invalid' });
402
+ service.notifyEvent({ ...validEnvelope, session: '' });
403
+ service.notifyEvent({ ...validEnvelope, timestamp: Number.POSITIVE_INFINITY });
404
+ service.notifyEvent(undefined);
405
+ service.notifyEvent('payload-secret');
406
+ service.notifyEvent({ ...validEnvelope, topic: 42, data: 'payload-secret' });
407
+ service.notifyEvent({ kind: 'usage', session: 'frontend-session', data: { secret: 'payload-secret' }, timestamp: 42 });
408
+ await flushDispatch();
409
+
410
+ expect(sink.events).to.be.empty;
411
+ expect(logger.warnings).to.have.length(13);
412
+ expect(logger.warnings.join(' ')).not.to.contain('payload-secret');
413
+ });
414
+
415
+ it('dispatches synchronously after preference readiness', async () => {
416
+ const sink = createSink('company/sink');
417
+ const preferences = createPreferences({ 'company/sink': ['*'] });
418
+ const service = createServiceWithPreferences(preferences, [sink]);
419
+ await preferences.ready;
420
+ await flushDispatch();
421
+
422
+ service.report('company/action');
423
+
424
+ expect(sink.events).to.have.length(1);
425
+ });
426
+
427
+ it('delivers queued and subsequent events only to local sinks after preference readiness rejection and logs once', async () => {
428
+ let rejectReady: (reason: Error) => void;
429
+ const ready = new Promise<void>((_resolve, reject) => rejectReady = reject);
430
+ const logger = new RecordingLogger();
431
+ const local = createSink('company/local', ['*'], 'local');
432
+ const remote = createSink('company/remote');
433
+ const preferences = createPreferences({ 'company/local': ['*'], 'company/remote': ['*'] }, ready);
434
+ const service = createServiceWithPreferences(preferences, [local, remote], logger);
435
+
436
+ service.report('company/first', { secret: 'first-secret' });
437
+ service.report('company/second', { secret: 'second-secret' });
438
+ rejectReady!(new Error('readiness-secret'));
439
+ await flushDispatch();
440
+ service.report('company/third', { secret: 'third-secret' });
441
+
442
+ expect(local.events.map(event => event.topic)).to.deep.equal(['company/first', 'company/second', 'company/third']);
443
+ expect(remote.events).to.be.empty;
444
+ expect(logger.errors).to.have.length(1);
445
+ expect(logger.errors.join(' ')).not.to.contain('secret');
446
+ });
447
+
448
+ it('uses the same policy path for direct and RPC reports', async () => {
449
+ const sink = createSink('company/sink', ['company/*']);
450
+ const service = createService('all', { 'company/sink': ['company/*'] }, [sink]);
451
+
452
+ service.report('company/direct');
453
+ service.notifyEvent({ topic: 'company/rpc', kind: 'crash', session: 'rpc-session', timestamp: 99 });
454
+ await flushDispatch();
455
+
456
+ expect(sink.events.map(event => event.topic)).to.deep.equal(['company/direct', 'company/rpc']);
457
+ expect(sink.events.map(event => event.timestamp)).to.deep.equal([1234, 99]);
458
+ expect(sink.events.map(event => event.kind)).to.deep.equal(['usage', 'crash']);
459
+ expect(sink.events.map(event => event.session)).to.deep.equal([BACKEND_TELEMETRY_SESSION, 'frontend/rpc-session']);
460
+ });
461
+
462
+ it('prevents RPC events from impersonating the backend session', async () => {
463
+ const sink = createSink('company/sink');
464
+ const service = createService('all', {}, [sink]);
465
+
466
+ service.notifyEvent({
467
+ topic: 'company/rpc',
468
+ kind: 'usage',
469
+ session: BACKEND_TELEMETRY_SESSION,
470
+ timestamp: 99
471
+ });
472
+ await flushDispatch();
473
+
474
+ expect(sink.events).to.have.length(1);
475
+ expect(sink.events[0].session).to.equal(`frontend/${BACKEND_TELEMETRY_SESSION}`);
476
+ expect(sink.events[0].session).not.to.equal(BACKEND_TELEMETRY_SESSION);
477
+ });
478
+
479
+ it('isolates void handlers, synchronous throws, asynchronous rejections, and slow sinks', async () => {
480
+ const logger = new RecordingLogger();
481
+ const successful = createSink('company/successful');
482
+ const throwing: TelemetrySink = {
483
+ id: 'company/throwing', interests: ['*'], handle: () => { throw new Error('payload-secret'); }
484
+ };
485
+ const rejecting: TelemetrySink = {
486
+ id: 'company/rejecting', interests: ['*'], handle: () => Promise.reject(new Error('payload-secret'))
487
+ };
488
+ const slow: TelemetrySink = {
489
+ id: 'company/slow', interests: ['*'], handle: () => new Promise<void>(() => undefined)
490
+ };
491
+ const filters = Object.fromEntries([successful, throwing, rejecting, slow].map(sink => [sink.id, ['*']]));
492
+
493
+ await reportEvent(createService('all', filters, [throwing, slow, successful, rejecting], logger));
494
+ await Promise.resolve();
495
+
496
+ expect(successful.events).to.have.length(1);
497
+ expect(logger.errors).to.have.length(2);
498
+ expect(logger.errors.join(' ')).not.to.contain('payload-secret');
499
+ });
500
+ });