@copilotkit/shared 1.57.3 → 1.58.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 (44) hide show
  1. package/dist/index.cjs +5 -1
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +2 -1
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.mts +2 -1
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +3 -2
  8. package/dist/index.mjs.map +1 -1
  9. package/dist/index.umd.js +98 -33
  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 +2 -1
  14. package/dist/telemetry/lambda-client.cjs +70 -0
  15. package/dist/telemetry/lambda-client.cjs.map +1 -0
  16. package/dist/telemetry/lambda-client.d.cts +18 -0
  17. package/dist/telemetry/lambda-client.d.cts.map +1 -0
  18. package/dist/telemetry/lambda-client.d.mts +18 -0
  19. package/dist/telemetry/lambda-client.d.mts.map +1 -0
  20. package/dist/telemetry/lambda-client.mjs +67 -0
  21. package/dist/telemetry/lambda-client.mjs.map +1 -0
  22. package/dist/telemetry/telemetry-client.cjs +29 -10
  23. package/dist/telemetry/telemetry-client.cjs.map +1 -1
  24. package/dist/telemetry/telemetry-client.d.cts +3 -0
  25. package/dist/telemetry/telemetry-client.d.cts.map +1 -1
  26. package/dist/telemetry/telemetry-client.d.mts +3 -0
  27. package/dist/telemetry/telemetry-client.d.mts.map +1 -1
  28. package/dist/telemetry/telemetry-client.mjs +29 -10
  29. package/dist/telemetry/telemetry-client.mjs.map +1 -1
  30. package/dist/utils/console-styling.cjs +1 -1
  31. package/dist/utils/console-styling.cjs.map +1 -1
  32. package/dist/utils/console-styling.mjs +1 -1
  33. package/dist/utils/console-styling.mjs.map +1 -1
  34. package/package.json +1 -1
  35. package/src/telemetry/index.ts +6 -0
  36. package/src/telemetry/lambda-client.test.ts +111 -0
  37. package/src/telemetry/lambda-client.ts +153 -0
  38. package/src/telemetry/telemetry-client.test.ts +289 -0
  39. package/src/telemetry/telemetry-client.ts +67 -15
  40. package/src/utils/console-styling.ts +1 -1
  41. package/dist/telemetry/scarf-client.cjs +0 -29
  42. package/dist/telemetry/scarf-client.cjs.map +0 -1
  43. package/dist/telemetry/scarf-client.mjs +0 -29
  44. package/dist/telemetry/scarf-client.mjs.map +0 -1
@@ -0,0 +1,111 @@
1
+ import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { send } from "./lambda-client";
3
+
4
+ describe("lambda-client send()", () => {
5
+ let fetchMock: ReturnType<typeof vi.fn>;
6
+ let originalFetch: typeof fetch;
7
+
8
+ beforeEach(() => {
9
+ originalFetch = global.fetch;
10
+ fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 202 }));
11
+ global.fetch = fetchMock as unknown as typeof fetch;
12
+ });
13
+
14
+ afterEach(() => {
15
+ global.fetch = originalFetch;
16
+ });
17
+
18
+ function bodyOf(callIdx = 0): {
19
+ properties: Record<string, unknown>;
20
+ global_properties: Record<string, unknown>;
21
+ } {
22
+ const init = fetchMock.mock.calls[callIdx][1] as RequestInit;
23
+ return JSON.parse(init.body as string);
24
+ }
25
+
26
+ function headersOf(callIdx = 0): Record<string, string> {
27
+ const init = fetchMock.mock.calls[callIdx][1] as RequestInit;
28
+ return init.headers as Record<string, string>;
29
+ }
30
+
31
+ test("strips cloud.public_api_key from properties before sending", async () => {
32
+ await send({
33
+ event: "oss.runtime.copilot_request_created",
34
+ properties: {
35
+ requestType: "run",
36
+ "cloud.api_key_provided": true,
37
+ "cloud.public_api_key": "ck_live_abc.secret-blob",
38
+ },
39
+ });
40
+
41
+ const body = bodyOf();
42
+ expect(body.properties).not.toHaveProperty("cloud.public_api_key");
43
+ // Boolean indicator stays — it's not the key itself.
44
+ expect(body.properties).toMatchObject({
45
+ requestType: "run",
46
+ "cloud.api_key_provided": true,
47
+ });
48
+ });
49
+
50
+ test("strips cloud.publicApiKey from globalProperties (v1 camelCase variant)", async () => {
51
+ await send({
52
+ event: "oss.runtime.instance_created",
53
+ globalProperties: {
54
+ "cloud.publicApiKey": "ck_live_abc.secret-blob",
55
+ "cloud.baseUrl": "https://api.cloud.copilotkit.ai",
56
+ sampleRate: 0.05,
57
+ },
58
+ });
59
+
60
+ const body = bodyOf();
61
+ expect(body.global_properties).not.toHaveProperty("cloud.publicApiKey");
62
+ // baseUrl is unrelated to attribution and rides through.
63
+ expect(body.global_properties).toMatchObject({
64
+ "cloud.baseUrl": "https://api.cloud.copilotkit.ai",
65
+ sampleRate: 0.05,
66
+ });
67
+ });
68
+
69
+ test("emits X-CopilotKit-Telemetry-Id when license JWT carries telemetry_id", async () => {
70
+ const payload = Buffer.from('{"telemetry_id":"abc-123"}').toString(
71
+ "base64url",
72
+ );
73
+ const token = `header.${payload}.sig`;
74
+
75
+ await send({
76
+ event: "oss.runtime.instance_created",
77
+ licenseToken: token,
78
+ });
79
+
80
+ expect(headersOf()["X-CopilotKit-Telemetry-Id"]).toBe("abc-123");
81
+ });
82
+
83
+ test("falls through to anonymous when license JWT has no telemetry_id", async () => {
84
+ const payload = Buffer.from('{"license_id":"foo"}').toString("base64url");
85
+ const token = `header.${payload}.sig`;
86
+
87
+ await send({
88
+ event: "oss.runtime.instance_created",
89
+ licenseToken: token,
90
+ });
91
+
92
+ expect(headersOf()["X-CopilotKit-Telemetry-Id"]).toBeUndefined();
93
+ });
94
+
95
+ test("falls through to anonymous when license token isn't a JWT shape", async () => {
96
+ await send({
97
+ event: "oss.runtime.instance_created",
98
+ licenseToken: "not-a-jwt",
99
+ });
100
+
101
+ expect(headersOf()["X-CopilotKit-Telemetry-Id"]).toBeUndefined();
102
+ });
103
+
104
+ test("swallows fetch errors silently", async () => {
105
+ fetchMock.mockRejectedValueOnce(new Error("network down"));
106
+
107
+ await expect(
108
+ send({ event: "oss.runtime.instance_created" }),
109
+ ).resolves.toBeUndefined();
110
+ });
111
+ });
@@ -0,0 +1,153 @@
1
+ // Telemetry sink client.
2
+ //
3
+ // Posts events to a CopilotKit-controlled telemetry-sink endpoint, which
4
+ // fans out to Scarf, Reo, and any future destinations. Replaces the direct
5
+ // per-vendor calls (scarf-client.ts) so that vendor changes don't require
6
+ // SDK releases and so that downstream services we don't want exposed to
7
+ // OSS readers (e.g. the email-enrichment service backing Reo) stay
8
+ // private.
9
+ //
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.
17
+ // - Anonymous: no license token, or a malformed/non-JWT one. No
18
+ // telemetry-id header; events still flow, attribution is best-effort
19
+ // from request-level signals (IP, UA).
20
+ //
21
+ // Note: CopilotCloud customer API keys (`ck_<env>_<id>.<secret>`) are
22
+ // unrelated to telemetry attribution. They flow into Segment / PostHog
23
+ // via the v1 shared TelemetryClient and never reach this code path.
24
+ //
25
+ // Best-effort: every error is swallowed. Telemetry must not break the
26
+ // host application.
27
+
28
+ const TELEMETRY_SINK_URL =
29
+ (typeof process !== "undefined" && process.env?.COPILOTKIT_TELEMETRY_URL) ||
30
+ "https://telemetry.copilotkit.ai/ingest";
31
+
32
+ const FETCH_TIMEOUT_MS = 3000;
33
+
34
+ export interface LambdaSendOptions {
35
+ event: string;
36
+ properties?: Record<string, unknown>;
37
+ globalProperties?: Record<string, unknown>;
38
+ packageName?: string;
39
+ packageVersion?: string;
40
+ // The CopilotKit license token (Ed25519-signed JWT), when one is
41
+ // configured on the runtime. The sender base64url-decodes the payload
42
+ // segment to extract `telemetry_id`; missing or malformed tokens
43
+ // produce an anonymous send.
44
+ licenseToken?: string;
45
+ }
46
+
47
+ // These fields aren't used by the telemetry service, so we strip them
48
+ // at the wire boundary rather than rely on every caller to omit them.
49
+ // Both the snake_case and camelCase variants are listed because callers
50
+ // upstream use different conventions.
51
+ const STRIPPED_KEYS = new Set(["cloud.public_api_key", "cloud.publicApiKey"]);
52
+
53
+ function stripCloudKeys(
54
+ obj: Record<string, unknown> | undefined,
55
+ ): Record<string, unknown> {
56
+ if (!obj) return {};
57
+ const out: Record<string, unknown> = {};
58
+ for (const [k, v] of Object.entries(obj)) {
59
+ if (!STRIPPED_KEYS.has(k)) out[k] = v;
60
+ }
61
+ return out;
62
+ }
63
+
64
+ // Pull telemetry_id out of a CopilotKit license token without verifying
65
+ // the signature. The token shape is a standard JWT
66
+ // (`<header>.<payload>.<sig>`) with base64url-encoded segments; the
67
+ // payload is JSON with a `telemetry_id` string field.
68
+ //
69
+ // Verification (Ed25519, key rotation, expiry) is the license-verifier
70
+ // package's job. For telemetry attribution we only need the claimed id —
71
+ // the trust model is claim-only on the Lambda side anyway.
72
+ //
73
+ // Exported so TelemetryClient setters can detect unparseable tokens at
74
+ // configuration time and surface a single warning, instead of silently
75
+ // emitting anonymous events on every capture.
76
+ export function parseTelemetryIdFromLicense(token?: string): string | null {
77
+ if (!token) return null;
78
+ const parts = token.split(".");
79
+ if (parts.length !== 3) return null;
80
+ try {
81
+ let b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
82
+ const padding = (4 - (b64.length % 4)) % 4;
83
+ b64 += "=".repeat(padding);
84
+ const json =
85
+ typeof atob === "function"
86
+ ? atob(b64)
87
+ : Buffer.from(b64, "base64").toString("utf8");
88
+ const decoded = JSON.parse(json) as { telemetry_id?: unknown };
89
+ return typeof decoded.telemetry_id === "string"
90
+ ? decoded.telemetry_id
91
+ : null;
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ // Parse the telemetry_id from a license token AND emit the rollout smoke
98
+ // signal if the parse returned null. Returning the parsed id lets callers
99
+ // cache it in one step (avoiding a second parseTelemetryIdFromLicense
100
+ // pass) while keeping the warn text in lockstep between v1 (shared) and
101
+ // v2 (runtime) TelemetryClient.setLicenseToken.
102
+ export function parseAndWarnTelemetryId(licenseToken: string): string | null {
103
+ const telemetryId = parseTelemetryIdFromLicense(licenseToken);
104
+ if (!telemetryId) {
105
+ console.warn(
106
+ "[CopilotKit] License token did not yield a telemetry_id; telemetry events will be sent anonymously.",
107
+ );
108
+ }
109
+ return telemetryId;
110
+ }
111
+
112
+ export async function send(opts: LambdaSendOptions): Promise<void> {
113
+ try {
114
+ const body = JSON.stringify({
115
+ event: opts.event,
116
+ properties: stripCloudKeys(opts.properties),
117
+ global_properties: stripCloudKeys(opts.globalProperties),
118
+ package: {
119
+ name: opts.packageName,
120
+ version: opts.packageVersion,
121
+ },
122
+ ts: Math.floor(Date.now() / 1000),
123
+ });
124
+
125
+ const telemetryId = parseTelemetryIdFromLicense(opts.licenseToken);
126
+ const headers: Record<string, string> = {
127
+ "Content-Type": "application/json",
128
+ "User-Agent": opts.packageName
129
+ ? `CopilotKit-Runtime/${opts.packageVersion ?? "unknown"} (${opts.packageName})`
130
+ : "CopilotKit-Runtime",
131
+ };
132
+ if (telemetryId) {
133
+ headers["X-CopilotKit-Telemetry-Id"] = telemetryId;
134
+ }
135
+
136
+ const controller = new AbortController();
137
+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
138
+ try {
139
+ await fetch(TELEMETRY_SINK_URL, {
140
+ method: "POST",
141
+ headers,
142
+ body,
143
+ signal: controller.signal,
144
+ });
145
+ } finally {
146
+ clearTimeout(timeoutId);
147
+ }
148
+ } catch {
149
+ // Silent failure — telemetry must not break the application.
150
+ }
151
+ }
152
+
153
+ export default { send };
@@ -0,0 +1,289 @@
1
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
2
+ import type { MockInstance } from "vitest";
3
+ import lambdaClient from "./lambda-client";
4
+ import { TelemetryClient, isTelemetryDisabled } from "./telemetry-client";
5
+
6
+ // Module mock so constructing TelemetryClient doesn't spin up segment's
7
+ // internal flush queue. Class-based (not vi.fn) so `vi.restoreAllMocks()`
8
+ // between tests doesn't wipe the `track` binding on subsequent `new
9
+ // Analytics(...)` calls.
10
+ const { segmentTrackMock } = vi.hoisted(() => ({
11
+ segmentTrackMock: vi.fn(),
12
+ }));
13
+ vi.mock("@segment/analytics-node", () => ({
14
+ Analytics: class {
15
+ track = segmentTrackMock;
16
+ },
17
+ }));
18
+
19
+ describe("v1 TelemetryClient", () => {
20
+ let lambdaSpy: MockInstance<typeof lambdaClient.send>;
21
+
22
+ beforeEach(() => {
23
+ lambdaSpy = vi.spyOn(lambdaClient, "send").mockResolvedValue(undefined);
24
+ segmentTrackMock.mockReset();
25
+ });
26
+
27
+ afterEach(() => {
28
+ vi.restoreAllMocks();
29
+ });
30
+
31
+ function makeClient(
32
+ overrides: Partial<ConstructorParameters<typeof TelemetryClient>[0]> = {},
33
+ ): TelemetryClient {
34
+ return new TelemetryClient({
35
+ packageName: "@copilotkit/shared",
36
+ packageVersion: "1.0.0",
37
+ sampleRate: 1,
38
+ ...overrides,
39
+ });
40
+ }
41
+
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
+ const baseInstanceEvent = {
48
+ actionsAmount: 0,
49
+ endpointsAmount: 0,
50
+ endpointTypes: [],
51
+ "cloud.api_key_provided": false,
52
+ } as const;
53
+
54
+ test("capture sends to both sinks when sampled in (anonymous, one decision gates both)", async () => {
55
+ vi.spyOn(Math, "random").mockReturnValue(0);
56
+ const client = makeClient({ sampleRate: 0.05 });
57
+
58
+ await client.capture("oss.runtime.instance_created", baseInstanceEvent);
59
+
60
+ expect(lambdaSpy).toHaveBeenCalledTimes(1);
61
+ expect(segmentTrackMock).toHaveBeenCalledTimes(1);
62
+ expect(segmentTrackMock.mock.calls[0][0]).toMatchObject({
63
+ event: "oss.runtime.instance_created",
64
+ });
65
+ });
66
+
67
+ test("capture skips both sinks when anonymous and sampled out", async () => {
68
+ // Math.random=0.99 vs sampleRate=0.05 — anonymous caller is gated out;
69
+ // neither sink should fire under the new one-decision-both-sinks model.
70
+ vi.spyOn(Math, "random").mockReturnValue(0.99);
71
+ const client = makeClient({ sampleRate: 0.05 });
72
+
73
+ await client.capture("oss.runtime.instance_created", baseInstanceEvent);
74
+
75
+ expect(lambdaSpy).not.toHaveBeenCalled();
76
+ expect(segmentTrackMock).not.toHaveBeenCalled();
77
+ });
78
+
79
+ test("identified callers bypass the sample gate (lambda + segment fire even when Math.random would fail)", async () => {
80
+ vi.spyOn(Math, "random").mockReturnValue(0.99);
81
+ const client = makeClient({ sampleRate: 0.05 });
82
+ client.setLicenseToken(jwtWith({ telemetry_id: "abc-123" }));
83
+
84
+ await client.capture("oss.runtime.instance_created", baseInstanceEvent);
85
+
86
+ expect(lambdaSpy).toHaveBeenCalledTimes(1);
87
+ expect(segmentTrackMock).toHaveBeenCalledTimes(1);
88
+ });
89
+
90
+ test("identified callers send to both sinks on every capture", async () => {
91
+ const client = makeClient({ sampleRate: 0.05 });
92
+ client.setLicenseToken(jwtWith({ telemetry_id: "abc-123" }));
93
+
94
+ await client.capture("oss.runtime.instance_created", baseInstanceEvent);
95
+ await client.capture("oss.runtime.instance_created", baseInstanceEvent);
96
+
97
+ expect(lambdaSpy).toHaveBeenCalledTimes(2);
98
+ expect(segmentTrackMock).toHaveBeenCalledTimes(2);
99
+ });
100
+
101
+ test("capture short-circuits both sinks when telemetryDisabled is true", async () => {
102
+ const client = makeClient({ telemetryDisabled: true });
103
+
104
+ await client.capture("oss.runtime.instance_created", baseInstanceEvent);
105
+
106
+ expect(lambdaSpy).not.toHaveBeenCalled();
107
+ expect(segmentTrackMock).not.toHaveBeenCalled();
108
+ });
109
+
110
+ test("setLicenseToken forwards the token in subsequent capture", async () => {
111
+ const token = jwtWith({ telemetry_id: "abc-123" });
112
+ const client = makeClient();
113
+ client.setLicenseToken(token);
114
+
115
+ await client.capture("oss.runtime.instance_created", baseInstanceEvent);
116
+
117
+ expect(lambdaSpy).toHaveBeenCalledTimes(1);
118
+ expect(lambdaSpy.mock.calls[0][0].licenseToken).toBe(token);
119
+ });
120
+
121
+ test("capture sends licenseToken=undefined when setLicenseToken was never called", async () => {
122
+ vi.spyOn(Math, "random").mockReturnValue(0);
123
+ const client = makeClient();
124
+
125
+ await client.capture("oss.runtime.instance_created", baseInstanceEvent);
126
+
127
+ expect(lambdaSpy.mock.calls[0][0].licenseToken).toBeUndefined();
128
+ });
129
+
130
+ test("setLicenseToken warns once when the token has no telemetry_id", () => {
131
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
132
+ const client = makeClient();
133
+
134
+ client.setLicenseToken(jwtWith({ license_id: "x" }));
135
+
136
+ expect(warn).toHaveBeenCalledTimes(1);
137
+ expect(warn.mock.calls[0][0]).toMatch(/telemetry_id/);
138
+ });
139
+
140
+ test("setLicenseToken does not warn when the token carries telemetry_id", () => {
141
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
142
+ const client = makeClient();
143
+
144
+ client.setLicenseToken(jwtWith({ telemetry_id: "abc-123" }));
145
+
146
+ expect(warn).not.toHaveBeenCalled();
147
+ });
148
+
149
+ test("identified events carry sampleWeight=1 (anonymous events carry 1/sampleRate)", async () => {
150
+ // Anonymous: sampleWeight should be 1 / sampleRate so downstream
151
+ // weight-based extrapolation reconstructs true volume.
152
+ // Identified: bypassing the gate means each event represents itself,
153
+ // so sampleWeight must be 1 — not the population's 1/sampleRate.
154
+ vi.spyOn(Math, "random").mockReturnValue(0);
155
+ const anonClient = makeClient({ sampleRate: 0.05 });
156
+ await anonClient.capture("oss.runtime.instance_created", baseInstanceEvent);
157
+ expect(lambdaSpy.mock.calls[0][0].globalProperties).toMatchObject({
158
+ sampleRate: 0.05,
159
+ sampleWeight: 20,
160
+ });
161
+ expect(segmentTrackMock.mock.calls[0][0]).toMatchObject({
162
+ properties: expect.objectContaining({
163
+ sampleRate: 0.05,
164
+ sampleWeight: 20,
165
+ }),
166
+ });
167
+
168
+ lambdaSpy.mockClear();
169
+ segmentTrackMock.mockReset();
170
+
171
+ const idClient = makeClient({ sampleRate: 0.05 });
172
+ idClient.setLicenseToken(jwtWith({ telemetry_id: "abc-123" }));
173
+ await idClient.capture("oss.runtime.instance_created", baseInstanceEvent);
174
+ expect(lambdaSpy.mock.calls[0][0].globalProperties).toMatchObject({
175
+ sampleRate: 1,
176
+ sampleWeight: 1,
177
+ });
178
+ expect(segmentTrackMock.mock.calls[0][0]).toMatchObject({
179
+ properties: expect.objectContaining({ sampleRate: 1, sampleWeight: 1 }),
180
+ });
181
+ });
182
+
183
+ test("malformed license token stays anonymous and remains sample-gated", async () => {
184
+ // parseTelemetryIdFromLicense returns null for any of: empty token,
185
+ // wrong-shape (not three dot-separated segments), base64/JSON parse
186
+ // failure. A misconfigured customer must not flip to identified-bypass.
187
+ vi.spyOn(Math, "random").mockReturnValue(0.99);
188
+ vi.spyOn(console, "warn").mockImplementation(() => undefined);
189
+ const client = makeClient({ sampleRate: 0.05 });
190
+
191
+ client.setLicenseToken("not-a-jwt");
192
+ await client.capture("oss.runtime.instance_created", baseInstanceEvent);
193
+
194
+ expect(lambdaSpy).not.toHaveBeenCalled();
195
+ expect(segmentTrackMock).not.toHaveBeenCalled();
196
+ });
197
+
198
+ test("setLicenseToken cache is overwritable (good token replaced by bad → back to anonymous gate)", async () => {
199
+ // Pins the cache as last-write-wins so a refactor to first-write-wins
200
+ // (e.g. `this.telemetryId ??= parseAndWarnTelemetryId(...)`) doesn't
201
+ // leak identified-bypass status across license replacements.
202
+ vi.spyOn(Math, "random").mockReturnValue(0.99);
203
+ vi.spyOn(console, "warn").mockImplementation(() => undefined);
204
+ const client = makeClient({ sampleRate: 0.05 });
205
+
206
+ client.setLicenseToken(jwtWith({ telemetry_id: "abc-123" }));
207
+ client.setLicenseToken(jwtWith({ license_id: "no-telemetry-id" }));
208
+ await client.capture("oss.runtime.instance_created", baseInstanceEvent);
209
+
210
+ expect(lambdaSpy).not.toHaveBeenCalled();
211
+ expect(segmentTrackMock).not.toHaveBeenCalled();
212
+ });
213
+
214
+ test("setCloudConfiguration writes cloud keys into globalProperties for both sinks", async () => {
215
+ vi.spyOn(Math, "random").mockReturnValue(0);
216
+ const client = makeClient({ sampleRate: 1 });
217
+ client.setCloudConfiguration({
218
+ publicApiKey: "ck_live_test.secret",
219
+ baseUrl: "https://api.cloud.copilotkit.ai",
220
+ });
221
+
222
+ await client.capture("oss.runtime.instance_created", baseInstanceEvent);
223
+
224
+ // v1 still ships cloud.publicApiKey through globalProperties — the
225
+ // lambda-client.send sanitization strips it at the wire (covered in
226
+ // lambda-client.test.ts), and Segment retains it intentionally for
227
+ // existing CopilotCloud user analytics.
228
+ expect(lambdaSpy.mock.calls[0][0].globalProperties).toMatchObject({
229
+ "cloud.publicApiKey": "ck_live_test.secret",
230
+ "cloud.baseUrl": "https://api.cloud.copilotkit.ai",
231
+ });
232
+ expect(segmentTrackMock.mock.calls[0][0]).toMatchObject({
233
+ properties: expect.objectContaining({
234
+ "cloud.publicApiKey": "ck_live_test.secret",
235
+ "cloud.baseUrl": "https://api.cloud.copilotkit.ai",
236
+ }),
237
+ });
238
+ });
239
+
240
+ test("constructor rejects sampleRate outside [0, 1]", () => {
241
+ expect(() => makeClient({ sampleRate: 1.5 })).toThrow(
242
+ "Sample rate must be between 0 and 1",
243
+ );
244
+ expect(() => makeClient({ sampleRate: -0.1 })).toThrow(
245
+ "Sample rate must be between 0 and 1",
246
+ );
247
+ });
248
+
249
+ test("constructor rejects NaN sampleRate from a malformed env override", () => {
250
+ // parseFloat('nonsense') = NaN; without the explicit guard, NaN slips
251
+ // past the range check (all NaN comparisons are false) and produces a
252
+ // silent always-drop. Guard the validator with Number.isNaN.
253
+ const original = process.env.COPILOTKIT_TELEMETRY_SAMPLE_RATE;
254
+ process.env.COPILOTKIT_TELEMETRY_SAMPLE_RATE = "not-a-number";
255
+ try {
256
+ expect(() => makeClient()).toThrow("Sample rate must be between 0 and 1");
257
+ } finally {
258
+ if (original === undefined) {
259
+ delete process.env.COPILOTKIT_TELEMETRY_SAMPLE_RATE;
260
+ } else {
261
+ process.env.COPILOTKIT_TELEMETRY_SAMPLE_RATE = original;
262
+ }
263
+ }
264
+ });
265
+ });
266
+
267
+ describe("isTelemetryDisabled", () => {
268
+ const originalEnv = { ...process.env };
269
+
270
+ afterEach(() => {
271
+ process.env = { ...originalEnv };
272
+ });
273
+
274
+ test.each([
275
+ ["COPILOTKIT_TELEMETRY_DISABLED", "true"],
276
+ ["COPILOTKIT_TELEMETRY_DISABLED", "1"],
277
+ ["DO_NOT_TRACK", "true"],
278
+ ["DO_NOT_TRACK", "1"],
279
+ ])("returns true when %s=%s", (key, val) => {
280
+ process.env[key] = val;
281
+ expect(isTelemetryDisabled()).toBe(true);
282
+ });
283
+
284
+ test("returns false when no opt-out env var is set", () => {
285
+ delete process.env.COPILOTKIT_TELEMETRY_DISABLED;
286
+ delete process.env.DO_NOT_TRACK;
287
+ expect(isTelemetryDisabled()).toBe(false);
288
+ });
289
+ });
@@ -1,8 +1,8 @@
1
1
  import { Analytics } from "@segment/analytics-node";
2
- import { AnalyticsEvents } from "./events";
2
+ import type { AnalyticsEvents } from "./events";
3
3
  import { flattenObject } from "./utils";
4
4
  import { v4 as uuidv4 } from "uuid";
5
- import scarfClient from "./scarf-client";
5
+ import lambdaClient, { parseAndWarnTelemetryId } from "./lambda-client";
6
6
 
7
7
  /**
8
8
  * Checks if telemetry is disabled via environment variables.
@@ -26,9 +26,22 @@ export class TelemetryClient {
26
26
  segment: Analytics | undefined;
27
27
  globalProperties: Record<string, any> = {};
28
28
  cloudConfiguration: { publicApiKey: string; baseUrl: string } | null = null;
29
+ // EIP / Intelligence license token (Ed25519-signed JWT). The lambda
30
+ // client decodes its payload to extract telemetry_id. Customer API
31
+ // keys are NOT used here — they flow only into Segment.
32
+ private licenseToken: string | null = null;
33
+ // Parsed telemetry_id from the license-token JWT payload. Cached at
34
+ // setLicenseToken time so `capture()` can branch on identified vs
35
+ // anonymous without re-parsing per event. Null when the token is
36
+ // absent or yielded no telemetry_id.
37
+ private telemetryId: string | null = null;
29
38
  packageName: string;
30
39
  packageVersion: string;
31
40
  private telemetryDisabled: boolean = false;
41
+ // Client-side sampling rate for anonymous events. Identified events
42
+ // (those whose license token yielded a telemetry_id) bypass the gate
43
+ // entirely. Applied uniformly to both the lambda sink and Segment —
44
+ // one dice roll per capture, both sinks see the same decision.
32
45
  private sampleRate: number = 0.05;
33
46
  private anonymousId = `anon_${uuidv4()}`;
34
47
 
@@ -79,13 +92,34 @@ export class TelemetryClient {
79
92
  event: K,
80
93
  properties: AnalyticsEvents[K],
81
94
  ) {
82
- if (!this.shouldSendEvent() || !this.segment) {
95
+ if (this.telemetryDisabled) {
96
+ return;
97
+ }
98
+
99
+ // Anonymous callers (no telemetry_id) are gated by sampleRate.
100
+ // Identified callers (license token with telemetry_id) always send —
101
+ // the volume is bounded by paying-customer count and full fidelity
102
+ // per identified customer is worth the marginal cost.
103
+ if (!this.telemetryId && !this.shouldSendEvent()) {
83
104
  return;
84
105
  }
85
106
 
107
+ // Identified events ship at 100% effective rate, anonymous events at
108
+ // sampleRate. Compute per-event so downstream weight-based extrapolation
109
+ // (sampleWeight = 1 / effectiveRate) is correct for both populations;
110
+ // a single global sampleWeight would overweight identified-customer
111
+ // counts by 1/sampleRate.
112
+ const effectiveSampleRate = this.telemetryId ? 1 : this.sampleRate;
113
+ const samplingMeta = {
114
+ sampleRate: effectiveSampleRate,
115
+ sampleRateAdjustmentFactor: 1 - effectiveSampleRate,
116
+ sampleWeight: 1 / effectiveSampleRate,
117
+ };
118
+
86
119
  const flattenedProperties = flattenObject(properties);
87
120
  const propertiesWithGlobal = {
88
121
  ...this.globalProperties,
122
+ ...samplingMeta,
89
123
  ...flattenedProperties,
90
124
  };
91
125
  const orderedPropertiesWithGlobal = Object.keys(propertiesWithGlobal)
@@ -98,15 +132,22 @@ export class TelemetryClient {
98
132
  {} as Record<string, any>,
99
133
  );
100
134
 
101
- this.segment.track({
102
- anonymousId: this.anonymousId,
135
+ await lambdaClient.send({
103
136
  event,
104
- properties: { ...orderedPropertiesWithGlobal },
137
+ properties: flattenedProperties,
138
+ globalProperties: { ...this.globalProperties, ...samplingMeta },
139
+ packageName: this.packageName,
140
+ packageVersion: this.packageVersion,
141
+ licenseToken: this.licenseToken ?? undefined,
105
142
  });
106
143
 
107
- await scarfClient.logEvent({
108
- event,
109
- });
144
+ if (this.segment) {
145
+ this.segment.track({
146
+ anonymousId: this.anonymousId,
147
+ event,
148
+ properties: { ...orderedPropertiesWithGlobal },
149
+ });
150
+ }
110
151
  }
111
152
 
112
153
  setGlobalProperties(properties: Record<string, any>) {
@@ -128,6 +169,14 @@ export class TelemetryClient {
128
169
  });
129
170
  }
130
171
 
172
+ // The license token isn't added to globalProperties — we don't want
173
+ // the JWT itself shipped on every event. Only its decoded telemetry_id
174
+ // travels, in the X-CopilotKit-Telemetry-Id header set by lambda-client.
175
+ setLicenseToken(licenseToken: string) {
176
+ this.licenseToken = licenseToken;
177
+ this.telemetryId = parseAndWarnTelemetryId(licenseToken);
178
+ }
179
+
131
180
  private setSampleRate(sampleRate: number | undefined) {
132
181
  let _sampleRate: number;
133
182
 
@@ -139,15 +188,18 @@ export class TelemetryClient {
139
188
  _sampleRate = parseFloat(process.env.COPILOTKIT_TELEMETRY_SAMPLE_RATE);
140
189
  }
141
190
 
142
- if (_sampleRate < 0 || _sampleRate > 1) {
191
+ // Number.isNaN guards against parseFloat("nonsense") slipping past the
192
+ // range check (all NaN comparisons are false), which would silently
193
+ // drop every anonymous event with no signal — especially important
194
+ // since the default is now 0.05, making env-var overrides more common.
195
+ if (Number.isNaN(_sampleRate) || _sampleRate < 0 || _sampleRate > 1) {
143
196
  throw new Error("Sample rate must be between 0 and 1");
144
197
  }
145
198
 
146
199
  this.sampleRate = _sampleRate;
147
- this.setGlobalProperties({
148
- sampleRate: this.sampleRate,
149
- sampleRateAdjustmentFactor: 1 - this.sampleRate,
150
- sampleWeight: 1 / this.sampleRate,
151
- });
200
+ // Per-event sampling metadata (sampleRate/sampleRateAdjustmentFactor/
201
+ // sampleWeight) is computed in capture() so identified events get
202
+ // their own effectiveSampleRate=1 weight instead of the anonymous
203
+ // population's 1/sampleRate.
152
204
  }
153
205
  }