@servicetitan/startup 38.1.0 → 38.2.0
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.
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/build.js +10 -1
- package/dist/cli/commands/build.js.map +1 -1
- package/dist/cli/commands/command.d.ts +4 -0
- package/dist/cli/commands/command.d.ts.map +1 -1
- package/dist/cli/commands/command.js +39 -0
- package/dist/cli/commands/command.js.map +1 -1
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +0 -1
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/install.d.ts.map +1 -1
- package/dist/cli/commands/install.js +1 -17
- package/dist/cli/commands/install.js.map +1 -1
- package/dist/cli/commands/registry/command-registry.d.ts +0 -16
- package/dist/cli/commands/registry/command-registry.d.ts.map +1 -1
- package/dist/cli/commands/registry/install.d.ts +0 -16
- package/dist/cli/commands/registry/install.d.ts.map +1 -1
- package/dist/cli/commands/registry/install.js +1 -17
- package/dist/cli/commands/registry/install.js.map +1 -1
- package/dist/cli/commands/start.d.ts.map +1 -1
- package/dist/cli/commands/start.js +9 -1
- package/dist/cli/commands/start.js.map +1 -1
- package/dist/cli/utils/cli-git.d.ts +7 -0
- package/dist/cli/utils/cli-git.d.ts.map +1 -1
- package/dist/cli/utils/cli-git.js +30 -0
- package/dist/cli/utils/cli-git.js.map +1 -1
- package/dist/cli/utils/cli-os.d.ts +6 -1
- package/dist/cli/utils/cli-os.d.ts.map +1 -1
- package/dist/cli/utils/cli-os.js +22 -0
- package/dist/cli/utils/cli-os.js.map +1 -1
- package/dist/telemetry/index.d.ts +3 -0
- package/dist/telemetry/index.d.ts.map +1 -0
- package/dist/telemetry/index.js +21 -0
- package/dist/telemetry/index.js.map +1 -0
- package/dist/telemetry/telemetry.d.ts +15 -0
- package/dist/telemetry/telemetry.d.ts.map +1 -0
- package/dist/telemetry/telemetry.js +176 -0
- package/dist/telemetry/telemetry.js.map +1 -0
- package/dist/vite/config/create-filtering-logger.d.ts.map +1 -1
- package/dist/vite/config/create-filtering-logger.js +6 -2
- package/dist/vite/config/create-filtering-logger.js.map +1 -1
- package/dist/webpack/configs/optimization-config.js +13 -8
- package/dist/webpack/configs/optimization-config.js.map +1 -1
- package/package.json +17 -14
- package/src/cli/commands/__tests__/build.test.ts +49 -2
- package/src/cli/commands/__tests__/command.test.ts +112 -0
- package/src/cli/commands/__tests__/init.test.ts +1 -1
- package/src/cli/commands/__tests__/install.test.ts +5 -72
- package/src/cli/commands/__tests__/start.test.ts +43 -2
- package/src/cli/commands/build.ts +9 -1
- package/src/cli/commands/command.ts +50 -0
- package/src/cli/commands/init.ts +0 -1
- package/src/cli/commands/install.ts +5 -21
- package/src/cli/commands/registry/install.ts +1 -6
- package/src/cli/commands/start.ts +8 -1
- package/src/cli/utils/__tests__/cli-git.test.ts +54 -3
- package/src/cli/utils/__tests__/cli-os.test.ts +98 -2
- package/src/cli/utils/cli-git.ts +27 -1
- package/src/cli/utils/cli-os.ts +30 -0
- package/src/telemetry/__tests__/telemetry.test.ts +326 -0
- package/src/telemetry/index.ts +2 -0
- package/src/telemetry/telemetry.ts +134 -0
- package/src/vite/config/__tests__/create-filtering-logger.test.ts +3 -2
- package/src/vite/config/create-filtering-logger.ts +8 -2
- package/src/webpack/__tests__/create-webpack-config-shared-dependencies.test.ts +27 -1
- package/src/webpack/configs/optimization-config.ts +19 -8
package/src/cli/utils/cli-git.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { runCommandOutput, runCommandOutputAsync } from './cli-os';
|
|
3
|
+
|
|
4
|
+
const GIT_TIMEOUT_MS = 2000;
|
|
2
5
|
|
|
3
6
|
export function gitGetBranch(): string {
|
|
4
7
|
return runCommandOutput('git rev-parse --abbrev-ref HEAD').trim();
|
|
@@ -7,3 +10,26 @@ export function gitGetBranch(): string {
|
|
|
7
10
|
export function gitGetCommitHash(): string {
|
|
8
11
|
return runCommandOutput('git rev-parse --short HEAD').trim();
|
|
9
12
|
}
|
|
13
|
+
|
|
14
|
+
export interface GitContext {
|
|
15
|
+
repo?: string;
|
|
16
|
+
branch?: string;
|
|
17
|
+
userEmail?: string;
|
|
18
|
+
userName?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function gitGetContext(): Promise<GitContext> {
|
|
22
|
+
const options = { quiet: true, ignoreErrors: true, timeout: GIT_TIMEOUT_MS };
|
|
23
|
+
const [rev, userEmail, userName] = await Promise.all([
|
|
24
|
+
runCommandOutputAsync('git rev-parse --abbrev-ref HEAD --show-toplevel', options),
|
|
25
|
+
runCommandOutputAsync('git config user.email', options),
|
|
26
|
+
runCommandOutputAsync('git config user.name', options),
|
|
27
|
+
]);
|
|
28
|
+
const [branch, root] = rev?.split('\n') ?? [];
|
|
29
|
+
return {
|
|
30
|
+
repo: root ? path.basename(root) : undefined,
|
|
31
|
+
branch,
|
|
32
|
+
userEmail,
|
|
33
|
+
userName,
|
|
34
|
+
};
|
|
35
|
+
}
|
package/src/cli/utils/cli-os.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
|
+
exec,
|
|
2
3
|
execFileSync,
|
|
4
|
+
ExecOptions,
|
|
3
5
|
execSync,
|
|
4
6
|
ExecSyncOptionsWithBufferEncoding,
|
|
5
7
|
spawn,
|
|
6
8
|
SpawnOptionsWithoutStdio,
|
|
7
9
|
} from 'child_process';
|
|
10
|
+
import { promisify } from 'util';
|
|
8
11
|
import { log } from '../../utils';
|
|
9
12
|
|
|
10
13
|
type RunCommandOptions = SpawnOptionsWithoutStdio & { quiet?: boolean };
|
|
@@ -81,6 +84,33 @@ export function runCommandOutput(
|
|
|
81
84
|
return result;
|
|
82
85
|
}
|
|
83
86
|
|
|
87
|
+
type RunCommandOutputAsyncOptions = ExecOptions & { quiet?: boolean; ignoreErrors?: boolean };
|
|
88
|
+
|
|
89
|
+
export async function runCommandOutputAsync(
|
|
90
|
+
command: string,
|
|
91
|
+
{ quiet, ignoreErrors, ...execOptions }: RunCommandOutputAsyncOptions = {}
|
|
92
|
+
): Promise<string | undefined> {
|
|
93
|
+
if (!quiet) {
|
|
94
|
+
log.info(`Running: ${command}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const { stdout } = await promisify(exec)(command, execOptions);
|
|
99
|
+
const result = stdout.toString().trim();
|
|
100
|
+
|
|
101
|
+
if (!quiet) {
|
|
102
|
+
log.info('command finished', result);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return result || undefined;
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (ignoreErrors) {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
84
114
|
export function killProcessTree(pid: number, signal: NodeJS.Signals = 'SIGKILL') {
|
|
85
115
|
try {
|
|
86
116
|
if (process.platform === 'win32') {
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*/
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import { gitGetContext } from '../../cli/utils/cli-git';
|
|
6
|
+
import { getStartupVersion } from '../../core';
|
|
7
|
+
import { Telemetry } from '../telemetry';
|
|
8
|
+
|
|
9
|
+
jest.mock('../../cli/utils/cli-git', () => ({ gitGetContext: jest.fn() }));
|
|
10
|
+
jest.mock('../../core', () => ({ getStartupVersion: jest.fn() }));
|
|
11
|
+
jest.mock('os', () => ({ ...jest.requireActual('os'), hostname: jest.fn() }));
|
|
12
|
+
|
|
13
|
+
jest.mock('@servicetitan/secrets', () => ({}), { virtual: true });
|
|
14
|
+
|
|
15
|
+
describe(`[startup] ${Telemetry.name}`, () => {
|
|
16
|
+
const OLD_ENV = process.env;
|
|
17
|
+
const token = 'pub-test-token';
|
|
18
|
+
const gitContext = {
|
|
19
|
+
repo: 'monolith',
|
|
20
|
+
branch: 'main',
|
|
21
|
+
userEmail: 'dev@st.com',
|
|
22
|
+
userName: 'dev',
|
|
23
|
+
};
|
|
24
|
+
const hostname = 'test-host';
|
|
25
|
+
const startupVersion = '38.0.0';
|
|
26
|
+
const intakeUrl = (clientToken: string) =>
|
|
27
|
+
`https://browser-intake-datadoghq.com/api/v2/logs?dd-api-key=${clientToken}&ddsource=frontend-platform`;
|
|
28
|
+
const secrets = jest.requireMock('@servicetitan/secrets') as {
|
|
29
|
+
datadogStartupClientToken?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
let fetchMock: jest.Mock;
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
jest.clearAllMocks();
|
|
36
|
+
process.env = {};
|
|
37
|
+
secrets.datadogStartupClientToken = token;
|
|
38
|
+
jest.mocked(gitGetContext).mockResolvedValue(gitContext);
|
|
39
|
+
jest.mocked(getStartupVersion).mockReturnValue(startupVersion);
|
|
40
|
+
jest.mocked(os.hostname).mockReturnValue(hostname);
|
|
41
|
+
fetchMock = jest.fn(() => Promise.resolve({ ok: true } as Response));
|
|
42
|
+
global.fetch = fetchMock as unknown as typeof fetch;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterAll(() => {
|
|
46
|
+
process.env = OLD_ENV;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const sentBody = () => JSON.parse(fetchMock.mock.calls[0][1].body)[0];
|
|
50
|
+
|
|
51
|
+
const emit = async (act: (telemetry: Telemetry) => void): Promise<void> => {
|
|
52
|
+
const telemetry = new Telemetry();
|
|
53
|
+
act(telemetry);
|
|
54
|
+
await telemetry.flush();
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
describe('construction', () => {
|
|
58
|
+
const subject = () => new Telemetry();
|
|
59
|
+
|
|
60
|
+
test('does not spawn git', () => {
|
|
61
|
+
subject();
|
|
62
|
+
|
|
63
|
+
expect(gitGetContext).not.toHaveBeenCalled();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('info', () => {
|
|
68
|
+
let attributes: Record<string, string>;
|
|
69
|
+
|
|
70
|
+
beforeEach(() => (attributes = {}));
|
|
71
|
+
|
|
72
|
+
const subject = () => emit(telemetry => telemetry.info('command.started', attributes));
|
|
73
|
+
|
|
74
|
+
test('posts to the browser logs intake with the client token', async () => {
|
|
75
|
+
await subject();
|
|
76
|
+
|
|
77
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
78
|
+
intakeUrl(token),
|
|
79
|
+
expect.objectContaining({
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: { 'Content-Type': 'application/json' },
|
|
82
|
+
})
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('includes the base context', async () => {
|
|
87
|
+
await subject();
|
|
88
|
+
|
|
89
|
+
expect(sentBody()).toEqual(
|
|
90
|
+
expect.objectContaining({
|
|
91
|
+
service: 'startup',
|
|
92
|
+
repo: gitContext.repo,
|
|
93
|
+
branch: gitContext.branch,
|
|
94
|
+
startupVersion,
|
|
95
|
+
hostname,
|
|
96
|
+
os: process.platform,
|
|
97
|
+
node: process.versions.node,
|
|
98
|
+
message: 'command.started',
|
|
99
|
+
status: 'info',
|
|
100
|
+
date: expect.any(Number),
|
|
101
|
+
agent: false,
|
|
102
|
+
usr: { email: gitContext.userEmail, name: gitContext.userName },
|
|
103
|
+
})
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('with event attributes', () => {
|
|
108
|
+
beforeEach(() => {
|
|
109
|
+
attributes.command = 'build';
|
|
110
|
+
attributes.bundler = 'vite';
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('merges them into the payload', async () => {
|
|
114
|
+
await subject();
|
|
115
|
+
|
|
116
|
+
expect(sentBody()).toEqual(expect.objectContaining(attributes));
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('when AI_AGENT is set', () => {
|
|
121
|
+
beforeEach(() => (process.env.AI_AGENT = 'claude-code'));
|
|
122
|
+
|
|
123
|
+
test('sets the agent flag', async () => {
|
|
124
|
+
await subject();
|
|
125
|
+
|
|
126
|
+
expect(sentBody()).toEqual(expect.objectContaining({ agent: true }));
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('when CURSOR_AGENT is set', () => {
|
|
131
|
+
beforeEach(() => (process.env.CURSOR_AGENT = '1'));
|
|
132
|
+
|
|
133
|
+
test('sets the agent flag', async () => {
|
|
134
|
+
await subject();
|
|
135
|
+
|
|
136
|
+
expect(sentBody()).toEqual(expect.objectContaining({ agent: true }));
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('without a client token', () => {
|
|
141
|
+
beforeEach(() => (secrets.datadogStartupClientToken = undefined));
|
|
142
|
+
|
|
143
|
+
test('does not send anything', async () => {
|
|
144
|
+
await subject();
|
|
145
|
+
|
|
146
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('does not collect git context', async () => {
|
|
150
|
+
await subject();
|
|
151
|
+
|
|
152
|
+
expect(gitGetContext).not.toHaveBeenCalled();
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('with DATADOG_CLIENT_TOKEN set', () => {
|
|
157
|
+
beforeEach(() => (process.env.DATADOG_CLIENT_TOKEN = 'env-token'));
|
|
158
|
+
|
|
159
|
+
test('overrides the secrets token', async () => {
|
|
160
|
+
await subject();
|
|
161
|
+
|
|
162
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
163
|
+
intakeUrl('env-token'),
|
|
164
|
+
expect.objectContaining({ method: 'POST' })
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe('when fetch throws', () => {
|
|
170
|
+
beforeEach(() => {
|
|
171
|
+
global.fetch = (() => {
|
|
172
|
+
throw new Error('boom');
|
|
173
|
+
}) as unknown as typeof fetch;
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('does not reject', async () => {
|
|
177
|
+
await expect(subject()).resolves.toBeUndefined();
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe('when base context gathering fails', () => {
|
|
182
|
+
beforeEach(() => {
|
|
183
|
+
jest.mocked(gitGetContext).mockRejectedValue(new Error('git blew up'));
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('still sends the event', async () => {
|
|
187
|
+
await subject();
|
|
188
|
+
|
|
189
|
+
expect(sentBody()).toEqual(expect.objectContaining({ message: 'command.started' }));
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe('warn', () => {
|
|
195
|
+
const subject = () => emit(telemetry => telemetry.warn('node.version.unsupported'));
|
|
196
|
+
|
|
197
|
+
test('sends with warn status', async () => {
|
|
198
|
+
await subject();
|
|
199
|
+
|
|
200
|
+
expect(sentBody()).toEqual(
|
|
201
|
+
expect.objectContaining({ message: 'node.version.unsupported', status: 'warn' })
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe('error', () => {
|
|
207
|
+
const message = 'compile blew up';
|
|
208
|
+
|
|
209
|
+
let error: unknown;
|
|
210
|
+
|
|
211
|
+
beforeEach(() => (error = new TypeError(message)));
|
|
212
|
+
|
|
213
|
+
const subject = () => emit(telemetry => telemetry.error('command.finished', error));
|
|
214
|
+
|
|
215
|
+
test('sends with error status, message, and kind', async () => {
|
|
216
|
+
await subject();
|
|
217
|
+
|
|
218
|
+
expect(sentBody()).toEqual(
|
|
219
|
+
expect.objectContaining({
|
|
220
|
+
message: 'command.finished',
|
|
221
|
+
status: 'error',
|
|
222
|
+
error: {
|
|
223
|
+
message,
|
|
224
|
+
kind: TypeError.name,
|
|
225
|
+
stack: expect.any(String),
|
|
226
|
+
},
|
|
227
|
+
})
|
|
228
|
+
);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe('with a string', () => {
|
|
232
|
+
const stringError = 'boom';
|
|
233
|
+
|
|
234
|
+
beforeEach(() => (error = stringError));
|
|
235
|
+
|
|
236
|
+
test('uses it as the message', async () => {
|
|
237
|
+
await subject();
|
|
238
|
+
|
|
239
|
+
expect(sentBody()).toEqual(
|
|
240
|
+
expect.objectContaining({ error: { message: stringError } })
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe('with a non-Error object', () => {
|
|
246
|
+
const objectError = { code: 42 };
|
|
247
|
+
|
|
248
|
+
beforeEach(() => (error = objectError));
|
|
249
|
+
|
|
250
|
+
test('serializes it', async () => {
|
|
251
|
+
await subject();
|
|
252
|
+
|
|
253
|
+
expect(sentBody()).toEqual(
|
|
254
|
+
expect.objectContaining({ error: { message: JSON.stringify(objectError) } })
|
|
255
|
+
);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe('with a circular value', () => {
|
|
260
|
+
beforeEach(() => {
|
|
261
|
+
const circular: Record<string, unknown> = {};
|
|
262
|
+
circular.self = circular;
|
|
263
|
+
error = circular;
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('falls back to a safe message', async () => {
|
|
267
|
+
await subject();
|
|
268
|
+
|
|
269
|
+
expect(sentBody()).toEqual(
|
|
270
|
+
expect.objectContaining({ error: { message: 'unserializable error value' } })
|
|
271
|
+
);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
describe('flush', () => {
|
|
277
|
+
const subject = () => {
|
|
278
|
+
const telemetry = new Telemetry();
|
|
279
|
+
telemetry.info('command.started');
|
|
280
|
+
telemetry.info('command.finished');
|
|
281
|
+
return telemetry;
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
describe('with in-flight sends', () => {
|
|
285
|
+
let resolvers: (() => void)[];
|
|
286
|
+
|
|
287
|
+
beforeEach(() => {
|
|
288
|
+
resolvers = [];
|
|
289
|
+
fetchMock.mockImplementation(
|
|
290
|
+
() =>
|
|
291
|
+
new Promise<Response>(resolve => {
|
|
292
|
+
resolvers.push(() => resolve({ ok: true } as Response));
|
|
293
|
+
})
|
|
294
|
+
);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test('waits for all of them, not just the last', async () => {
|
|
298
|
+
let done = false;
|
|
299
|
+
const flushing = subject()
|
|
300
|
+
.flush()
|
|
301
|
+
.then(() => (done = true));
|
|
302
|
+
|
|
303
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
304
|
+
expect(resolvers).toHaveLength(2);
|
|
305
|
+
|
|
306
|
+
resolvers[1]();
|
|
307
|
+
await Promise.resolve();
|
|
308
|
+
expect(done).toBe(false);
|
|
309
|
+
|
|
310
|
+
resolvers[0]();
|
|
311
|
+
await flushing;
|
|
312
|
+
expect(done).toBe(true);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
describe('when a send rejects', () => {
|
|
317
|
+
beforeEach(() => {
|
|
318
|
+
fetchMock.mockRejectedValue(new Error('network down'));
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test('resolves without rejecting', async () => {
|
|
322
|
+
await expect(subject().flush()).resolves.toBeUndefined();
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { isCI } from '@servicetitan/install';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import { gitGetContext } from '../cli/utils/cli-git';
|
|
4
|
+
import { getStartupVersion } from '../core';
|
|
5
|
+
|
|
6
|
+
const DDSOURCE = 'frontend-platform';
|
|
7
|
+
const SERVICE = 'startup';
|
|
8
|
+
const SITE = 'datadoghq.com';
|
|
9
|
+
const REQUEST_TIMEOUT_MS = 2000;
|
|
10
|
+
|
|
11
|
+
type Status = 'info' | 'warn' | 'error';
|
|
12
|
+
export type AttributeValue = string | number | boolean | undefined;
|
|
13
|
+
export type Attributes = Record<string, AttributeValue | Record<string, AttributeValue>>;
|
|
14
|
+
|
|
15
|
+
interface Context {
|
|
16
|
+
clientToken: string;
|
|
17
|
+
baseAttributes: Attributes;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function toError(error: unknown): { message: string; kind?: string; stack?: string } {
|
|
21
|
+
if (error instanceof Error) {
|
|
22
|
+
return { message: error.message, kind: error.name, stack: error.stack };
|
|
23
|
+
}
|
|
24
|
+
if (typeof error === 'string') {
|
|
25
|
+
return { message: error };
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
return { message: JSON.stringify(error) };
|
|
29
|
+
} catch {
|
|
30
|
+
return { message: 'unserializable error value' };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function getClientToken(): string | undefined {
|
|
35
|
+
if (process.env.DATADOG_CLIENT_TOKEN) {
|
|
36
|
+
return process.env.DATADOG_CLIENT_TOKEN;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const secrets = require('@servicetitan/secrets') as {
|
|
40
|
+
datadogStartupClientToken?: string;
|
|
41
|
+
};
|
|
42
|
+
return secrets.datadogStartupClientToken;
|
|
43
|
+
} catch {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function getBaseContext(): Promise<Attributes> {
|
|
49
|
+
const git = await gitGetContext();
|
|
50
|
+
return {
|
|
51
|
+
service: SERVICE,
|
|
52
|
+
repo: git.repo,
|
|
53
|
+
branch: git.branch,
|
|
54
|
+
startupVersion: getStartupVersion(),
|
|
55
|
+
ci: isCI(),
|
|
56
|
+
agent: Boolean(process.env.AI_AGENT ?? process.env.CURSOR_AGENT),
|
|
57
|
+
hostname: os.hostname(),
|
|
58
|
+
os: process.platform,
|
|
59
|
+
node: process.versions.node,
|
|
60
|
+
usr: { email: git.userEmail, name: git.userName },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function resolveContext(): Promise<Context | undefined> {
|
|
65
|
+
const clientToken = getClientToken();
|
|
66
|
+
if (!clientToken) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
const baseAttributes = await getBaseContext().catch(() => ({}));
|
|
70
|
+
return { clientToken, baseAttributes };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class Telemetry {
|
|
74
|
+
private initPromise?: Promise<Context | undefined>;
|
|
75
|
+
private readonly pendingRequests = new Set<Promise<unknown>>();
|
|
76
|
+
|
|
77
|
+
info(name: string, attributes: Attributes = {}): void {
|
|
78
|
+
this.event(name, 'info', attributes);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
warn(name: string, attributes: Attributes = {}): void {
|
|
82
|
+
this.event(name, 'warn', attributes);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
error(name: string, error: unknown, attributes: Attributes = {}): void {
|
|
86
|
+
this.event(name, 'error', { ...attributes, error: toError(error) });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async flush(): Promise<void> {
|
|
90
|
+
/*
|
|
91
|
+
* Promise.all, not allSettled: pending requests never reject, and swallowing
|
|
92
|
+
* here would leave failures asymmetric on whether flush ran (a race).
|
|
93
|
+
*/
|
|
94
|
+
await Promise.all([...this.pendingRequests]);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private init(): Promise<Context | undefined> {
|
|
98
|
+
this.initPromise ??= resolveContext();
|
|
99
|
+
return this.initPromise;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private event(name: string, status: Status, attributes: Attributes): void {
|
|
103
|
+
try {
|
|
104
|
+
this.send({ message: name, status, date: Date.now(), ...attributes });
|
|
105
|
+
} catch {} // eslint-disable-line no-empty
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private send(event: Attributes): void {
|
|
109
|
+
const request = this.init()
|
|
110
|
+
.then(context => {
|
|
111
|
+
if (!context) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const { clientToken, baseAttributes } = context;
|
|
115
|
+
const controller = new AbortController();
|
|
116
|
+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
117
|
+
timer.unref();
|
|
118
|
+
const url =
|
|
119
|
+
`https://browser-intake-${SITE}/api/v2/logs` +
|
|
120
|
+
`?dd-api-key=${encodeURIComponent(clientToken)}&ddsource=${DDSOURCE}`;
|
|
121
|
+
return fetch(url, {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
headers: { 'Content-Type': 'application/json' },
|
|
124
|
+
body: JSON.stringify([{ ...baseAttributes, ...event }]),
|
|
125
|
+
signal: controller.signal,
|
|
126
|
+
}).finally(() => clearTimeout(timer));
|
|
127
|
+
})
|
|
128
|
+
.catch(() => {})
|
|
129
|
+
.finally(() => this.pendingRequests.delete(request));
|
|
130
|
+
this.pendingRequests.add(request);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export const telemetry = new Telemetry();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createLogger, Logger } from 'vite';
|
|
2
|
+
import { EMPTY_PREFIX } from '../../plugins/ignore-modules-plugin';
|
|
2
3
|
import { createFilteringLogger } from '../create-filtering-logger';
|
|
3
4
|
|
|
4
5
|
jest.mock('vite', () => ({ createLogger: jest.fn() }));
|
|
@@ -50,8 +51,8 @@ describe(createFilteringLogger.name, () => {
|
|
|
50
51
|
|
|
51
52
|
describe('when message is an ignored-module import warning', () => {
|
|
52
53
|
beforeEach(() => {
|
|
53
|
-
|
|
54
|
-
|
|
54
|
+
const renderedPrefix = EMPTY_PREFIX.replace('\0', '\\0');
|
|
55
|
+
message = `[IMPORT_IS_UNDEFINED] Import \`DefaultPortalContext\` will always be undefined because there is no matching export in '${renderedPrefix}@servicetitan/design-system'`;
|
|
55
56
|
});
|
|
56
57
|
|
|
57
58
|
itSuppressesMessage();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createLogger, type Logger } from 'vite';
|
|
2
|
+
import { EMPTY_PREFIX } from '../plugins/ignore-modules-plugin';
|
|
2
3
|
|
|
3
4
|
/*
|
|
4
5
|
* Expected warnings when a host's HTML references the shared-bundle assets created by
|
|
@@ -13,10 +14,15 @@ const SHARED_ASSET_WARNINGS = [
|
|
|
13
14
|
/*
|
|
14
15
|
* Expected when code feature-detects an optional peer dependency (e.g. `Package.Export
|
|
15
16
|
* === undefined`) and ignoreModulesPlugin has substituted an empty module for it. Matched
|
|
16
|
-
* on the plugin's
|
|
17
|
+
* on the plugin's EMPTY_PREFIX marker so a genuinely undefined export elsewhere still warns.
|
|
18
|
+
* Rolldown renders the marker's leading NUL control character as the visible characters "\"
|
|
19
|
+
* and "0" when it prints a warning, so match that instead of the raw NUL, doubled here
|
|
20
|
+
* because the backslash is also a regex metacharacter.
|
|
17
21
|
*/
|
|
18
22
|
const IGNORED_MODULE_WARNINGS = [
|
|
19
|
-
|
|
23
|
+
new RegExp(
|
|
24
|
+
`Import \`\\w+\` will always be undefined because there is no matching export in '${EMPTY_PREFIX.replace('\0', '\\\\0')}[^']+'`
|
|
25
|
+
),
|
|
20
26
|
];
|
|
21
27
|
|
|
22
28
|
const SUPPRESSED_WARNINGS = [...SHARED_ASSET_WARNINGS, ...IGNORED_MODULE_WARNINGS];
|
|
@@ -11,7 +11,7 @@ import { WebpackAssetsManifest } from 'webpack-assets-manifest';
|
|
|
11
11
|
import RemoveEmptyScriptsPlugin from 'webpack-remove-empty-scripts';
|
|
12
12
|
import VirtualModulesPlugin from 'webpack-virtual-modules';
|
|
13
13
|
import yargs from 'yargs';
|
|
14
|
-
import { getLaunchDarklySdkVersion } from '../../core';
|
|
14
|
+
import { CHUNK_PATTERNS, getLaunchDarklySdkVersion } from '../../core';
|
|
15
15
|
import {
|
|
16
16
|
getFolders,
|
|
17
17
|
getPackageData,
|
|
@@ -350,6 +350,32 @@ describe(`[startup] ${createWebpackConfig.name}`, () => {
|
|
|
350
350
|
cacheGroups.servicetitan.priority ?? 0
|
|
351
351
|
);
|
|
352
352
|
});
|
|
353
|
+
|
|
354
|
+
describe.each(Object.keys(CHUNK_PATTERNS))(
|
|
355
|
+
'optimization.splitChunks.cacheGroups.%s',
|
|
356
|
+
name => {
|
|
357
|
+
const cacheGroup = () =>
|
|
358
|
+
(subject().optimization!.splitChunks as any).cacheGroups[name];
|
|
359
|
+
|
|
360
|
+
test('excludes the design-system chunk', () => {
|
|
361
|
+
expect(cacheGroup().chunks({ name: 'design-system' })).toBe(false);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test('claims other chunks', () => {
|
|
365
|
+
expect(cacheGroup().chunks({ name: 'foo' })).toBe(true);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
describe('when application does not share design system', () => {
|
|
369
|
+
beforeEach(() => {
|
|
370
|
+
jest.mocked(loadSharedDependencies).mockReturnValue({});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test('claims all chunks', () => {
|
|
374
|
+
expect(cacheGroup().chunks).toBe('all');
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
);
|
|
353
379
|
});
|
|
354
380
|
|
|
355
381
|
test('configures "output.chunkLoadingGlobal"', () => {
|
|
@@ -32,13 +32,16 @@ export function optimizationConfig(context: WebpackBuildContext, _: Overrides):
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
function hostConfig(optimization: ConfigWithCacheGroups, context: WebpackBuildContext) {
|
|
35
|
-
const { isProduction } = context.build;
|
|
36
|
-
const { isWebComponent } = context.package;
|
|
35
|
+
const { isProduction, emitExposedDependencies } = context.build;
|
|
36
|
+
const { isWebComponent, sharedDependencies } = context.package;
|
|
37
37
|
if (isWebComponent || !isProduction) {
|
|
38
38
|
return;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
const excludeDesignSystem =
|
|
42
|
+
emitExposedDependencies && !!sharedDependencies['@servicetitan/design-system'];
|
|
43
|
+
|
|
44
|
+
Object.assign(optimization.splitChunks.cacheGroups, vendorGroups(excludeDesignSystem));
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
function minimizeConfig(optimization: ConfigWithCacheGroups, context: WebpackBuildContext) {
|
|
@@ -115,27 +118,35 @@ function webComponentConfig(optimization: ConfigWithCacheGroups, context: Webpac
|
|
|
115
118
|
});
|
|
116
119
|
}
|
|
117
120
|
|
|
118
|
-
function vendorGroups() {
|
|
121
|
+
function vendorGroups(excludeDesignSystem = false): Record<string, any> {
|
|
122
|
+
/*
|
|
123
|
+
* The design-system entry already gets its own chunk; excluding it here keeps
|
|
124
|
+
* its CSS from being absorbed into a vendor chunk.
|
|
125
|
+
*/
|
|
126
|
+
const chunks: any = excludeDesignSystem
|
|
127
|
+
? (chunk: { name?: string }) => chunk.name !== 'design-system'
|
|
128
|
+
: 'all';
|
|
129
|
+
|
|
119
130
|
return {
|
|
120
131
|
'servicetitan': {
|
|
121
132
|
name: 'servicetitan',
|
|
122
133
|
test: CHUNK_PATTERNS.servicetitan,
|
|
123
|
-
chunks
|
|
134
|
+
chunks,
|
|
124
135
|
},
|
|
125
136
|
'vendor-core': {
|
|
126
137
|
name: 'vendor-core',
|
|
127
138
|
test: CHUNK_PATTERNS['vendor-core'],
|
|
128
|
-
chunks
|
|
139
|
+
chunks,
|
|
129
140
|
},
|
|
130
141
|
'kendo': {
|
|
131
142
|
name: 'kendo',
|
|
132
143
|
test: CHUNK_PATTERNS.kendo,
|
|
133
|
-
chunks
|
|
144
|
+
chunks,
|
|
134
145
|
},
|
|
135
146
|
'vendor': {
|
|
136
147
|
name: 'vendor',
|
|
137
148
|
test: CHUNK_PATTERNS.vendor,
|
|
138
|
-
chunks
|
|
149
|
+
chunks,
|
|
139
150
|
priority: -5,
|
|
140
151
|
},
|
|
141
152
|
};
|