Skip to content

Commit 674caac

Browse files
committed
feat(telemetry): gate anonymous v1 events client-side, bypass for identified
Cache parsed telemetry_id at setLicenseToken time and use it to branch in capture(): - Identified callers (token with telemetry_id) always send to both sinks. - Anonymous callers are sampled at sampleRate (default 0.05); one dice roll gates both lambda and Segment. The Lambda no longer needs to bypass-from-sampling for identified events — that decision moves entirely to the client. Reduces lambda invocations by ~95% for the anonymous OSS-runtime firehose.
1 parent e03de79 commit 674caac

2 files changed

Lines changed: 61 additions & 21 deletions

File tree

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

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,27 +51,51 @@ describe("v1 TelemetryClient", () => {
5151
"cloud.api_key_provided": false,
5252
} as const;
5353

54-
test("capture always sends to the lambda sink, regardless of segment sample rate", async () => {
55-
// Math.random=0.99 would sample out segment's 5% gate — lambda must still fire.
56-
vi.spyOn(Math, "random").mockReturnValue(0.99);
54+
test("capture sends to both sinks when sampled in (anonymous, one decision gates both)", async () => {
55+
vi.spyOn(Math, "random").mockReturnValue(0);
5756
const client = makeClient({ sampleRate: 0.05 });
5857

5958
await client.capture("oss.runtime.instance_created", baseInstanceEvent);
6059

6160
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();
6276
expect(segmentTrackMock).not.toHaveBeenCalled();
6377
});
6478

65-
test("capture sends to segment when sampled in", async () => {
66-
vi.spyOn(Math, "random").mockReturnValue(0);
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);
6781
const client = makeClient({ sampleRate: 0.05 });
82+
client.setLicenseToken(jwtWith({ telemetry_id: "abc-123" }));
6883

6984
await client.capture("oss.runtime.instance_created", baseInstanceEvent);
7085

86+
expect(lambdaSpy).toHaveBeenCalledTimes(1);
7187
expect(segmentTrackMock).toHaveBeenCalledTimes(1);
72-
expect(segmentTrackMock.mock.calls[0][0]).toMatchObject({
73-
event: "oss.runtime.instance_created",
74-
});
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);
7599
});
76100

77101
test("capture short-circuits both sinks when telemetryDisabled is true", async () => {
@@ -95,6 +119,7 @@ describe("v1 TelemetryClient", () => {
95119
});
96120

97121
test("capture sends licenseToken=undefined when setLicenseToken was never called", async () => {
122+
vi.spyOn(Math, "random").mockReturnValue(0);
98123
const client = makeClient();
99124

100125
await client.capture("oss.runtime.instance_created", baseInstanceEvent);

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

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,7 @@ import { Analytics } from "@segment/analytics-node";
22
import type { AnalyticsEvents } from "./events";
33
import { flattenObject } from "./utils";
44
import { v4 as uuidv4 } from "uuid";
5-
import lambdaClient, {
6-
warnIfLicenseTokenLacksTelemetryId,
7-
} from "./lambda-client";
5+
import lambdaClient, { parseTelemetryIdFromLicense } from "./lambda-client";
86

97
/**
108
* Checks if telemetry is disabled via environment variables.
@@ -32,12 +30,18 @@ export class TelemetryClient {
3230
// client decodes its payload to extract telemetry_id. Customer API
3331
// keys are NOT used here — they flow only into Segment.
3432
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;
3538
packageName: string;
3639
packageVersion: string;
3740
private telemetryDisabled: boolean = false;
38-
// Sample rate gates the Segment path only. The telemetry-sink Lambda
39-
// handles sampling for the Scarf / Reo / future-vendor fan-out
40-
// server-side, so the Lambda call always fires (subject to telemetryDisabled).
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.
4145
private sampleRate: number = 0.05;
4246
private anonymousId = `anon_${uuidv4()}`;
4347

@@ -92,6 +96,14 @@ export class TelemetryClient {
9296
return;
9397
}
9498

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()) {
104+
return;
105+
}
106+
95107
const flattenedProperties = flattenObject(properties);
96108
const propertiesWithGlobal = {
97109
...this.globalProperties,
@@ -107,9 +119,6 @@ export class TelemetryClient {
107119
{} as Record<string, any>,
108120
);
109121

110-
// Always send to the telemetry-sink Lambda — sampling happens
111-
// server-side. The Lambda fans out to Scarf, Reo, and any future
112-
// vendor sinks.
113122
await lambdaClient.send({
114123
event,
115124
properties: flattenedProperties,
@@ -119,9 +128,7 @@ export class TelemetryClient {
119128
licenseToken: this.licenseToken ?? undefined,
120129
});
121130

122-
// Segment path retained for CopilotCloud-specific user analytics that
123-
// pre-date the Lambda. Keeps its existing 5% client-side sampling.
124-
if (this.shouldSendEvent() && this.segment) {
131+
if (this.segment) {
125132
this.segment.track({
126133
anonymousId: this.anonymousId,
127134
event,
@@ -154,7 +161,15 @@ export class TelemetryClient {
154161
// travels, in the X-CopilotKit-Telemetry-Id header set by lambda-client.
155162
setLicenseToken(licenseToken: string) {
156163
this.licenseToken = licenseToken;
157-
warnIfLicenseTokenLacksTelemetryId(licenseToken);
164+
this.telemetryId = parseTelemetryIdFromLicense(licenseToken);
165+
if (!this.telemetryId) {
166+
// Smoke signal during the issuer rollout: a token was provided
167+
// but no telemetry_id came back. Surface it once at configuration
168+
// time rather than silently degrading to anonymous on every send.
169+
console.warn(
170+
"[CopilotKit] License token did not yield a telemetry_id; telemetry events will be sent anonymously.",
171+
);
172+
}
158173
}
159174

160175
private setSampleRate(sampleRate: number | undefined) {

0 commit comments

Comments
 (0)