Skip to content

Commit ed5f448

Browse files
BenTaylorDevclaude
andcommitted
feat(telemetry): strip cloud API key from lambda payload
The CopilotCloud customer key (`ck_<env>_<id>.<secret>`) is routed to Segment for downstream user analytics, but has no role in the telemetry-sink Lambda. Worse, the secret half should never leave the customer's runtime. Strips both wire-format variants at the lambda-client boundary: - `cloud.public_api_key` (v2 event property convention) - `cloud.publicApiKey` (v1 globalProperties from setCloudConfiguration) The strip happens at the lambda-client wire layer rather than in each caller, so any future caller (or accidental property regression) is covered by default. Boolean indicators like `cloud.api_key_provided` and unrelated fields like `cloud.baseUrl` continue to ride through. New unit test (`lambda-client.test.ts`) exercises the strip with a real fetch spy, plus end-to-end JWT extraction including the no-`telemetry_id` and not-a-JWT fallback paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ff33123 commit ed5f448

2 files changed

Lines changed: 133 additions & 2 deletions

File tree

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});

packages/shared/src/telemetry/lambda-client.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,26 @@ export interface LambdaSendOptions {
4444
licenseToken?: string;
4545
}
4646

47+
// Cloud API key fields routed by the v1 shared TelemetryClient land in
48+
// `globalProperties` for Segment's benefit, but the telemetry-sink
49+
// Lambda has no use for them — and the `.<secret>` half of
50+
// `ck_<env>_<id>.<secret>` must never leave the customer's runtime.
51+
// Strip both the snake_case (v2 event property) and camelCase (v1
52+
// globalProperties) variants at the wire boundary so callers can't
53+
// accidentally leak the key.
54+
const STRIPPED_KEYS = new Set(["cloud.public_api_key", "cloud.publicApiKey"]);
55+
56+
function stripCloudKeys(
57+
obj: Record<string, unknown> | undefined,
58+
): Record<string, unknown> {
59+
if (!obj) return {};
60+
const out: Record<string, unknown> = {};
61+
for (const [k, v] of Object.entries(obj)) {
62+
if (!STRIPPED_KEYS.has(k)) out[k] = v;
63+
}
64+
return out;
65+
}
66+
4767
// Pull telemetry_id out of a CopilotKit license token without verifying
4868
// the signature. The token shape is a standard JWT
4969
// (`<header>.<payload>.<sig>`) with base64url-encoded segments; the
@@ -77,8 +97,8 @@ export async function send(opts: LambdaSendOptions): Promise<void> {
7797
try {
7898
const body = JSON.stringify({
7999
event: opts.event,
80-
properties: opts.properties || {},
81-
global_properties: opts.globalProperties || {},
100+
properties: stripCloudKeys(opts.properties),
101+
global_properties: stripCloudKeys(opts.globalProperties),
82102
package: {
83103
name: opts.packageName,
84104
version: opts.packageVersion,

0 commit comments

Comments
 (0)