@copilotkit/shared 1.69.3 → 1.70.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.
Files changed (58) hide show
  1. package/dist/index.cjs +31 -13
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +19 -17
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.mts +19 -17
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +29 -15
  8. package/dist/index.mjs.map +1 -1
  9. package/dist/index.umd.js +159 -31
  10. package/dist/index.umd.js.map +1 -1
  11. package/dist/package.cjs +1 -1
  12. package/dist/package.mjs +1 -1
  13. package/dist/telemetry/index.d.mts +3 -2
  14. package/dist/telemetry/lambda-client.cjs +25 -4
  15. package/dist/telemetry/lambda-client.cjs.map +1 -1
  16. package/dist/telemetry/lambda-client.d.cts +14 -1
  17. package/dist/telemetry/lambda-client.d.cts.map +1 -1
  18. package/dist/telemetry/lambda-client.d.mts +14 -1
  19. package/dist/telemetry/lambda-client.d.mts.map +1 -1
  20. package/dist/telemetry/lambda-client.mjs +25 -5
  21. package/dist/telemetry/lambda-client.mjs.map +1 -1
  22. package/dist/telemetry/sampling.cjs +28 -0
  23. package/dist/telemetry/sampling.cjs.map +1 -0
  24. package/dist/telemetry/sampling.d.cts +37 -0
  25. package/dist/telemetry/sampling.d.cts.map +1 -0
  26. package/dist/telemetry/sampling.d.mts +37 -0
  27. package/dist/telemetry/sampling.d.mts.map +1 -0
  28. package/dist/telemetry/sampling.mjs +25 -0
  29. package/dist/telemetry/sampling.mjs.map +1 -0
  30. package/dist/telemetry/telemetry-client.cjs +72 -11
  31. package/dist/telemetry/telemetry-client.cjs.map +1 -1
  32. package/dist/telemetry/telemetry-client.d.cts +43 -1
  33. package/dist/telemetry/telemetry-client.d.cts.map +1 -1
  34. package/dist/telemetry/telemetry-client.d.mts +43 -1
  35. package/dist/telemetry/telemetry-client.d.mts.map +1 -1
  36. package/dist/telemetry/telemetry-client.mjs +73 -12
  37. package/dist/telemetry/telemetry-client.mjs.map +1 -1
  38. package/dist/utils/index.d.cts +1 -1
  39. package/dist/utils/index.d.mts +1 -1
  40. package/dist/utils/types.cjs.map +1 -1
  41. package/dist/utils/types.d.cts +46 -1
  42. package/dist/utils/types.d.cts.map +1 -1
  43. package/dist/utils/types.d.mts +46 -1
  44. package/dist/utils/types.d.mts.map +1 -1
  45. package/dist/utils/types.mjs.map +1 -1
  46. package/package.json +2 -2
  47. package/src/__tests__/license-context.test.ts +224 -25
  48. package/src/index.ts +72 -16
  49. package/src/telemetry/index.ts +2 -0
  50. package/src/telemetry/lambda-client.test.ts +336 -1
  51. package/src/telemetry/lambda-client.ts +56 -15
  52. package/src/telemetry/sampling.test.ts +65 -0
  53. package/src/telemetry/sampling.ts +70 -0
  54. package/src/telemetry/telemetry-blank-license-identity.test.ts +121 -0
  55. package/src/telemetry/telemetry-client.test.ts +438 -15
  56. package/src/telemetry/telemetry-client.ts +145 -30
  57. package/src/utils/__tests__/conditions.test.ts +161 -0
  58. 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 CopilotKit license token is configured. The token is
12
- // a JWT (header.payload.sig) whose payload carries `telemetry_id`.
13
- // The SDK base64url-decodes the payload without verifying the
14
- // Ed25519 signature, which is the license-verifier's job and
15
- // emits the id via `X-CopilotKit-Telemetry-Id`. The Lambda uses it
16
- // to enrich events with the customer's email.
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 `telemetry_id` string field.
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
- let b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
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 atob === "function"
86
- ? atob(b64)
87
- : Buffer.from(b64, "base64").toString("utf8");
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
- return typeof decoded.telemetry_id === "string"
90
- ? decoded.telemetry_id
91
- : null;
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 = parseTelemetryIdFromLicense(opts.licenseToken);
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
+ }