@copilotkit/web-inspector 1.61.1 → 1.61.2

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.
@@ -1,17 +1,27 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
2
  import type { MockInstance } from "vitest";
3
3
 
4
+ import { readFileSync } from "node:fs";
5
+ import { resolve } from "node:path";
6
+
4
7
  import {
5
8
  TELEMETRY_DOCS_URL,
6
9
  TELEMETRY_EVENTS,
7
10
  TELEMETRY_INGEST_URL,
11
+ getRuntimeUrlType,
8
12
  getTelemetryDistinctIdForUrl,
9
13
  maybeShowDisclosure,
10
14
  track,
11
15
  trackBannerClicked,
12
16
  trackBannerViewed,
17
+ trackTalkToEngineerClicked,
18
+ trackThreadsEmptyEnabledViewed,
19
+ trackThreadsEnabledViewed,
20
+ trackThreadsIntelligenceSignupClicked,
21
+ trackThreadsLockedViewed,
13
22
  trackThreadsTabClicked,
14
- } from "../telemetry";
23
+ trackThreadsTalkToEngineerClicked,
24
+ } from "../telemetry.js";
15
25
  import {
16
26
  _resetTelemetryPersistenceForTesting,
17
27
  getOrCreateTelemetryDistinctId,
@@ -19,13 +29,16 @@ import {
19
29
  isTelemetryOptedOut,
20
30
  markTelemetryDisclosureShown,
21
31
  setTelemetryOptOut,
22
- } from "../persistence";
32
+ } from "../persistence.js";
23
33
 
24
34
  // The wrapper short-circuits before any network call when opted out, but
25
35
  // for the network-touching cases we mock fetch globally so we can read
26
36
  // what would have been sent without making real HTTP requests.
27
37
  let fetchMock: MockInstance<typeof fetch>;
28
38
  let consoleInfoSpy: MockInstance<typeof console.info>;
39
+ const webInspectorPackage = JSON.parse(
40
+ readFileSync(resolve(process.cwd(), "package.json"), "utf8"),
41
+ ) as { version: string };
29
42
 
30
43
  beforeEach(() => {
31
44
  // Each test starts from a clean localStorage so distinct-ID + opt-out
@@ -83,10 +96,12 @@ describe("track()", () => {
83
96
  // package is top-level object, not a string inside properties
84
97
  expect(body.package).toEqual({ name: "@copilotkit/web-inspector" });
85
98
  expect(body.properties).not.toHaveProperty("package");
99
+ expect(body.properties).not.toHaveProperty("package_name");
100
+ expect(body.properties).not.toHaveProperty("inspector_distinct_id");
86
101
  expect(typeof body.ts).toBe("number");
87
102
  });
88
103
 
89
- it("sends regardless of localStorage opt-out callers gate on core.telemetryDisabled", async () => {
104
+ it("short-circuits when localStorage opt-out is set", async () => {
90
105
  setTelemetryOptOut(true);
91
106
  expect(isTelemetryOptedOut()).toBe(true);
92
107
 
@@ -96,9 +111,20 @@ describe("track()", () => {
96
111
  });
97
112
  await Promise.resolve();
98
113
 
99
- // track() no longer short-circuits on localStorage; opt-out is enforced
100
- // at the call site via core.telemetryDisabled before track*() is invoked.
101
- expect(fetchMock).toHaveBeenCalledTimes(1);
114
+ expect(fetchMock).not.toHaveBeenCalled();
115
+ });
116
+
117
+ it("swallows unserializable properties before dispatching", async () => {
118
+ const circular: Record<string, unknown> = {};
119
+ circular.self = circular;
120
+
121
+ expect(() => track(TELEMETRY_EVENTS.bannerClicked, circular)).not.toThrow();
122
+ expect(() =>
123
+ track(TELEMETRY_EVENTS.bannerClicked, { value: 1n }),
124
+ ).not.toThrow();
125
+ await Promise.resolve();
126
+
127
+ expect(fetchMock).not.toHaveBeenCalled();
102
128
  });
103
129
 
104
130
  it("swallows fetch failures (telemetry is best-effort)", async () => {
@@ -170,17 +196,131 @@ describe("typed helpers", () => {
170
196
  expect(body.properties.cta).toBe("body");
171
197
  });
172
198
 
173
- it("trackThreadsTabClicked sends no caller-supplied properties", async () => {
174
- trackThreadsTabClicked();
199
+ it("trackThreadsTabClicked sends thread metadata without content", async () => {
200
+ trackThreadsTabClicked({
201
+ intelligence_status: "intelligence_not_enabled",
202
+ thread_service_status: "unavailable",
203
+ runtime_mode: "sse",
204
+ runtime_url_type: "localhost",
205
+ license_status: "none",
206
+ telemetry_disabled: false,
207
+ });
175
208
  await Promise.resolve();
176
209
  const [, init] = fetchMock.mock.calls[0]!;
177
210
  const body = JSON.parse((init?.body as string) ?? "{}") as {
178
211
  event: string;
179
212
  properties: Record<string, unknown>;
213
+ package: { name: string; version?: string };
180
214
  };
181
215
  expect(body.event).toBe(TELEMETRY_EVENTS.threadsTabClicked);
182
- // Only distinct_id should be in properties (no caller keys)
183
- expect(Object.keys(body.properties)).toEqual(["distinct_id"]);
216
+ expect(body.properties).toMatchObject({
217
+ intelligence_status: "intelligence_not_enabled",
218
+ thread_service_status: "unavailable",
219
+ runtime_mode: "sse",
220
+ runtime_url_type: "localhost",
221
+ license_status: "none",
222
+ telemetry_disabled: false,
223
+ package_name: "@copilotkit/web-inspector",
224
+ package_version: webInspectorPackage.version,
225
+ });
226
+ expect(body.properties).toHaveProperty("inspector_distinct_id");
227
+ expect(body.properties.inspector_distinct_id).toBe(
228
+ body.properties.distinct_id,
229
+ );
230
+ expect(body.package).toEqual({
231
+ name: "@copilotkit/web-inspector",
232
+ version: webInspectorPackage.version,
233
+ });
234
+ });
235
+
236
+ it("sends required threads CTA and viewed events", async () => {
237
+ trackThreadsLockedViewed({
238
+ intelligence_status: "intelligence_not_enabled",
239
+ thread_service_status: "unavailable",
240
+ });
241
+ trackThreadsIntelligenceSignupClicked({
242
+ cta: "signup",
243
+ cta_surface: "threads_locked",
244
+ posthog_distinct_id: "abc-123",
245
+ });
246
+ trackThreadsTalkToEngineerClicked({
247
+ cta: "talk_to_engineer",
248
+ cta_surface: "threads_locked",
249
+ posthog_distinct_id: "abc-123",
250
+ });
251
+ trackTalkToEngineerClicked({
252
+ cta: "talk_to_engineer",
253
+ cta_surface: "threads_header",
254
+ posthog_distinct_id: "abc-123",
255
+ });
256
+ trackThreadsEmptyEnabledViewed({
257
+ intelligence_status: "intelligence_enabled",
258
+ thread_service_status: "available",
259
+ thread_count: 0,
260
+ });
261
+ trackThreadsEnabledViewed({
262
+ intelligence_status: "intelligence_enabled",
263
+ thread_service_status: "available",
264
+ thread_count: 2,
265
+ });
266
+
267
+ await Promise.resolve();
268
+
269
+ const payloads = fetchMock.mock.calls.map(([, init]) => {
270
+ return JSON.parse((init?.body as string) ?? "{}") as {
271
+ event: string;
272
+ properties: Record<string, unknown>;
273
+ };
274
+ });
275
+ const events = payloads.map((payload) => payload.event);
276
+ expect(events).toEqual([
277
+ TELEMETRY_EVENTS.threadsLockedViewed,
278
+ TELEMETRY_EVENTS.threadsIntelligenceSignupClicked,
279
+ TELEMETRY_EVENTS.threadsTalkToEngineerClicked,
280
+ TELEMETRY_EVENTS.talkToEngineerClicked,
281
+ TELEMETRY_EVENTS.threadsEmptyEnabledViewed,
282
+ TELEMETRY_EVENTS.threadsEnabledViewed,
283
+ ]);
284
+ expect(payloads[0]!.properties).toMatchObject({
285
+ intelligence_status: "intelligence_not_enabled",
286
+ thread_service_status: "unavailable",
287
+ });
288
+ expect(payloads[1]!.properties).toMatchObject({
289
+ cta: "signup",
290
+ cta_surface: "threads_locked",
291
+ posthog_distinct_id: "abc-123",
292
+ });
293
+ expect(payloads[2]!.properties).toMatchObject({
294
+ cta: "talk_to_engineer",
295
+ cta_surface: "threads_locked",
296
+ posthog_distinct_id: "abc-123",
297
+ });
298
+ expect(payloads[3]!.properties).toMatchObject({
299
+ cta: "talk_to_engineer",
300
+ cta_surface: "threads_header",
301
+ posthog_distinct_id: "abc-123",
302
+ });
303
+ expect(payloads[4]!.properties).toMatchObject({
304
+ intelligence_status: "intelligence_enabled",
305
+ thread_service_status: "available",
306
+ thread_count: 0,
307
+ });
308
+ expect(payloads[5]!.properties).toMatchObject({
309
+ intelligence_status: "intelligence_enabled",
310
+ thread_service_status: "available",
311
+ thread_count: 2,
312
+ });
313
+ });
314
+ });
315
+
316
+ // ─── Safe URL classification ────────────────────────────────────────────────
317
+
318
+ describe("getRuntimeUrlType()", () => {
319
+ it("classifies runtime URLs without exposing origins", () => {
320
+ expect(getRuntimeUrlType(undefined)).toBe("missing");
321
+ expect(getRuntimeUrlType("/api/copilotkit")).toBe("relative");
322
+ expect(getRuntimeUrlType("http://localhost:4000")).toBe("localhost");
323
+ expect(getRuntimeUrlType("https://example.com/api")).toBe("remote");
184
324
  });
185
325
  });
186
326
 
@@ -1,4 +1,4 @@
1
- import type { Anchor, ContextState, Position, Size } from "./types";
1
+ import type { Anchor, ContextState, Position, Size } from "./types.js";
2
2
 
3
3
  export function updateSizeFromElement(
4
4
  state: ContextState,
@@ -1,4 +1,4 @@
1
- import type { Anchor, DockMode, Position, Size } from "./types";
1
+ import type { Anchor, DockMode, Position, Size } from "./types.js";
2
2
 
3
3
  export type PersistedContextState = {
4
4
  anchor?: Anchor;
@@ -1,6 +1,5 @@
1
- // Inspector-side anonymous telemetry. Three V1 events fire from index.ts
2
- // `oss.inspector.banner_viewed`, `oss.inspector.banner_clicked`, and
3
- // `oss.inspector.threads_tab_clicked`. POSTs directly from the browser
1
+ // Inspector-side anonymous telemetry. V1 events fire from index.ts for
2
+ // banner and thread-inspection interactions. POSTs directly from the browser
4
3
  // to the CopilotKit telemetry sink at `telemetry.copilotkit.ai/ingest`,
5
4
  // where a Lambda fan-out forwards events to PostHog / Reo / Scarf.
6
5
  //
@@ -21,16 +20,24 @@ import {
21
20
  hasTelemetryDisclosureBeenShown,
22
21
  isTelemetryOptedOut,
23
22
  markTelemetryDisclosureShown,
24
- } from "./persistence";
23
+ } from "./persistence.js";
24
+ import packageJson from "../../package.json" with { type: "json" };
25
25
 
26
26
  // V1 funnel events. Namespaced `oss.inspector.*` so the lambda's
27
- // event-type allowlist (oss-path-to-production) can gate them
28
- // server-side. Adding a new event here requires a corresponding
29
- // allowlist entry on the lambda or events will be rejected.
27
+ // owned-prefix gate (oss-path-to-production) can accept them server-side
28
+ // without a per-event sink deploy.
30
29
  export const TELEMETRY_EVENTS = {
31
30
  bannerViewed: "oss.inspector.banner_viewed",
32
31
  bannerClicked: "oss.inspector.banner_clicked",
33
32
  threadsTabClicked: "oss.inspector.threads_tab_clicked",
33
+ threadsLockedViewed: "oss.inspector.threads_locked_viewed",
34
+ threadsIntelligenceSignupClicked:
35
+ "oss.inspector.threads_intelligence_signup_clicked",
36
+ threadsTalkToEngineerClicked:
37
+ "oss.inspector.threads_talk_to_engineer_clicked",
38
+ talkToEngineerClicked: "oss.inspector.talk_to_engineer_clicked",
39
+ threadsEmptyEnabledViewed: "oss.inspector.threads_empty_enabled_viewed",
40
+ threadsEnabledViewed: "oss.inspector.threads_enabled_viewed",
34
41
  } as const;
35
42
 
36
43
  export type TelemetryEvent =
@@ -47,11 +54,63 @@ export const TELEMETRY_INGEST_URL = "https://telemetry.copilotkit.ai/ingest";
47
54
  export const TELEMETRY_DOCS_URL = "https://docs.copilotkit.ai/telemetry";
48
55
 
49
56
  const PACKAGE_NAME = "@copilotkit/web-inspector";
57
+ const PACKAGE_VERSION = packageJson.version;
50
58
 
51
59
  // 3-second cap so a slow gateway can't hang the host app. Matches the
52
60
  // runtime's existing scarf-client convention.
53
61
  const FETCH_TIMEOUT_MS = 3000;
54
62
 
63
+ function isThreadsTelemetryEvent(event: TelemetryEvent): boolean {
64
+ return (
65
+ event === TELEMETRY_EVENTS.threadsTabClicked ||
66
+ event === TELEMETRY_EVENTS.threadsLockedViewed ||
67
+ event === TELEMETRY_EVENTS.threadsIntelligenceSignupClicked ||
68
+ event === TELEMETRY_EVENTS.threadsTalkToEngineerClicked ||
69
+ event === TELEMETRY_EVENTS.talkToEngineerClicked ||
70
+ event === TELEMETRY_EVENTS.threadsEmptyEnabledViewed ||
71
+ event === TELEMETRY_EVENTS.threadsEnabledViewed
72
+ );
73
+ }
74
+
75
+ export type RuntimeUrlType =
76
+ | "missing"
77
+ | "relative"
78
+ | "localhost"
79
+ | "same_origin"
80
+ | "remote"
81
+ | "invalid";
82
+
83
+ export function getRuntimeUrlType(
84
+ runtimeUrl: string | undefined,
85
+ ): RuntimeUrlType {
86
+ if (!runtimeUrl) return "missing";
87
+ if (runtimeUrl.startsWith("/") && !runtimeUrl.startsWith("//")) {
88
+ return "relative";
89
+ }
90
+
91
+ try {
92
+ const baseHref =
93
+ typeof window !== "undefined"
94
+ ? window.location.href
95
+ : "https://copilotkit.ai";
96
+ const url = new URL(runtimeUrl, baseHref);
97
+ const baseUrl = new URL(baseHref);
98
+ const hostname = url.hostname.toLowerCase();
99
+
100
+ if (
101
+ hostname === "localhost" ||
102
+ hostname === "127.0.0.1" ||
103
+ hostname === "[::1]"
104
+ ) {
105
+ return "localhost";
106
+ }
107
+
108
+ return url.origin === baseUrl.origin ? "same_origin" : "remote";
109
+ } catch {
110
+ return "invalid";
111
+ }
112
+ }
113
+
55
114
  /**
56
115
  * Fire-and-forget telemetry send. Returns synchronously; the network
57
116
  * call is dispatched in the background and any failure is swallowed.
@@ -64,16 +123,34 @@ export function track(
64
123
  event: TelemetryEvent,
65
124
  properties: Record<string, unknown> = {},
66
125
  ): void {
126
+ if (isTelemetryOptedOut()) return;
127
+
67
128
  const distinctId = getOrCreateTelemetryDistinctId();
68
- const body = JSON.stringify({
69
- event,
70
- properties: {
71
- ...properties,
72
- distinct_id: distinctId,
73
- },
74
- package: { name: PACKAGE_NAME },
75
- ts: Math.floor(Date.now() / 1000),
76
- });
129
+ const threadsProperties = isThreadsTelemetryEvent(event)
130
+ ? {
131
+ package_name: PACKAGE_NAME,
132
+ package_version: PACKAGE_VERSION,
133
+ inspector_distinct_id: distinctId,
134
+ }
135
+ : {};
136
+ let body: string;
137
+ try {
138
+ body = JSON.stringify({
139
+ event,
140
+ properties: {
141
+ ...properties,
142
+ ...threadsProperties,
143
+ distinct_id: distinctId,
144
+ },
145
+ package: {
146
+ name: PACKAGE_NAME,
147
+ ...(isThreadsTelemetryEvent(event) ? { version: PACKAGE_VERSION } : {}),
148
+ },
149
+ ts: Math.floor(Date.now() / 1000),
150
+ });
151
+ } catch {
152
+ return;
153
+ }
77
154
 
78
155
  void postBestEffort(TELEMETRY_INGEST_URL, body, distinctId);
79
156
  }
@@ -97,8 +174,75 @@ export function trackBannerClicked(props: {
97
174
  track(TELEMETRY_EVENTS.bannerClicked, props);
98
175
  }
99
176
 
100
- export function trackThreadsTabClicked(): void {
101
- track(TELEMETRY_EVENTS.threadsTabClicked);
177
+ export type InspectorThreadTelemetryProps = {
178
+ package_name?: typeof PACKAGE_NAME;
179
+ package_version?: string;
180
+ inspector_distinct_id?: string;
181
+ posthog_distinct_id?: string;
182
+ intelligence_status?:
183
+ | "intelligence_not_enabled"
184
+ | "intelligence_enabled"
185
+ | "unknown";
186
+ thread_service_status?: "unavailable" | "available" | "unknown" | "error";
187
+ license_status?:
188
+ | "valid"
189
+ | "none"
190
+ | "expired"
191
+ | "expiring"
192
+ | "invalid"
193
+ | "unknown";
194
+ runtime_mode?: "sse" | "intelligence";
195
+ runtime_url_type?: RuntimeUrlType;
196
+ cta_surface?:
197
+ | "threads_locked"
198
+ | "threads_header"
199
+ | "threads_empty"
200
+ | "threads_populated";
201
+ cta?: "signup" | "talk_to_engineer";
202
+ telemetry_disabled?: boolean;
203
+ thread_count?: number;
204
+ };
205
+
206
+ export function trackThreadsTabClicked(
207
+ props: InspectorThreadTelemetryProps = {},
208
+ ): void {
209
+ track(TELEMETRY_EVENTS.threadsTabClicked, props);
210
+ }
211
+
212
+ export function trackThreadsLockedViewed(
213
+ props: InspectorThreadTelemetryProps,
214
+ ): void {
215
+ track(TELEMETRY_EVENTS.threadsLockedViewed, props);
216
+ }
217
+
218
+ export function trackThreadsIntelligenceSignupClicked(
219
+ props: InspectorThreadTelemetryProps,
220
+ ): void {
221
+ track(TELEMETRY_EVENTS.threadsIntelligenceSignupClicked, props);
222
+ }
223
+
224
+ export function trackThreadsTalkToEngineerClicked(
225
+ props: InspectorThreadTelemetryProps,
226
+ ): void {
227
+ track(TELEMETRY_EVENTS.threadsTalkToEngineerClicked, props);
228
+ }
229
+
230
+ export function trackTalkToEngineerClicked(
231
+ props: InspectorThreadTelemetryProps,
232
+ ): void {
233
+ track(TELEMETRY_EVENTS.talkToEngineerClicked, props);
234
+ }
235
+
236
+ export function trackThreadsEmptyEnabledViewed(
237
+ props: InspectorThreadTelemetryProps,
238
+ ): void {
239
+ track(TELEMETRY_EVENTS.threadsEmptyEnabledViewed, props);
240
+ }
241
+
242
+ export function trackThreadsEnabledViewed(
243
+ props: InspectorThreadTelemetryProps,
244
+ ): void {
245
+ track(TELEMETRY_EVENTS.threadsEnabledViewed, props);
102
246
  }
103
247
 
104
248
  /**