@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
|
@@ -1,5 +1,167 @@
|
|
|
1
1
|
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
-
import { send } from "./lambda-client";
|
|
2
|
+
import { parseTelemetryIdFromLicense, send } from "./lambda-client";
|
|
3
|
+
|
|
4
|
+
const EXPECTED_TELEMETRY_SINK_URL =
|
|
5
|
+
process.env.COPILOTKIT_TELEMETRY_URL ??
|
|
6
|
+
"https://telemetry.copilotkit.ai/ingest";
|
|
7
|
+
const TELEMETRY_ID_HEADER = "x-copilotkit-telemetry-id";
|
|
8
|
+
const LEGACY_IDENTITY_PAYLOAD = Buffer.from(
|
|
9
|
+
JSON.stringify({ telemetry_id: "legacy-telemetry-id" }),
|
|
10
|
+
).toString("base64url");
|
|
11
|
+
const INVALID_BASE64URL_PAYLOADS = [
|
|
12
|
+
{
|
|
13
|
+
label: "a dollar sign",
|
|
14
|
+
payload: `${LEGACY_IDENTITY_PAYLOAD}$`,
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
label: "a space",
|
|
18
|
+
payload: `${LEGACY_IDENTITY_PAYLOAD} `,
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
label: "a line break",
|
|
22
|
+
payload: `${LEGACY_IDENTITY_PAYLOAD}\n`,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
label: "an impossible length",
|
|
26
|
+
payload: `${Buffer.from(JSON.stringify({ telemetry_id: "xx" })).toString(
|
|
27
|
+
"base64url",
|
|
28
|
+
)}A`,
|
|
29
|
+
},
|
|
30
|
+
] as const;
|
|
31
|
+
|
|
32
|
+
test("normalizes surrounding HTTP whitespace in a legacy license identity", () => {
|
|
33
|
+
const telemetryId = "\t legacy-telemetry-id \t";
|
|
34
|
+
const payload = Buffer.from(
|
|
35
|
+
JSON.stringify({ telemetry_id: telemetryId }),
|
|
36
|
+
).toString("base64url");
|
|
37
|
+
|
|
38
|
+
expect(parseTelemetryIdFromLicense(`header.${payload}.sig`)).toBe(
|
|
39
|
+
"legacy-telemetry-id",
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("rejects an ingest-invalid UTF-8 legacy identity in the browser fallback", () => {
|
|
44
|
+
const payload = Buffer.from(
|
|
45
|
+
JSON.stringify({ telemetry_id: "tenant-é" }),
|
|
46
|
+
).toString("base64url");
|
|
47
|
+
vi.stubGlobal("Buffer", undefined);
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
expect(parseTelemetryIdFromLicense(`header.${payload}.sig`)).toBeNull();
|
|
51
|
+
} finally {
|
|
52
|
+
vi.unstubAllGlobals();
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test.each(INVALID_BASE64URL_PAYLOADS)(
|
|
57
|
+
"rejects a legacy payload with $label in Node and the browser fallback",
|
|
58
|
+
({ payload }) => {
|
|
59
|
+
const token = `header.${payload}.sig`;
|
|
60
|
+
|
|
61
|
+
expect(parseTelemetryIdFromLicense(token)).toBeNull();
|
|
62
|
+
|
|
63
|
+
vi.stubGlobal("Buffer", undefined);
|
|
64
|
+
try {
|
|
65
|
+
expect(parseTelemetryIdFromLicense(token)).toBeNull();
|
|
66
|
+
} finally {
|
|
67
|
+
vi.unstubAllGlobals();
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Captures the telemetry request without replacing fetch with an untyped mock.
|
|
74
|
+
*/
|
|
75
|
+
function setupCapturedRequest() {
|
|
76
|
+
let capturedRequest:
|
|
77
|
+
| {
|
|
78
|
+
bodyText: string;
|
|
79
|
+
headers: Record<string, string>;
|
|
80
|
+
rawTelemetryIdHeader: string | undefined;
|
|
81
|
+
url: string;
|
|
82
|
+
}
|
|
83
|
+
| undefined;
|
|
84
|
+
const fetchMock = vi
|
|
85
|
+
.spyOn(globalThis, "fetch")
|
|
86
|
+
.mockImplementation((input, init) => {
|
|
87
|
+
if (typeof init?.body !== "string") {
|
|
88
|
+
throw new Error("Expected telemetry request body to be a string");
|
|
89
|
+
}
|
|
90
|
+
if (
|
|
91
|
+
init.headers === undefined ||
|
|
92
|
+
init.headers instanceof Headers ||
|
|
93
|
+
Array.isArray(init.headers)
|
|
94
|
+
) {
|
|
95
|
+
throw new Error("Expected telemetry request headers to be a record");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
capturedRequest = {
|
|
99
|
+
bodyText: init.body,
|
|
100
|
+
headers: Object.fromEntries(new Headers(init.headers).entries()),
|
|
101
|
+
rawTelemetryIdHeader:
|
|
102
|
+
init.headers["X-CopilotKit-Telemetry-Id"] ?? undefined,
|
|
103
|
+
url: String(input),
|
|
104
|
+
};
|
|
105
|
+
return Promise.resolve(new Response(null, { status: 202 }));
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const readRequest = () => {
|
|
109
|
+
if (!capturedRequest) {
|
|
110
|
+
throw new Error("Expected telemetry send to reach fetch successfully");
|
|
111
|
+
}
|
|
112
|
+
return capturedRequest;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
readRequest,
|
|
117
|
+
teardown: () => fetchMock.mockRestore(),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
test.each(["bad\nid", "bad\u0000id", "tenant-🚀"])(
|
|
122
|
+
"header-invalid legacy identity %j sends anonymously instead of dropping the event",
|
|
123
|
+
async (invalidTelemetryId) => {
|
|
124
|
+
const payload = Buffer.from(
|
|
125
|
+
JSON.stringify({ telemetry_id: invalidTelemetryId }),
|
|
126
|
+
).toString("base64url");
|
|
127
|
+
const { readRequest, teardown } = setupCapturedRequest();
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
await send({
|
|
131
|
+
event: "oss.runtime.instance_created",
|
|
132
|
+
licenseToken: `header.${payload}.sig`,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
expect(readRequest().rawTelemetryIdHeader).toBeUndefined();
|
|
136
|
+
} finally {
|
|
137
|
+
teardown();
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
test.each(INVALID_BASE64URL_PAYLOADS)(
|
|
143
|
+
"legacy payload with $label sends anonymously in Node and the browser fallback",
|
|
144
|
+
async ({ payload }) => {
|
|
145
|
+
const { readRequest, teardown } = setupCapturedRequest();
|
|
146
|
+
const sendMalformedToken = () =>
|
|
147
|
+
send({
|
|
148
|
+
event: "oss.runtime.instance_created",
|
|
149
|
+
licenseToken: `header.${payload}.sig`,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
await sendMalformedToken();
|
|
154
|
+
expect(readRequest().rawTelemetryIdHeader).toBeUndefined();
|
|
155
|
+
|
|
156
|
+
vi.stubGlobal("Buffer", undefined);
|
|
157
|
+
await sendMalformedToken();
|
|
158
|
+
expect(readRequest().rawTelemetryIdHeader).toBeUndefined();
|
|
159
|
+
} finally {
|
|
160
|
+
vi.unstubAllGlobals();
|
|
161
|
+
teardown();
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
);
|
|
3
165
|
|
|
4
166
|
describe("lambda-client send()", () => {
|
|
5
167
|
let fetchMock: ReturnType<typeof vi.fn>;
|
|
@@ -109,3 +271,176 @@ describe("lambda-client send()", () => {
|
|
|
109
271
|
).resolves.toBeUndefined();
|
|
110
272
|
});
|
|
111
273
|
});
|
|
274
|
+
|
|
275
|
+
const lambdaIdentityTransportCases = [
|
|
276
|
+
{
|
|
277
|
+
label: "standalone identity over a legacy license identity",
|
|
278
|
+
explicitTelemetryId: "explicit-telemetry-id",
|
|
279
|
+
licenseTelemetryId: "license-telemetry-id",
|
|
280
|
+
expectedTelemetryId: "explicit-telemetry-id",
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
label: "normalized standalone identity over a legacy license identity",
|
|
284
|
+
explicitTelemetryId: "\t explicit-telemetry-id \t",
|
|
285
|
+
licenseTelemetryId: "license-telemetry-id",
|
|
286
|
+
expectedTelemetryId: "explicit-telemetry-id",
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
label: "legacy license identity without a standalone identity",
|
|
290
|
+
explicitTelemetryId: undefined,
|
|
291
|
+
licenseTelemetryId: "license-telemetry-id",
|
|
292
|
+
expectedTelemetryId: "license-telemetry-id",
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
label: "legacy license identity with an empty standalone identity",
|
|
296
|
+
explicitTelemetryId: "",
|
|
297
|
+
licenseTelemetryId: "license-telemetry-id",
|
|
298
|
+
expectedTelemetryId: "license-telemetry-id",
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
label: "legacy license identity with a whitespace-only standalone identity",
|
|
302
|
+
explicitTelemetryId: " \t ",
|
|
303
|
+
licenseTelemetryId: "license-telemetry-id",
|
|
304
|
+
expectedTelemetryId: "license-telemetry-id",
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
label: "legacy license identity with a header-invalid standalone identity",
|
|
308
|
+
explicitTelemetryId: "bad\nid",
|
|
309
|
+
licenseTelemetryId: "license-telemetry-id",
|
|
310
|
+
expectedTelemetryId: "license-telemetry-id",
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
label: "legacy license identity with an ingest-invalid standalone identity",
|
|
314
|
+
explicitTelemetryId: "team.prod",
|
|
315
|
+
licenseTelemetryId: "license-telemetry-id",
|
|
316
|
+
expectedTelemetryId: "license-telemetry-id",
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
label: "legacy license identity with an overlong standalone identity",
|
|
320
|
+
explicitTelemetryId: "a".repeat(129),
|
|
321
|
+
licenseTelemetryId: "license-telemetry-id",
|
|
322
|
+
expectedTelemetryId: "license-telemetry-id",
|
|
323
|
+
},
|
|
324
|
+
] as const;
|
|
325
|
+
|
|
326
|
+
test.each(lambdaIdentityTransportCases)(
|
|
327
|
+
"sends $label only through the telemetry identity header",
|
|
328
|
+
async ({ explicitTelemetryId, licenseTelemetryId, expectedTelemetryId }) => {
|
|
329
|
+
const payload = Buffer.from(
|
|
330
|
+
JSON.stringify({ telemetry_id: licenseTelemetryId }),
|
|
331
|
+
).toString("base64url");
|
|
332
|
+
const licenseToken = `header.${payload}.sig`;
|
|
333
|
+
const { readRequest, teardown } = setupCapturedRequest();
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
await send({
|
|
337
|
+
event: "oss.runtime.instance_created",
|
|
338
|
+
properties: { requestType: "run" },
|
|
339
|
+
globalProperties: { sampleRate: 0.25 },
|
|
340
|
+
packageName: "@copilotkit/runtime",
|
|
341
|
+
packageVersion: "1.2.3",
|
|
342
|
+
licenseToken,
|
|
343
|
+
telemetryId: explicitTelemetryId,
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
const { bodyText, headers, rawTelemetryIdHeader, url } = readRequest();
|
|
347
|
+
expect(url).toBe(EXPECTED_TELEMETRY_SINK_URL);
|
|
348
|
+
expect(rawTelemetryIdHeader).toBe(expectedTelemetryId);
|
|
349
|
+
expect(headers).toEqual({
|
|
350
|
+
"content-type": "application/json",
|
|
351
|
+
[TELEMETRY_ID_HEADER]: expectedTelemetryId,
|
|
352
|
+
"user-agent": "CopilotKit-Runtime/1.2.3 (@copilotkit/runtime)",
|
|
353
|
+
});
|
|
354
|
+
expect(JSON.parse(bodyText)).toEqual({
|
|
355
|
+
event: "oss.runtime.instance_created",
|
|
356
|
+
properties: { requestType: "run" },
|
|
357
|
+
global_properties: { sampleRate: 0.25 },
|
|
358
|
+
package: {
|
|
359
|
+
name: "@copilotkit/runtime",
|
|
360
|
+
version: "1.2.3",
|
|
361
|
+
},
|
|
362
|
+
ts: expect.any(Number),
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
const nonIdentityHeaders = Object.entries(headers).filter(
|
|
366
|
+
([name]) => name !== TELEMETRY_ID_HEADER,
|
|
367
|
+
);
|
|
368
|
+
const nonIdentityHeaderText = JSON.stringify(nonIdentityHeaders);
|
|
369
|
+
for (const identity of [explicitTelemetryId, licenseTelemetryId]) {
|
|
370
|
+
if (identity === undefined || identity.trim().length === 0) continue;
|
|
371
|
+
expect(url).not.toContain(identity);
|
|
372
|
+
expect(bodyText).not.toContain(identity);
|
|
373
|
+
expect(nonIdentityHeaderText).not.toContain(identity);
|
|
374
|
+
}
|
|
375
|
+
} finally {
|
|
376
|
+
teardown();
|
|
377
|
+
}
|
|
378
|
+
},
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
test.each(["", " \t "])(
|
|
382
|
+
"blank standalone identity %j without a legacy identity sends no identity header",
|
|
383
|
+
async (blankTelemetryId) => {
|
|
384
|
+
const { readRequest, teardown } = setupCapturedRequest();
|
|
385
|
+
|
|
386
|
+
try {
|
|
387
|
+
await send({
|
|
388
|
+
event: "oss.runtime.instance_created",
|
|
389
|
+
telemetryId: blankTelemetryId,
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
expect(readRequest().headers[TELEMETRY_ID_HEADER]).toBeUndefined();
|
|
393
|
+
} finally {
|
|
394
|
+
teardown();
|
|
395
|
+
}
|
|
396
|
+
},
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
test.each([
|
|
400
|
+
"bad\nid",
|
|
401
|
+
"bad\rid",
|
|
402
|
+
"bad\u0000id",
|
|
403
|
+
"bad\u0001id",
|
|
404
|
+
"bad\u007fid",
|
|
405
|
+
"tenant-🚀",
|
|
406
|
+
])(
|
|
407
|
+
"header-invalid standalone identity %j sends anonymously instead of dropping the event",
|
|
408
|
+
async (invalidTelemetryId) => {
|
|
409
|
+
const { readRequest, teardown } = setupCapturedRequest();
|
|
410
|
+
|
|
411
|
+
try {
|
|
412
|
+
await send({
|
|
413
|
+
event: "oss.runtime.instance_created",
|
|
414
|
+
telemetryId: invalidTelemetryId,
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
const { headers, rawTelemetryIdHeader } = readRequest();
|
|
418
|
+
expect(rawTelemetryIdHeader).toBeUndefined();
|
|
419
|
+
expect(headers[TELEMETRY_ID_HEADER]).toBeUndefined();
|
|
420
|
+
} finally {
|
|
421
|
+
teardown();
|
|
422
|
+
}
|
|
423
|
+
},
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
test("does not treat COPILOTKIT_TELEMETRY_ID as an identity alias", async () => {
|
|
427
|
+
const originalTelemetryId = process.env.COPILOTKIT_TELEMETRY_ID;
|
|
428
|
+
const envTelemetryId = "environment-telemetry-id";
|
|
429
|
+
process.env.COPILOTKIT_TELEMETRY_ID = envTelemetryId;
|
|
430
|
+
const { readRequest, teardown } = setupCapturedRequest();
|
|
431
|
+
|
|
432
|
+
try {
|
|
433
|
+
await send({ event: "oss.runtime.instance_created" });
|
|
434
|
+
|
|
435
|
+
const { bodyText, headers } = readRequest();
|
|
436
|
+
expect(headers[TELEMETRY_ID_HEADER]).toBeUndefined();
|
|
437
|
+
expect(bodyText).not.toContain(envTelemetryId);
|
|
438
|
+
} finally {
|
|
439
|
+
if (originalTelemetryId === undefined) {
|
|
440
|
+
delete process.env.COPILOTKIT_TELEMETRY_ID;
|
|
441
|
+
} else {
|
|
442
|
+
process.env.COPILOTKIT_TELEMETRY_ID = originalTelemetryId;
|
|
443
|
+
}
|
|
444
|
+
teardown();
|
|
445
|
+
}
|
|
446
|
+
});
|
|
@@ -8,12 +8,12 @@
|
|
|
8
8
|
// private.
|
|
9
9
|
//
|
|
10
10
|
// Two attribution modes:
|
|
11
|
-
// - Identified: a
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
11
|
+
// - Identified: a standalone telemetry id or a CopilotKit license token is
|
|
12
|
+
// configured. Standalone identity takes precedence. License tokens are
|
|
13
|
+
// JWTs (header.payload.sig) whose payload carries `telemetry_id`; the SDK
|
|
14
|
+
// base64url-decodes the payload — without verifying the Ed25519 signature,
|
|
15
|
+
// which is the license-verifier's job. The resolved identity is emitted
|
|
16
|
+
// only via `X-CopilotKit-Telemetry-Id`.
|
|
17
17
|
// - Anonymous: no license token, or a malformed/non-JWT one. No
|
|
18
18
|
// telemetry-id header; events still flow, attribution is best-effort
|
|
19
19
|
// from request-level signals (IP, UA).
|
|
@@ -30,6 +30,7 @@ const TELEMETRY_SINK_URL =
|
|
|
30
30
|
"https://telemetry.copilotkit.ai/ingest";
|
|
31
31
|
|
|
32
32
|
const FETCH_TIMEOUT_MS = 3000;
|
|
33
|
+
const TELEMETRY_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
|
|
33
34
|
|
|
34
35
|
export interface LambdaSendOptions {
|
|
35
36
|
event: string;
|
|
@@ -37,6 +38,8 @@ export interface LambdaSendOptions {
|
|
|
37
38
|
globalProperties?: Record<string, unknown>;
|
|
38
39
|
packageName?: string;
|
|
39
40
|
packageVersion?: string;
|
|
41
|
+
/** Standalone analytics identity, resolved before any legacy license claim. */
|
|
42
|
+
telemetryId?: string;
|
|
40
43
|
// The CopilotKit license token (Ed25519-signed JWT), when one is
|
|
41
44
|
// configured on the runtime. The sender base64url-decodes the payload
|
|
42
45
|
// segment to extract `telemetry_id`; missing or malformed tokens
|
|
@@ -44,6 +47,33 @@ export interface LambdaSendOptions {
|
|
|
44
47
|
licenseToken?: string;
|
|
45
48
|
}
|
|
46
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Return the first telemetry identity accepted by the ingest service.
|
|
52
|
+
*
|
|
53
|
+
* Empty and whitespace-only values are unconfigured placeholders and must not
|
|
54
|
+
* suppress a later identity source. Leading and trailing HTTP spaces and tabs
|
|
55
|
+
* are removed before validation. The ingest service accepts 1 to 128 ASCII
|
|
56
|
+
* letters, digits, underscores, and hyphens.
|
|
57
|
+
*
|
|
58
|
+
* @internal
|
|
59
|
+
*/
|
|
60
|
+
export function firstNonBlankTelemetryId(
|
|
61
|
+
...candidates: ReadonlyArray<string | undefined>
|
|
62
|
+
): string | undefined {
|
|
63
|
+
for (const candidate of candidates) {
|
|
64
|
+
if (candidate === undefined) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const normalized = candidate.replace(/^[\t ]+|[\t ]+$/g, "");
|
|
69
|
+
if (TELEMETRY_ID_PATTERN.test(normalized)) {
|
|
70
|
+
return normalized;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
47
77
|
// These fields aren't used by the telemetry service, so we strip them
|
|
48
78
|
// at the wire boundary rather than rely on every caller to omit them.
|
|
49
79
|
// Both the snake_case and camelCase variants are listed because callers
|
|
@@ -64,7 +94,7 @@ function stripCloudKeys(
|
|
|
64
94
|
// Pull telemetry_id out of a CopilotKit license token without verifying
|
|
65
95
|
// the signature. The token shape is a standard JWT
|
|
66
96
|
// (`<header>.<payload>.<sig>`) with base64url-encoded segments; the
|
|
67
|
-
// payload is JSON with a
|
|
97
|
+
// payload is UTF-8 JSON with a telemetry_id accepted by the ingest service.
|
|
68
98
|
//
|
|
69
99
|
// Verification (Ed25519, key rotation, expiry) is the license-verifier
|
|
70
100
|
// package's job. For telemetry attribution we only need the claimed id —
|
|
@@ -78,17 +108,26 @@ export function parseTelemetryIdFromLicense(token?: string): string | null {
|
|
|
78
108
|
const parts = token.split(".");
|
|
79
109
|
if (parts.length !== 3) return null;
|
|
80
110
|
try {
|
|
81
|
-
|
|
111
|
+
const payload = parts[1];
|
|
112
|
+
if (!/^[A-Za-z0-9_-]+$/.test(payload) || payload.length % 4 === 1) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let b64 = payload.replace(/-/g, "+").replace(/_/g, "/");
|
|
82
117
|
const padding = (4 - (b64.length % 4)) % 4;
|
|
83
118
|
b64 += "=".repeat(padding);
|
|
84
119
|
const json =
|
|
85
|
-
typeof
|
|
86
|
-
?
|
|
87
|
-
:
|
|
120
|
+
typeof Buffer !== "undefined"
|
|
121
|
+
? Buffer.from(b64, "base64").toString("utf8")
|
|
122
|
+
: new TextDecoder().decode(
|
|
123
|
+
Uint8Array.from(atob(b64), (character) => character.charCodeAt(0)),
|
|
124
|
+
);
|
|
88
125
|
const decoded = JSON.parse(json) as { telemetry_id?: unknown };
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
126
|
+
const telemetryId =
|
|
127
|
+
typeof decoded.telemetry_id === "string"
|
|
128
|
+
? decoded.telemetry_id
|
|
129
|
+
: undefined;
|
|
130
|
+
return firstNonBlankTelemetryId(telemetryId) ?? null;
|
|
92
131
|
} catch {
|
|
93
132
|
return null;
|
|
94
133
|
}
|
|
@@ -122,7 +161,9 @@ export async function send(opts: LambdaSendOptions): Promise<void> {
|
|
|
122
161
|
ts: Math.floor(Date.now() / 1000),
|
|
123
162
|
});
|
|
124
163
|
|
|
125
|
-
const telemetryId =
|
|
164
|
+
const telemetryId =
|
|
165
|
+
firstNonBlankTelemetryId(opts.telemetryId) ??
|
|
166
|
+
parseTelemetryIdFromLicense(opts.licenseToken);
|
|
126
167
|
const headers: Record<string, string> = {
|
|
127
168
|
"Content-Type": "application/json",
|
|
128
169
|
"User-Agent": opts.packageName
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
computeSamplingMeta,
|
|
4
|
+
TELEMETRY_EMITTER_V1,
|
|
5
|
+
TELEMETRY_EMITTER_V2,
|
|
6
|
+
} from "./sampling";
|
|
7
|
+
|
|
8
|
+
describe("computeSamplingMeta", () => {
|
|
9
|
+
test("anonymous callers get the population weight 1/sampleRate", () => {
|
|
10
|
+
// The gate let 5% through, so each surviving event stands for 20.
|
|
11
|
+
expect(
|
|
12
|
+
computeSamplingMeta({ telemetryId: null, sampleRate: 0.05 }),
|
|
13
|
+
).toEqual({
|
|
14
|
+
sampleRate: 0.05,
|
|
15
|
+
sampleRateAdjustmentFactor: 0.95,
|
|
16
|
+
sampleWeight: 20,
|
|
17
|
+
telemetry_identified: false,
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("identified callers get weight 1 — they bypassed the gate", () => {
|
|
22
|
+
// The branch that matters: an identified event represents only itself,
|
|
23
|
+
// so weighting it by the anonymous population's 20 would inflate every
|
|
24
|
+
// paying customer's volume 20×.
|
|
25
|
+
expect(
|
|
26
|
+
computeSamplingMeta({ telemetryId: "abc-123", sampleRate: 0.05 }),
|
|
27
|
+
).toEqual({
|
|
28
|
+
sampleRate: 1,
|
|
29
|
+
sampleRateAdjustmentFactor: 0,
|
|
30
|
+
sampleWeight: 1,
|
|
31
|
+
telemetry_identified: true,
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("telemetry_identified stays true when sampleRate=1 makes the weights identical", () => {
|
|
36
|
+
// COPILOTKIT_TELEMETRY_SAMPLE_RATE=1 gives anonymous events weight 1
|
|
37
|
+
// too, so weight alone stops separating the populations. This is the
|
|
38
|
+
// case that makes the flag worth carrying rather than inferring
|
|
39
|
+
// (OSS-1018).
|
|
40
|
+
const anon = computeSamplingMeta({ telemetryId: null, sampleRate: 1 });
|
|
41
|
+
const identified = computeSamplingMeta({
|
|
42
|
+
telemetryId: "abc-123",
|
|
43
|
+
sampleRate: 1,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
expect(anon.sampleWeight).toBe(identified.sampleWeight);
|
|
47
|
+
expect(anon.telemetry_identified).toBe(false);
|
|
48
|
+
expect(identified.telemetry_identified).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("an empty telemetry_id is anonymous, not identified", () => {
|
|
52
|
+
// parseTelemetryIdFromLicense only returns strings or null, but a
|
|
53
|
+
// token carrying `telemetry_id: ""` would otherwise read as identified
|
|
54
|
+
// and silently bypass the sample gate.
|
|
55
|
+
expect(
|
|
56
|
+
computeSamplingMeta({ telemetryId: "", sampleRate: 0.05 }),
|
|
57
|
+
).toMatchObject({ sampleWeight: 20, telemetry_identified: false });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("the two emitter markers are distinct", () => {
|
|
61
|
+
// Downstream separates the populations by this value; if they ever
|
|
62
|
+
// collide the dedupe rule silently keeps or drops both.
|
|
63
|
+
expect(TELEMETRY_EMITTER_V1).not.toBe(TELEMETRY_EMITTER_V2);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Per-event telemetry metadata shared by both TelemetryClients.
|
|
2
|
+
//
|
|
3
|
+
// Two clients emit `oss.runtime.*`: the v1 client in this package and the
|
|
4
|
+
// v2 client in @copilotkit/runtime. Both gate anonymous events at
|
|
5
|
+
// `sampleRate` and let identified callers (a license token that yielded a
|
|
6
|
+
// telemetry_id) through at 100%, so real volume can only be recovered
|
|
7
|
+
// downstream as sum(sampleWeight) — a flat multiplier is wrong whenever
|
|
8
|
+
// the identified share moves, which it does as Intelligence keys roll out.
|
|
9
|
+
//
|
|
10
|
+
// The two clients computed this independently and drifted: v2 sampled but
|
|
11
|
+
// stamped nothing, leaving ~24% of runtime volume unweightable from the
|
|
12
|
+
// data alone (OSS-1017). Computing it here is what stops them drifting
|
|
13
|
+
// again.
|
|
14
|
+
|
|
15
|
+
/** Identifies which client emitted an event. */
|
|
16
|
+
export const TELEMETRY_EMITTER_V1 = "v1-shared";
|
|
17
|
+
export const TELEMETRY_EMITTER_V2 = "v2-runtime";
|
|
18
|
+
|
|
19
|
+
export type TelemetryEmitter =
|
|
20
|
+
| typeof TELEMETRY_EMITTER_V1
|
|
21
|
+
| typeof TELEMETRY_EMITTER_V2;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Which wire an event copy travelled on. The v1 client sends each capture
|
|
25
|
+
* to both, so this is what tells the two copies apart downstream
|
|
26
|
+
* (OSS-1019); the v2 client only ever sends to the lambda sink.
|
|
27
|
+
*/
|
|
28
|
+
export type TelemetryTransport = "segment" | "lambda";
|
|
29
|
+
|
|
30
|
+
export interface SamplingMeta {
|
|
31
|
+
/** The rate this event was actually gated at: 1 when identified. */
|
|
32
|
+
sampleRate: number;
|
|
33
|
+
sampleRateAdjustmentFactor: number;
|
|
34
|
+
/** Multiply by this to extrapolate the population the event stands for. */
|
|
35
|
+
sampleWeight: number;
|
|
36
|
+
/** Whether the event bypassed the sample gate. */
|
|
37
|
+
telemetry_identified: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Compute the sampling block for one captured event.
|
|
42
|
+
*
|
|
43
|
+
* `telemetryId` is the caller's parsed license telemetry_id, or null when
|
|
44
|
+
* anonymous — it decides the branch, and is deliberately not returned:
|
|
45
|
+
* only the non-PII shape below travels on the event.
|
|
46
|
+
*/
|
|
47
|
+
export function computeSamplingMeta({
|
|
48
|
+
telemetryId,
|
|
49
|
+
sampleRate,
|
|
50
|
+
}: {
|
|
51
|
+
telemetryId: string | null;
|
|
52
|
+
sampleRate: number;
|
|
53
|
+
}): SamplingMeta {
|
|
54
|
+
const identified = Boolean(telemetryId);
|
|
55
|
+
// Identified events ship at a 100% effective rate, anonymous ones at
|
|
56
|
+
// sampleRate. Computed per event because a single global weight would
|
|
57
|
+
// overweight identified-customer counts by 1/sampleRate.
|
|
58
|
+
const effectiveSampleRate = identified ? 1 : sampleRate;
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
sampleRate: effectiveSampleRate,
|
|
62
|
+
sampleRateAdjustmentFactor: 1 - effectiveSampleRate,
|
|
63
|
+
sampleWeight: 1 / effectiveSampleRate,
|
|
64
|
+
// Stated outright rather than inferred from sampleWeight === 1:
|
|
65
|
+
// under COPILOTKIT_TELEMETRY_SAMPLE_RATE=1 anonymous events also
|
|
66
|
+
// weigh 1, and the two populations stop being distinguishable
|
|
67
|
+
// (OSS-1018).
|
|
68
|
+
telemetry_identified: identified,
|
|
69
|
+
};
|
|
70
|
+
}
|