@copilotkit/shared 1.57.2 → 1.57.4
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 +5 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -1
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +2 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +3 -2
- package/dist/index.mjs.map +1 -1
- package/dist/index.umd.js +97 -32
- 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 +2 -1
- package/dist/telemetry/lambda-client.cjs +70 -0
- package/dist/telemetry/lambda-client.cjs.map +1 -0
- package/dist/telemetry/lambda-client.d.cts +18 -0
- package/dist/telemetry/lambda-client.d.cts.map +1 -0
- package/dist/telemetry/lambda-client.d.mts +18 -0
- package/dist/telemetry/lambda-client.d.mts.map +1 -0
- package/dist/telemetry/lambda-client.mjs +67 -0
- package/dist/telemetry/lambda-client.mjs.map +1 -0
- package/dist/telemetry/telemetry-client.cjs +29 -10
- package/dist/telemetry/telemetry-client.cjs.map +1 -1
- package/dist/telemetry/telemetry-client.d.cts +3 -0
- package/dist/telemetry/telemetry-client.d.cts.map +1 -1
- package/dist/telemetry/telemetry-client.d.mts +3 -0
- package/dist/telemetry/telemetry-client.d.mts.map +1 -1
- package/dist/telemetry/telemetry-client.mjs +29 -10
- package/dist/telemetry/telemetry-client.mjs.map +1 -1
- package/package.json +1 -1
- package/src/telemetry/index.ts +6 -0
- package/src/telemetry/lambda-client.test.ts +111 -0
- package/src/telemetry/lambda-client.ts +153 -0
- package/src/telemetry/telemetry-client.test.ts +289 -0
- package/src/telemetry/telemetry-client.ts +67 -15
- package/dist/telemetry/scarf-client.cjs +0 -29
- package/dist/telemetry/scarf-client.cjs.map +0 -1
- package/dist/telemetry/scarf-client.mjs +0 -29
- 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
|
|
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 (
|
|
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
|
-
|
|
102
|
-
anonymousId: this.anonymousId,
|
|
135
|
+
await lambdaClient.send({
|
|
103
136
|
event,
|
|
104
|
-
properties:
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
}
|