@copilotkit/shared 1.69.3 → 1.70.1
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/index.cjs +31 -13
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +19 -17
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +19 -17
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +29 -15
- package/dist/index.mjs.map +1 -1
- package/dist/index.umd.js +162 -34
- package/dist/index.umd.js.map +1 -1
- package/dist/package.cjs +1 -1
- package/dist/package.mjs +1 -1
- package/dist/telemetry/index.d.mts +3 -2
- package/dist/telemetry/lambda-client.cjs +25 -4
- package/dist/telemetry/lambda-client.cjs.map +1 -1
- package/dist/telemetry/lambda-client.d.cts +14 -1
- package/dist/telemetry/lambda-client.d.cts.map +1 -1
- package/dist/telemetry/lambda-client.d.mts +14 -1
- package/dist/telemetry/lambda-client.d.mts.map +1 -1
- package/dist/telemetry/lambda-client.mjs +25 -5
- package/dist/telemetry/lambda-client.mjs.map +1 -1
- package/dist/telemetry/sampling.cjs +28 -0
- package/dist/telemetry/sampling.cjs.map +1 -0
- package/dist/telemetry/sampling.d.cts +37 -0
- package/dist/telemetry/sampling.d.cts.map +1 -0
- package/dist/telemetry/sampling.d.mts +37 -0
- package/dist/telemetry/sampling.d.mts.map +1 -0
- package/dist/telemetry/sampling.mjs +25 -0
- package/dist/telemetry/sampling.mjs.map +1 -0
- package/dist/telemetry/telemetry-client.cjs +72 -11
- package/dist/telemetry/telemetry-client.cjs.map +1 -1
- package/dist/telemetry/telemetry-client.d.cts +43 -1
- package/dist/telemetry/telemetry-client.d.cts.map +1 -1
- package/dist/telemetry/telemetry-client.d.mts +43 -1
- package/dist/telemetry/telemetry-client.d.mts.map +1 -1
- package/dist/telemetry/telemetry-client.mjs +73 -12
- package/dist/telemetry/telemetry-client.mjs.map +1 -1
- package/dist/utils/console-styling.cjs +3 -3
- package/dist/utils/console-styling.cjs.map +1 -1
- package/dist/utils/console-styling.mjs +3 -3
- package/dist/utils/console-styling.mjs.map +1 -1
- package/dist/utils/index.d.cts +1 -1
- package/dist/utils/index.d.mts +1 -1
- package/dist/utils/types.cjs.map +1 -1
- package/dist/utils/types.d.cts +46 -1
- package/dist/utils/types.d.cts.map +1 -1
- package/dist/utils/types.d.mts +46 -1
- package/dist/utils/types.d.mts.map +1 -1
- package/dist/utils/types.mjs.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/license-context.test.ts +224 -25
- package/src/index.ts +72 -16
- package/src/telemetry/index.ts +2 -0
- package/src/telemetry/lambda-client.test.ts +336 -1
- package/src/telemetry/lambda-client.ts +56 -15
- package/src/telemetry/sampling.test.ts +65 -0
- package/src/telemetry/sampling.ts +70 -0
- package/src/telemetry/telemetry-blank-license-identity.test.ts +121 -0
- package/src/telemetry/telemetry-client.test.ts +438 -15
- package/src/telemetry/telemetry-client.ts +145 -30
- package/src/utils/__tests__/conditions.test.ts +161 -0
- package/src/utils/console-styling.ts +3 -3
- package/src/utils/types.ts +52 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { expect, test, vi } from "vitest";
|
|
2
|
+
import { lambdaClient } from "./lambda-client";
|
|
3
|
+
import { TelemetryClient } from "./telemetry-client";
|
|
4
|
+
|
|
5
|
+
const { segmentTrackMock } = vi.hoisted(() => ({
|
|
6
|
+
segmentTrackMock: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
vi.mock("@segment/analytics-node", () => ({
|
|
10
|
+
Analytics: class {
|
|
11
|
+
track = segmentTrackMock;
|
|
12
|
+
},
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
const instanceCreatedEvent = {
|
|
16
|
+
actionsAmount: 0,
|
|
17
|
+
endpointTypes: [],
|
|
18
|
+
endpointsAmount: 0,
|
|
19
|
+
"cloud.api_key_provided": false,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Build the signature-agnostic legacy token shape used at this boundary. */
|
|
23
|
+
function jwtWithTelemetryId(telemetryId: string): string {
|
|
24
|
+
const payload = Buffer.from(
|
|
25
|
+
JSON.stringify({ telemetry_id: telemetryId }),
|
|
26
|
+
).toString("base64url");
|
|
27
|
+
return `header.${payload}.sig`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Creates one isolated Shared telemetry capture with a fixed sampling decision. */
|
|
31
|
+
function setupSharedCapture(randomValue: number, telemetryId: string = " \t ") {
|
|
32
|
+
const priorSampleRate = process.env.COPILOTKIT_TELEMETRY_SAMPLE_RATE;
|
|
33
|
+
delete process.env.COPILOTKIT_TELEMETRY_SAMPLE_RATE;
|
|
34
|
+
segmentTrackMock.mockReset();
|
|
35
|
+
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(randomValue);
|
|
36
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
37
|
+
const client = new TelemetryClient({
|
|
38
|
+
packageName: "@copilotkit/shared",
|
|
39
|
+
packageVersion: "test",
|
|
40
|
+
telemetryDisabled: false,
|
|
41
|
+
sampleRate: 0.05,
|
|
42
|
+
});
|
|
43
|
+
client.setLicenseToken(jwtWithTelemetryId(telemetryId));
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
client,
|
|
47
|
+
randomSpy,
|
|
48
|
+
teardown: () => {
|
|
49
|
+
randomSpy.mockRestore();
|
|
50
|
+
warnSpy.mockRestore();
|
|
51
|
+
segmentTrackMock.mockReset();
|
|
52
|
+
if (priorSampleRate === undefined) {
|
|
53
|
+
delete process.env.COPILOTKIT_TELEMETRY_SAMPLE_RATE;
|
|
54
|
+
} else {
|
|
55
|
+
process.env.COPILOTKIT_TELEMETRY_SAMPLE_RATE = priorSampleRate;
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
test("Shared whitespace-only legacy claim stays subject to sampling", async () => {
|
|
62
|
+
const sinkSpy = vi.spyOn(lambdaClient, "send").mockResolvedValue(undefined);
|
|
63
|
+
const { client, randomSpy, teardown } = setupSharedCapture(0.99);
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
await client.capture("oss.runtime.instance_created", instanceCreatedEvent);
|
|
67
|
+
|
|
68
|
+
expect(randomSpy).toHaveBeenCalledTimes(1);
|
|
69
|
+
expect(sinkSpy).not.toHaveBeenCalled();
|
|
70
|
+
expect(segmentTrackMock).not.toHaveBeenCalled();
|
|
71
|
+
} finally {
|
|
72
|
+
sinkSpy.mockRestore();
|
|
73
|
+
teardown();
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("Shared sampled whitespace-only legacy claim sends no identity header", async () => {
|
|
78
|
+
const fetchSpy = vi
|
|
79
|
+
.spyOn(globalThis, "fetch")
|
|
80
|
+
.mockResolvedValue(new Response("", { status: 202 }));
|
|
81
|
+
const { client, randomSpy, teardown } = setupSharedCapture(0);
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
await client.capture("oss.runtime.instance_created", instanceCreatedEvent);
|
|
85
|
+
|
|
86
|
+
expect(randomSpy).toHaveBeenCalledTimes(1);
|
|
87
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
88
|
+
const request = fetchSpy.mock.calls[0]?.[1];
|
|
89
|
+
expect(
|
|
90
|
+
new Headers(request?.headers).get("X-CopilotKit-Telemetry-Id"),
|
|
91
|
+
).toBeNull();
|
|
92
|
+
} finally {
|
|
93
|
+
fetchSpy.mockRestore();
|
|
94
|
+
teardown();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test.each(["bad\nid", "bad\u0000id", "tenant-🚀"])(
|
|
99
|
+
"Shared header-invalid legacy claim %j stays subject to sampling",
|
|
100
|
+
async (invalidTelemetryId) => {
|
|
101
|
+
const sinkSpy = vi.spyOn(lambdaClient, "send").mockResolvedValue(undefined);
|
|
102
|
+
const { client, randomSpy, teardown } = setupSharedCapture(
|
|
103
|
+
0.99,
|
|
104
|
+
invalidTelemetryId,
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
await client.capture(
|
|
109
|
+
"oss.runtime.instance_created",
|
|
110
|
+
instanceCreatedEvent,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
expect(randomSpy).toHaveBeenCalledTimes(1);
|
|
114
|
+
expect(sinkSpy).not.toHaveBeenCalled();
|
|
115
|
+
expect(segmentTrackMock).not.toHaveBeenCalled();
|
|
116
|
+
} finally {
|
|
117
|
+
sinkSpy.mockRestore();
|
|
118
|
+
teardown();
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
);
|
|
@@ -16,6 +16,76 @@ vi.mock("@segment/analytics-node", () => ({
|
|
|
16
16
|
},
|
|
17
17
|
}));
|
|
18
18
|
|
|
19
|
+
function jwtWith(payload: {
|
|
20
|
+
telemetry_id?: string;
|
|
21
|
+
license_id?: string;
|
|
22
|
+
}): string {
|
|
23
|
+
const b64 = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
24
|
+
return `header.${b64}.sig`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Builds a token whose payload contains an illegal base64url character. */
|
|
28
|
+
function malformedJwtWithTelemetryId(telemetryId: string): string {
|
|
29
|
+
const payload = Buffer.from(JSON.stringify({ telemetry_id: telemetryId }))
|
|
30
|
+
.toString("base64url")
|
|
31
|
+
.concat("$");
|
|
32
|
+
return `header.${payload}.sig`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type TelemetryIdentityMode =
|
|
36
|
+
| { label: "standalone"; telemetryId: string }
|
|
37
|
+
| { label: "legacy license"; licenseToken: string }
|
|
38
|
+
| { label: "anonymous" };
|
|
39
|
+
|
|
40
|
+
/** Applies the identity path under test without conflating legacy and standalone APIs. */
|
|
41
|
+
function applyTelemetryIdentity(
|
|
42
|
+
client: TelemetryClient,
|
|
43
|
+
identity: TelemetryIdentityMode,
|
|
44
|
+
) {
|
|
45
|
+
if ("telemetryId" in identity) {
|
|
46
|
+
client.setTelemetryIdentity({ telemetryId: identity.telemetryId });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if ("licenseToken" in identity) {
|
|
51
|
+
client.setLicenseToken(identity.licenseToken);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const telemetryIdentityModes = [
|
|
56
|
+
{
|
|
57
|
+
label: "standalone",
|
|
58
|
+
telemetryId: "standalone-telemetry-id",
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
label: "legacy license",
|
|
62
|
+
licenseToken: jwtWith({ telemetry_id: "legacy-telemetry-id" }),
|
|
63
|
+
},
|
|
64
|
+
{ label: "anonymous" },
|
|
65
|
+
] satisfies readonly TelemetryIdentityMode[];
|
|
66
|
+
|
|
67
|
+
const telemetryOptOuts = [
|
|
68
|
+
{ environmentVariable: "COPILOTKIT_TELEMETRY_DISABLED", value: "true" },
|
|
69
|
+
{ environmentVariable: "DO_NOT_TRACK", value: "1" },
|
|
70
|
+
] as const;
|
|
71
|
+
|
|
72
|
+
const telemetryOptOutCases = telemetryOptOuts.flatMap((optOut) =>
|
|
73
|
+
telemetryIdentityModes.map((identity) => ({ identity, optOut })),
|
|
74
|
+
);
|
|
75
|
+
const callerSampleRate = process.env.COPILOTKIT_TELEMETRY_SAMPLE_RATE;
|
|
76
|
+
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
delete process.env.COPILOTKIT_TELEMETRY_SAMPLE_RATE;
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
if (callerSampleRate === undefined) {
|
|
83
|
+
delete process.env.COPILOTKIT_TELEMETRY_SAMPLE_RATE;
|
|
84
|
+
} else {
|
|
85
|
+
process.env.COPILOTKIT_TELEMETRY_SAMPLE_RATE = callerSampleRate;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
19
89
|
describe("v1 TelemetryClient", () => {
|
|
20
90
|
let lambdaSpy: MockInstance<typeof lambdaClient.send>;
|
|
21
91
|
|
|
@@ -26,6 +96,7 @@ describe("v1 TelemetryClient", () => {
|
|
|
26
96
|
|
|
27
97
|
afterEach(() => {
|
|
28
98
|
vi.restoreAllMocks();
|
|
99
|
+
vi.unstubAllEnvs();
|
|
29
100
|
});
|
|
30
101
|
|
|
31
102
|
function makeClient(
|
|
@@ -39,17 +110,83 @@ describe("v1 TelemetryClient", () => {
|
|
|
39
110
|
});
|
|
40
111
|
}
|
|
41
112
|
|
|
42
|
-
function jwtWith(payload: Record<string, unknown>): string {
|
|
43
|
-
const b64 = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
44
|
-
return `header.${b64}.sig`;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
113
|
const baseInstanceEvent = {
|
|
48
114
|
actionsAmount: 0,
|
|
49
115
|
endpointsAmount: 0,
|
|
50
116
|
endpointTypes: [],
|
|
51
117
|
"cloud.api_key_provided": false,
|
|
52
118
|
} as const;
|
|
119
|
+
const flattenedBaseInstanceEvent = {
|
|
120
|
+
actionsAmount: 0,
|
|
121
|
+
endpointsAmount: 0,
|
|
122
|
+
"cloud.api_key_provided": false,
|
|
123
|
+
} as const;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Verifies the shared event shape and sampling metadata sent to both sinks.
|
|
127
|
+
*
|
|
128
|
+
* @param expectedIdentity - Identity fields expected only on the Lambda send.
|
|
129
|
+
* @param samplingMeta - Effective sampling metadata for this capture.
|
|
130
|
+
* @param telemetryIdentified - Whether a license-derived identity bypassed sampling.
|
|
131
|
+
* @param identityValues - Raw identity values that must not leak into payloads.
|
|
132
|
+
*/
|
|
133
|
+
function expectBothSinksReceivedEvent(
|
|
134
|
+
expectedIdentity: Pick<
|
|
135
|
+
Parameters<typeof lambdaClient.send>[0],
|
|
136
|
+
"licenseToken" | "telemetryId"
|
|
137
|
+
>,
|
|
138
|
+
samplingMeta: {
|
|
139
|
+
sampleRate: number;
|
|
140
|
+
sampleRateAdjustmentFactor: number;
|
|
141
|
+
sampleWeight: number;
|
|
142
|
+
},
|
|
143
|
+
telemetryIdentified: boolean,
|
|
144
|
+
identityValues: readonly string[],
|
|
145
|
+
): void {
|
|
146
|
+
expect(lambdaSpy).toHaveBeenCalledTimes(1);
|
|
147
|
+
const lambdaEvent = lambdaSpy.mock.calls[0][0];
|
|
148
|
+
expect(lambdaEvent).toMatchObject({
|
|
149
|
+
...expectedIdentity,
|
|
150
|
+
globalProperties: samplingMeta,
|
|
151
|
+
});
|
|
152
|
+
expect(lambdaEvent.properties).toEqual(flattenedBaseInstanceEvent);
|
|
153
|
+
expect(lambdaEvent.globalProperties).toEqual({
|
|
154
|
+
"copilotkit.package.name": "@copilotkit/shared",
|
|
155
|
+
"copilotkit.package.version": "1.0.0",
|
|
156
|
+
...samplingMeta,
|
|
157
|
+
telemetry_emitter: "v1-shared",
|
|
158
|
+
telemetry_event_id: expect.any(String),
|
|
159
|
+
telemetry_identified: telemetryIdentified,
|
|
160
|
+
telemetry_transport: "lambda",
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
expect(segmentTrackMock).toHaveBeenCalledTimes(1);
|
|
164
|
+
const segmentEvent = segmentTrackMock.mock.calls[0][0];
|
|
165
|
+
expect(segmentEvent).toEqual({
|
|
166
|
+
anonymousId: expect.stringMatching(/^anon_/),
|
|
167
|
+
event: "oss.runtime.instance_created",
|
|
168
|
+
properties: {
|
|
169
|
+
...flattenedBaseInstanceEvent,
|
|
170
|
+
"copilotkit.package.name": "@copilotkit/shared",
|
|
171
|
+
"copilotkit.package.version": "1.0.0",
|
|
172
|
+
...samplingMeta,
|
|
173
|
+
telemetry_emitter: "v1-shared",
|
|
174
|
+
telemetry_event_id: expect.any(String),
|
|
175
|
+
telemetry_identified: telemetryIdentified,
|
|
176
|
+
telemetry_transport: "segment",
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
expect(segmentEvent).not.toHaveProperty("userId");
|
|
180
|
+
|
|
181
|
+
const transportPayload = JSON.stringify({
|
|
182
|
+
lambdaGlobalProperties: lambdaEvent.globalProperties,
|
|
183
|
+
lambdaProperties: lambdaEvent.properties,
|
|
184
|
+
segmentEvent,
|
|
185
|
+
});
|
|
186
|
+
for (const identityValue of identityValues) {
|
|
187
|
+
expect(transportPayload).not.toContain(identityValue);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
53
190
|
|
|
54
191
|
test("capture sends to both sinks when sampled in (anonymous, one decision gates both)", async () => {
|
|
55
192
|
vi.spyOn(Math, "random").mockReturnValue(0);
|
|
@@ -76,6 +213,189 @@ describe("v1 TelemetryClient", () => {
|
|
|
76
213
|
expect(segmentTrackMock).not.toHaveBeenCalled();
|
|
77
214
|
});
|
|
78
215
|
|
|
216
|
+
test.each([
|
|
217
|
+
{ label: "configured directly", priorLicenseToken: undefined },
|
|
218
|
+
{
|
|
219
|
+
label: "replacing a legacy license identity",
|
|
220
|
+
priorLicenseToken: jwtWith({ telemetry_id: "legacy-telemetry-id" }),
|
|
221
|
+
},
|
|
222
|
+
])(
|
|
223
|
+
"standalone identity remains sample-gated when $label",
|
|
224
|
+
async ({ priorLicenseToken }) => {
|
|
225
|
+
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.99);
|
|
226
|
+
const client = makeClient({ sampleRate: 0.05 });
|
|
227
|
+
if (priorLicenseToken !== undefined) {
|
|
228
|
+
client.setLicenseToken(priorLicenseToken);
|
|
229
|
+
}
|
|
230
|
+
client.setTelemetryIdentity({
|
|
231
|
+
telemetryId: "standalone-telemetry-id",
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
await client.capture("oss.runtime.instance_created", baseInstanceEvent);
|
|
235
|
+
|
|
236
|
+
expect(randomSpy).toHaveBeenCalledTimes(1);
|
|
237
|
+
expect(lambdaSpy).not.toHaveBeenCalled();
|
|
238
|
+
expect(segmentTrackMock).not.toHaveBeenCalled();
|
|
239
|
+
},
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
test("sampled standalone identity remains a transport claim with anonymous sampling metadata", async () => {
|
|
243
|
+
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0);
|
|
244
|
+
const client = makeClient({ sampleRate: 0.05 });
|
|
245
|
+
client.setTelemetryIdentity({
|
|
246
|
+
telemetryId: "standalone-telemetry-id",
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
await client.capture("oss.runtime.instance_created", baseInstanceEvent);
|
|
250
|
+
|
|
251
|
+
expect(randomSpy).toHaveBeenCalledTimes(1);
|
|
252
|
+
expectBothSinksReceivedEvent(
|
|
253
|
+
{ telemetryId: "standalone-telemetry-id" },
|
|
254
|
+
{
|
|
255
|
+
sampleRate: 0.05,
|
|
256
|
+
sampleRateAdjustmentFactor: 0.95,
|
|
257
|
+
sampleWeight: 20,
|
|
258
|
+
},
|
|
259
|
+
false,
|
|
260
|
+
["standalone-telemetry-id"],
|
|
261
|
+
);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("standalone identity is stored without surrounding HTTP whitespace", async () => {
|
|
265
|
+
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0);
|
|
266
|
+
const client = makeClient({ sampleRate: 0.05 });
|
|
267
|
+
client.setTelemetryIdentity({
|
|
268
|
+
telemetryId: "\t standalone-telemetry-id \t",
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
await client.capture("oss.runtime.instance_created", baseInstanceEvent);
|
|
272
|
+
|
|
273
|
+
expect(randomSpy).toHaveBeenCalledTimes(1);
|
|
274
|
+
expect(lambdaSpy).toHaveBeenCalledWith(
|
|
275
|
+
expect.objectContaining({
|
|
276
|
+
telemetryId: "standalone-telemetry-id",
|
|
277
|
+
}),
|
|
278
|
+
);
|
|
279
|
+
expect(segmentTrackMock).toHaveBeenCalledTimes(1);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test.each(["bad\nid", "bad\u0000id", "tenant-🚀"])(
|
|
283
|
+
"header-invalid standalone identity %j stays anonymously sampled",
|
|
284
|
+
async (invalidTelemetryId) => {
|
|
285
|
+
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0);
|
|
286
|
+
const client = makeClient({ sampleRate: 0.05 });
|
|
287
|
+
client.setTelemetryIdentity({
|
|
288
|
+
telemetryId: invalidTelemetryId,
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
await client.capture("oss.runtime.instance_created", baseInstanceEvent);
|
|
292
|
+
|
|
293
|
+
expect(randomSpy).toHaveBeenCalledTimes(1);
|
|
294
|
+
expect(lambdaSpy).toHaveBeenCalledWith(
|
|
295
|
+
expect.objectContaining({
|
|
296
|
+
licenseToken: undefined,
|
|
297
|
+
telemetryId: undefined,
|
|
298
|
+
}),
|
|
299
|
+
);
|
|
300
|
+
expect(segmentTrackMock).toHaveBeenCalledTimes(1);
|
|
301
|
+
},
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
test("legacy license identity bypasses sampling for both sinks with the effective rate", async () => {
|
|
305
|
+
const telemetryId = "legacy-telemetry-id";
|
|
306
|
+
const licenseToken = jwtWith({ telemetry_id: telemetryId });
|
|
307
|
+
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.99);
|
|
308
|
+
const client = makeClient({ sampleRate: 0.05 });
|
|
309
|
+
client.setTelemetryIdentity({ licenseToken });
|
|
310
|
+
|
|
311
|
+
await client.capture("oss.runtime.instance_created", baseInstanceEvent);
|
|
312
|
+
|
|
313
|
+
expect(randomSpy).not.toHaveBeenCalled();
|
|
314
|
+
expectBothSinksReceivedEvent(
|
|
315
|
+
{ licenseToken },
|
|
316
|
+
{
|
|
317
|
+
sampleRate: 1,
|
|
318
|
+
sampleRateAdjustmentFactor: 0,
|
|
319
|
+
sampleWeight: 1,
|
|
320
|
+
},
|
|
321
|
+
true,
|
|
322
|
+
[telemetryId, licenseToken],
|
|
323
|
+
);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("clearing telemetry identity restores one anonymous sampling decision for both sinks", async () => {
|
|
327
|
+
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0);
|
|
328
|
+
const client = makeClient({ sampleRate: 0.05 });
|
|
329
|
+
client.setTelemetryIdentity({ telemetryId: "standalone-telemetry-id" });
|
|
330
|
+
client.setTelemetryIdentity({});
|
|
331
|
+
|
|
332
|
+
await client.capture("oss.runtime.instance_created", baseInstanceEvent);
|
|
333
|
+
|
|
334
|
+
expect(randomSpy).toHaveBeenCalledTimes(1);
|
|
335
|
+
expect(lambdaSpy).toHaveBeenCalledTimes(1);
|
|
336
|
+
expect(lambdaSpy.mock.calls[0][0]).toMatchObject({
|
|
337
|
+
licenseToken: undefined,
|
|
338
|
+
telemetryId: undefined,
|
|
339
|
+
globalProperties: {
|
|
340
|
+
sampleRate: 0.05,
|
|
341
|
+
sampleRateAdjustmentFactor: 0.95,
|
|
342
|
+
sampleWeight: 20,
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
expect(segmentTrackMock).toHaveBeenCalledTimes(1);
|
|
346
|
+
expect(segmentTrackMock.mock.calls[0][0]).toMatchObject({
|
|
347
|
+
properties: expect.objectContaining({
|
|
348
|
+
sampleRate: 0.05,
|
|
349
|
+
sampleRateAdjustmentFactor: 0.95,
|
|
350
|
+
sampleWeight: 20,
|
|
351
|
+
}),
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test.each(["", " \t "])(
|
|
356
|
+
"blank standalone identity %j falls through to a supplied legacy identity",
|
|
357
|
+
async (blankTelemetryId) => {
|
|
358
|
+
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.99);
|
|
359
|
+
const licenseToken = jwtWith({ telemetry_id: "legacy-telemetry-id" });
|
|
360
|
+
const client = makeClient({ sampleRate: 0.05 });
|
|
361
|
+
client.setTelemetryIdentity({
|
|
362
|
+
telemetryId: blankTelemetryId,
|
|
363
|
+
licenseToken,
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
await client.capture("oss.runtime.instance_created", baseInstanceEvent);
|
|
367
|
+
|
|
368
|
+
expect(randomSpy).not.toHaveBeenCalled();
|
|
369
|
+
expect(lambdaSpy).toHaveBeenCalledWith(
|
|
370
|
+
expect.objectContaining({
|
|
371
|
+
licenseToken,
|
|
372
|
+
telemetryId: undefined,
|
|
373
|
+
globalProperties: expect.objectContaining({ sampleRate: 1 }),
|
|
374
|
+
}),
|
|
375
|
+
);
|
|
376
|
+
},
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
test.each(["", " \t "])(
|
|
380
|
+
"blank standalone identity %j without a legacy identity remains anonymously sampled",
|
|
381
|
+
async (blankTelemetryId) => {
|
|
382
|
+
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0);
|
|
383
|
+
const client = makeClient({ sampleRate: 0.05 });
|
|
384
|
+
client.setTelemetryIdentity({ telemetryId: blankTelemetryId });
|
|
385
|
+
|
|
386
|
+
await client.capture("oss.runtime.instance_created", baseInstanceEvent);
|
|
387
|
+
|
|
388
|
+
expect(randomSpy).toHaveBeenCalledTimes(1);
|
|
389
|
+
expect(lambdaSpy).toHaveBeenCalledWith(
|
|
390
|
+
expect.objectContaining({
|
|
391
|
+
licenseToken: undefined,
|
|
392
|
+
telemetryId: undefined,
|
|
393
|
+
globalProperties: expect.objectContaining({ sampleRate: 0.05 }),
|
|
394
|
+
}),
|
|
395
|
+
);
|
|
396
|
+
},
|
|
397
|
+
);
|
|
398
|
+
|
|
79
399
|
test("identified callers bypass the sample gate (lambda + segment fire even when Math.random would fail)", async () => {
|
|
80
400
|
vi.spyOn(Math, "random").mockReturnValue(0.99);
|
|
81
401
|
const client = makeClient({ sampleRate: 0.05 });
|
|
@@ -107,6 +427,20 @@ describe("v1 TelemetryClient", () => {
|
|
|
107
427
|
expect(segmentTrackMock).not.toHaveBeenCalled();
|
|
108
428
|
});
|
|
109
429
|
|
|
430
|
+
test.each(telemetryOptOutCases)(
|
|
431
|
+
"$optOut.environmentVariable disables both sinks for $identity.label telemetry",
|
|
432
|
+
async ({ identity, optOut }) => {
|
|
433
|
+
vi.stubEnv(optOut.environmentVariable, optOut.value);
|
|
434
|
+
const client = makeClient();
|
|
435
|
+
applyTelemetryIdentity(client, identity);
|
|
436
|
+
|
|
437
|
+
await client.capture("oss.runtime.instance_created", baseInstanceEvent);
|
|
438
|
+
|
|
439
|
+
expect(lambdaSpy).not.toHaveBeenCalled();
|
|
440
|
+
expect(segmentTrackMock).not.toHaveBeenCalled();
|
|
441
|
+
},
|
|
442
|
+
);
|
|
443
|
+
|
|
110
444
|
test("setLicenseToken forwards the token in subsequent capture", async () => {
|
|
111
445
|
const token = jwtWith({ telemetry_id: "abc-123" });
|
|
112
446
|
const client = makeClient();
|
|
@@ -195,6 +529,19 @@ describe("v1 TelemetryClient", () => {
|
|
|
195
529
|
expect(segmentTrackMock).not.toHaveBeenCalled();
|
|
196
530
|
});
|
|
197
531
|
|
|
532
|
+
test("illegal base64url license payload cannot bypass sampleRate 0", async () => {
|
|
533
|
+
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0);
|
|
534
|
+
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
535
|
+
const client = makeClient({ sampleRate: 0 });
|
|
536
|
+
|
|
537
|
+
client.setLicenseToken(malformedJwtWithTelemetryId("legacy-telemetry-id"));
|
|
538
|
+
await client.capture("oss.runtime.instance_created", baseInstanceEvent);
|
|
539
|
+
|
|
540
|
+
expect(randomSpy).toHaveBeenCalledTimes(1);
|
|
541
|
+
expect(lambdaSpy).not.toHaveBeenCalled();
|
|
542
|
+
expect(segmentTrackMock).not.toHaveBeenCalled();
|
|
543
|
+
});
|
|
544
|
+
|
|
198
545
|
test("setLicenseToken cache is overwritable (good token replaced by bad → back to anonymous gate)", async () => {
|
|
199
546
|
// Pins the cache as last-write-wins so a refactor to first-write-wins
|
|
200
547
|
// (e.g. `this.telemetryId ??= parseAndWarnTelemetryId(...)`) doesn't
|
|
@@ -250,17 +597,93 @@ describe("v1 TelemetryClient", () => {
|
|
|
250
597
|
// parseFloat('nonsense') = NaN; without the explicit guard, NaN slips
|
|
251
598
|
// past the range check (all NaN comparisons are false) and produces a
|
|
252
599
|
// silent always-drop. Guard the validator with Number.isNaN.
|
|
253
|
-
const original = process.env.COPILOTKIT_TELEMETRY_SAMPLE_RATE;
|
|
254
600
|
process.env.COPILOTKIT_TELEMETRY_SAMPLE_RATE = "not-a-number";
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
601
|
+
|
|
602
|
+
expect(() => makeClient()).toThrow("Sample rate must be between 0 and 1");
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
test("both copies of one capture share a telemetry_event_id and differ only by transport", async () => {
|
|
606
|
+
// The dual-write is the whole of OSS-1019: one request, two rows, and
|
|
607
|
+
// before this nothing on either row said they were the same event.
|
|
608
|
+
// Consumers had to infer it from $lib, which is incidental.
|
|
609
|
+
vi.spyOn(Math, "random").mockReturnValue(0);
|
|
610
|
+
const client = makeClient({ sampleRate: 0.05 });
|
|
611
|
+
|
|
612
|
+
await client.capture("oss.runtime.instance_created", baseInstanceEvent);
|
|
613
|
+
|
|
614
|
+
const lambdaGlobals = lambdaSpy.mock.calls[0][0].globalProperties as Record<
|
|
615
|
+
string,
|
|
616
|
+
unknown
|
|
617
|
+
>;
|
|
618
|
+
const segmentProps = segmentTrackMock.mock.calls[0][0].properties as Record<
|
|
619
|
+
string,
|
|
620
|
+
unknown
|
|
621
|
+
>;
|
|
622
|
+
|
|
623
|
+
expect(lambdaGlobals.telemetry_event_id).toEqual(expect.any(String));
|
|
624
|
+
expect(segmentProps.telemetry_event_id).toBe(
|
|
625
|
+
lambdaGlobals.telemetry_event_id,
|
|
626
|
+
);
|
|
627
|
+
expect(lambdaGlobals.telemetry_transport).toBe("lambda");
|
|
628
|
+
expect(segmentProps.telemetry_transport).toBe("segment");
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
test("each capture gets its own telemetry_event_id", async () => {
|
|
632
|
+
// A per-client id would collapse every event from one process into a
|
|
633
|
+
// single row under a dedupe-by-id rule.
|
|
634
|
+
vi.spyOn(Math, "random").mockReturnValue(0);
|
|
635
|
+
const client = makeClient({ sampleRate: 0.05 });
|
|
636
|
+
|
|
637
|
+
await client.capture("oss.runtime.instance_created", baseInstanceEvent);
|
|
638
|
+
await client.capture("oss.runtime.instance_created", baseInstanceEvent);
|
|
639
|
+
|
|
640
|
+
const first = (
|
|
641
|
+
lambdaSpy.mock.calls[0][0].globalProperties as Record<string, unknown>
|
|
642
|
+
).telemetry_event_id;
|
|
643
|
+
const second = (
|
|
644
|
+
lambdaSpy.mock.calls[1][0].globalProperties as Record<string, unknown>
|
|
645
|
+
).telemetry_event_id;
|
|
646
|
+
|
|
647
|
+
expect(first).not.toBe(second);
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
test("both copies are stamped as emitted by the v1 client", async () => {
|
|
651
|
+
vi.spyOn(Math, "random").mockReturnValue(0);
|
|
652
|
+
const client = makeClient({ sampleRate: 0.05 });
|
|
653
|
+
|
|
654
|
+
await client.capture("oss.runtime.instance_created", baseInstanceEvent);
|
|
655
|
+
|
|
656
|
+
expect(lambdaSpy.mock.calls[0][0].globalProperties).toMatchObject({
|
|
657
|
+
telemetry_emitter: "v1-shared",
|
|
658
|
+
});
|
|
659
|
+
expect(segmentTrackMock.mock.calls[0][0]).toMatchObject({
|
|
660
|
+
properties: expect.objectContaining({ telemetry_emitter: "v1-shared" }),
|
|
661
|
+
});
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
test("telemetry_identified rides on both copies and tracks the gate branch", async () => {
|
|
665
|
+
vi.spyOn(Math, "random").mockReturnValue(0);
|
|
666
|
+
const anonClient = makeClient({ sampleRate: 0.05 });
|
|
667
|
+
await anonClient.capture("oss.runtime.instance_created", baseInstanceEvent);
|
|
668
|
+
expect(lambdaSpy.mock.calls[0][0].globalProperties).toMatchObject({
|
|
669
|
+
telemetry_identified: false,
|
|
670
|
+
});
|
|
671
|
+
expect(segmentTrackMock.mock.calls[0][0]).toMatchObject({
|
|
672
|
+
properties: expect.objectContaining({ telemetry_identified: false }),
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
lambdaSpy.mockClear();
|
|
676
|
+
segmentTrackMock.mockReset();
|
|
677
|
+
|
|
678
|
+
const idClient = makeClient({ sampleRate: 0.05 });
|
|
679
|
+
idClient.setLicenseToken(jwtWith({ telemetry_id: "abc-123" }));
|
|
680
|
+
await idClient.capture("oss.runtime.instance_created", baseInstanceEvent);
|
|
681
|
+
expect(lambdaSpy.mock.calls[0][0].globalProperties).toMatchObject({
|
|
682
|
+
telemetry_identified: true,
|
|
683
|
+
});
|
|
684
|
+
expect(segmentTrackMock.mock.calls[0][0]).toMatchObject({
|
|
685
|
+
properties: expect.objectContaining({ telemetry_identified: true }),
|
|
686
|
+
});
|
|
264
687
|
});
|
|
265
688
|
});
|
|
266
689
|
|