@copilotkit/web-inspector 1.57.1 → 1.57.3
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 +104 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +7 -0
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +7 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +104 -5
- package/dist/index.mjs.map +1 -1
- package/dist/index.umd.js +257 -6
- package/dist/index.umd.js.map +1 -1
- package/dist/lib/persistence.cjs +49 -0
- package/dist/lib/persistence.cjs.map +1 -1
- package/dist/lib/persistence.mjs +46 -1
- package/dist/lib/persistence.mjs.map +1 -1
- package/dist/lib/telemetry.cjs +112 -0
- package/dist/lib/telemetry.cjs.map +1 -0
- package/dist/lib/telemetry.mjs +106 -0
- package/dist/lib/telemetry.mjs.map +1 -0
- package/dist/styles/generated.cjs +1 -1
- package/dist/styles/generated.cjs.map +1 -1
- package/dist/styles/generated.mjs +1 -1
- package/dist/styles/generated.mjs.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +171 -2
- package/src/lib/__tests__/telemetry.test.ts +323 -0
- package/src/lib/persistence.ts +97 -0
- package/src/lib/telemetry.ts +179 -0
- package/src/styles/generated.css +1 -1
- package/vitest.config.ts +1 -0
package/src/index.ts
CHANGED
|
@@ -47,6 +47,15 @@ import {
|
|
|
47
47
|
isValidDockMode,
|
|
48
48
|
} from "./lib/persistence";
|
|
49
49
|
import type { PersistedState } from "./lib/persistence";
|
|
50
|
+
import {
|
|
51
|
+
TELEMETRY_DOCS_URL,
|
|
52
|
+
ensureTelemetryDistinctId,
|
|
53
|
+
getTelemetryDistinctIdForUrl,
|
|
54
|
+
maybeShowDisclosure,
|
|
55
|
+
trackBannerClicked,
|
|
56
|
+
trackBannerViewed,
|
|
57
|
+
trackThreadsTabClicked,
|
|
58
|
+
} from "./lib/telemetry";
|
|
50
59
|
|
|
51
60
|
export const WEB_INSPECTOR_TAG = "cpk-web-inspector" as const;
|
|
52
61
|
|
|
@@ -57,7 +66,8 @@ type MenuKey =
|
|
|
57
66
|
| "agents"
|
|
58
67
|
| "frontend-tools"
|
|
59
68
|
| "agent-context"
|
|
60
|
-
| "threads"
|
|
69
|
+
| "threads"
|
|
70
|
+
| "settings";
|
|
61
71
|
|
|
62
72
|
type MenuItem = {
|
|
63
73
|
key: MenuKey;
|
|
@@ -2408,11 +2418,32 @@ export class WebInspectorElement extends LitElement {
|
|
|
2408
2418
|
private announcementHtml: string | null = null;
|
|
2409
2419
|
private announcementTimestamp: string | null = null;
|
|
2410
2420
|
private announcementPreviewText: string | null = null;
|
|
2421
|
+
// Forward-compat for an optional `cta_label` field on the announcement
|
|
2422
|
+
// CDN payload (e.g. "Try threads", "New feature"). The current schema
|
|
2423
|
+
// ({timestamp, previewText, announcement}) doesn't carry it, so this is
|
|
2424
|
+
// null in production today; we read it defensively in fetchAnnouncement
|
|
2425
|
+
// so a future CDN-side schema bump lights up `cta_label` on banner_clicked
|
|
2426
|
+
// without an inspector release.
|
|
2427
|
+
private announcementCtaLabel: string | null = null;
|
|
2411
2428
|
private hasUnseenAnnouncement = false;
|
|
2412
2429
|
private announcementLoaded = false;
|
|
2413
2430
|
private announcementPromise: Promise<void> | null = null;
|
|
2414
2431
|
private showAnnouncementPreview = true;
|
|
2415
2432
|
private announcementExpanded = false;
|
|
2433
|
+
// Per-instance dedup for `oss.inspector.banner_viewed` so the event fires
|
|
2434
|
+
// at most once per announcement timestamp per inspector mount. Plan calls
|
|
2435
|
+
// for "de-dup per timestamp per session"; instance-scoping is closer
|
|
2436
|
+
// to per-mount than per-tab (sessionStorage), but for the inspector the
|
|
2437
|
+
// distinction is academic — inspector instances rarely outlive the page.
|
|
2438
|
+
private viewedBannerTimestamps: Set<string> = new Set();
|
|
2439
|
+
private pendingBannerViewed: {
|
|
2440
|
+
banner_id: string;
|
|
2441
|
+
cta_label?: string;
|
|
2442
|
+
} | null = null;
|
|
2443
|
+
// Per-instance dedup for `oss.inspector.banner_clicked` (keyed by
|
|
2444
|
+
// `${bannerId}:${cta}`) so copy-button retries and accidental multi-clicks
|
|
2445
|
+
// don't inflate funnel counts beyond one signal per intent type per banner.
|
|
2446
|
+
private clickedBannerIds: Set<string> = new Set();
|
|
2416
2447
|
|
|
2417
2448
|
get core(): CopilotKitCore | null {
|
|
2418
2449
|
return this._core;
|
|
@@ -2603,6 +2634,11 @@ export class WebInspectorElement extends LitElement {
|
|
|
2603
2634
|
onRuntimeConnectionStatusChanged: ({ status }) => {
|
|
2604
2635
|
this.runtimeStatus = status;
|
|
2605
2636
|
if (status === "connected") {
|
|
2637
|
+
if (!core.telemetryDisabled) {
|
|
2638
|
+
ensureTelemetryDistinctId();
|
|
2639
|
+
maybeShowDisclosure();
|
|
2640
|
+
}
|
|
2641
|
+
this.flushPendingBannerViewed();
|
|
2606
2642
|
for (const agentId of this._ownedThreadStores.keys()) {
|
|
2607
2643
|
this.refreshOwnedThreadStore(agentId);
|
|
2608
2644
|
}
|
|
@@ -3913,6 +3949,11 @@ ${argsString}</pre
|
|
|
3913
3949
|
color: #010507;
|
|
3914
3950
|
font-weight: 600;
|
|
3915
3951
|
}
|
|
3952
|
+
.cpk-tab-icon {
|
|
3953
|
+
display: inline-flex;
|
|
3954
|
+
flex-shrink: 0;
|
|
3955
|
+
align-items: center;
|
|
3956
|
+
}
|
|
3916
3957
|
.cpk-tab-active .cpk-tab-icon {
|
|
3917
3958
|
color: #757cf2;
|
|
3918
3959
|
}
|
|
@@ -4337,6 +4378,24 @@ ${argsString}</pre
|
|
|
4337
4378
|
<div class="min-w-[160px] max-w-xs">${agentSelector}</div>
|
|
4338
4379
|
<div class="flex items-center gap-1">
|
|
4339
4380
|
${this.renderDockControls()}
|
|
4381
|
+
<button
|
|
4382
|
+
class="flex h-8 w-8 items-center justify-center rounded-md transition hover:bg-gray-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-400 ${
|
|
4383
|
+
this.selectedMenu === "settings"
|
|
4384
|
+
? "bg-gray-100 text-gray-700"
|
|
4385
|
+
: "text-gray-400 hover:text-gray-600"
|
|
4386
|
+
}"
|
|
4387
|
+
type="button"
|
|
4388
|
+
aria-label="Settings"
|
|
4389
|
+
aria-pressed=${this.selectedMenu === "settings"}
|
|
4390
|
+
@click=${() =>
|
|
4391
|
+
this.handleMenuSelect(
|
|
4392
|
+
this.selectedMenu === "settings"
|
|
4393
|
+
? "ag-ui-events"
|
|
4394
|
+
: "settings",
|
|
4395
|
+
)}
|
|
4396
|
+
>
|
|
4397
|
+
${this.renderIcon("Settings")}
|
|
4398
|
+
</button>
|
|
4340
4399
|
<button
|
|
4341
4400
|
class="flex h-8 w-8 items-center justify-center rounded-md text-gray-400 transition hover:bg-gray-100 hover:text-gray-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-400"
|
|
4342
4401
|
type="button"
|
|
@@ -5601,9 +5660,64 @@ ${argsString}</pre
|
|
|
5601
5660
|
return this.renderThreadsView();
|
|
5602
5661
|
}
|
|
5603
5662
|
|
|
5663
|
+
if (this.selectedMenu === "settings") {
|
|
5664
|
+
return this.renderSettingsPanel();
|
|
5665
|
+
}
|
|
5666
|
+
|
|
5604
5667
|
return nothing;
|
|
5605
5668
|
}
|
|
5606
5669
|
|
|
5670
|
+
private renderSettingsPanel() {
|
|
5671
|
+
const optedOut = this.core?.telemetryDisabled ?? false;
|
|
5672
|
+
return html`
|
|
5673
|
+
<div class="flex h-full flex-col overflow-hidden">
|
|
5674
|
+
<div class="overflow-auto p-4">
|
|
5675
|
+
<div class="space-y-3">
|
|
5676
|
+
<h2 class="text-sm font-semibold text-slate-900">Settings</h2>
|
|
5677
|
+
|
|
5678
|
+
<div class="space-y-2">
|
|
5679
|
+
<h3 class="text-sm text-slate-500">Privacy</h3>
|
|
5680
|
+
<div class="rounded-lg border border-slate-200 bg-white p-4 space-y-3">
|
|
5681
|
+
<p class="text-sm text-gray-600 flex items-start gap-2">
|
|
5682
|
+
<span>${optedOut ? "❌" : "✅"}</span>
|
|
5683
|
+
<span>
|
|
5684
|
+
${
|
|
5685
|
+
optedOut
|
|
5686
|
+
? "You have disabled anonymous interaction data collection."
|
|
5687
|
+
: "CopilotKit is currently collecting anonymous interaction data from the inspector so we know which features people use. We never collect message content, agent state, prompts, or completions."
|
|
5688
|
+
}
|
|
5689
|
+
</span>
|
|
5690
|
+
</p>
|
|
5691
|
+
<a
|
|
5692
|
+
class="inline-flex items-center gap-1 text-sm text-slate-700 underline hover:text-slate-900"
|
|
5693
|
+
href=${TELEMETRY_DOCS_URL}
|
|
5694
|
+
target="_blank"
|
|
5695
|
+
rel="noopener"
|
|
5696
|
+
>Learn more →</a>
|
|
5697
|
+
</div>
|
|
5698
|
+
</div>
|
|
5699
|
+
</div>
|
|
5700
|
+
</div>
|
|
5701
|
+
</div>
|
|
5702
|
+
`;
|
|
5703
|
+
}
|
|
5704
|
+
|
|
5705
|
+
// Fires `banner_clicked` at most once per `${bannerId}:${cta}` per mount so
|
|
5706
|
+
// copy-button retries and accidental multi-clicks don't inflate funnel counts.
|
|
5707
|
+
private trackBannerClickedOnce(opts: { cta: "body" | "dismiss" }): void {
|
|
5708
|
+
if (this.core?.telemetryDisabled) return;
|
|
5709
|
+
const id = this.announcementTimestamp;
|
|
5710
|
+
if (!id) return;
|
|
5711
|
+
const key = `${id}:${opts.cta}`;
|
|
5712
|
+
if (this.clickedBannerIds.has(key)) return;
|
|
5713
|
+
this.clickedBannerIds.add(key);
|
|
5714
|
+
trackBannerClicked({
|
|
5715
|
+
banner_id: id,
|
|
5716
|
+
cta: opts.cta,
|
|
5717
|
+
cta_label: this.announcementCtaLabel ?? undefined,
|
|
5718
|
+
});
|
|
5719
|
+
}
|
|
5720
|
+
|
|
5607
5721
|
private handleThreadDividerPointerDown = (event: PointerEvent) => {
|
|
5608
5722
|
this.threadDividerResizing = true;
|
|
5609
5723
|
this.threadDividerPointerId = event.pointerId;
|
|
@@ -6371,7 +6485,10 @@ ${prettyEvent}</pre
|
|
|
6371
6485
|
}
|
|
6372
6486
|
|
|
6373
6487
|
private handleMenuSelect(key: MenuKey): void {
|
|
6374
|
-
if (
|
|
6488
|
+
if (
|
|
6489
|
+
key !== "settings" &&
|
|
6490
|
+
!this.menuItems.some((item) => item.key === key)
|
|
6491
|
+
) {
|
|
6375
6492
|
return;
|
|
6376
6493
|
}
|
|
6377
6494
|
|
|
@@ -6410,6 +6527,9 @@ ${prettyEvent}</pre
|
|
|
6410
6527
|
}
|
|
6411
6528
|
|
|
6412
6529
|
if (key === "threads") {
|
|
6530
|
+
if (this.selectedMenu !== "threads" && !this.core?.telemetryDisabled) {
|
|
6531
|
+
trackThreadsTabClicked();
|
|
6532
|
+
}
|
|
6413
6533
|
this.autoSelectLatestThread();
|
|
6414
6534
|
}
|
|
6415
6535
|
|
|
@@ -7286,6 +7406,16 @@ ${prettyEvent}</pre
|
|
|
7286
7406
|
</div>`;
|
|
7287
7407
|
}
|
|
7288
7408
|
|
|
7409
|
+
private flushPendingBannerViewed(): void {
|
|
7410
|
+
if (!this.pendingBannerViewed || this.core?.telemetryDisabled) {
|
|
7411
|
+
this.pendingBannerViewed = null;
|
|
7412
|
+
return;
|
|
7413
|
+
}
|
|
7414
|
+
if (this.runtimeStatus !== "connected") return;
|
|
7415
|
+
trackBannerViewed(this.pendingBannerViewed);
|
|
7416
|
+
this.pendingBannerViewed = null;
|
|
7417
|
+
}
|
|
7418
|
+
|
|
7289
7419
|
private ensureAnnouncementLoading(): void {
|
|
7290
7420
|
if (
|
|
7291
7421
|
this.announcementPromise ||
|
|
@@ -7326,6 +7456,7 @@ ${prettyEvent}</pre
|
|
|
7326
7456
|
}
|
|
7327
7457
|
|
|
7328
7458
|
private handleDismissAnnouncement = (): void => {
|
|
7459
|
+
this.trackBannerClickedOnce({ cta: "dismiss" });
|
|
7329
7460
|
this.markAnnouncementSeen();
|
|
7330
7461
|
};
|
|
7331
7462
|
|
|
@@ -7340,6 +7471,7 @@ ${prettyEvent}</pre
|
|
|
7340
7471
|
timestamp?: unknown;
|
|
7341
7472
|
previewText?: unknown;
|
|
7342
7473
|
announcement?: unknown;
|
|
7474
|
+
cta_label?: unknown;
|
|
7343
7475
|
};
|
|
7344
7476
|
|
|
7345
7477
|
const timestamp =
|
|
@@ -7348,6 +7480,8 @@ ${prettyEvent}</pre
|
|
|
7348
7480
|
typeof data?.previewText === "string" ? data.previewText : null;
|
|
7349
7481
|
const markdown =
|
|
7350
7482
|
typeof data?.announcement === "string" ? data.announcement : null;
|
|
7483
|
+
const ctaLabel =
|
|
7484
|
+
typeof data?.cta_label === "string" ? data.cta_label : null;
|
|
7351
7485
|
|
|
7352
7486
|
if (!timestamp || !markdown) {
|
|
7353
7487
|
throw new Error("Malformed announcement payload");
|
|
@@ -7357,6 +7491,7 @@ ${prettyEvent}</pre
|
|
|
7357
7491
|
|
|
7358
7492
|
this.announcementTimestamp = timestamp;
|
|
7359
7493
|
this.announcementPreviewText = previewText ?? "";
|
|
7494
|
+
this.announcementCtaLabel = ctaLabel;
|
|
7360
7495
|
this.hasUnseenAnnouncement =
|
|
7361
7496
|
(!storedTimestamp || storedTimestamp !== timestamp) &&
|
|
7362
7497
|
!!this.announcementPreviewText;
|
|
@@ -7364,6 +7499,22 @@ ${prettyEvent}</pre
|
|
|
7364
7499
|
this.announcementHtml = await this.convertMarkdownToHtml(markdown);
|
|
7365
7500
|
this.announcementLoaded = true;
|
|
7366
7501
|
|
|
7502
|
+
// banner_viewed: gate on actual visibility and per-mount dedup.
|
|
7503
|
+
// Store as pending rather than firing immediately — telemetryDisabled
|
|
7504
|
+
// may not be known yet if /info hasn't returned. Flushed in
|
|
7505
|
+
// onRuntimeConnectionStatusChanged once the handshake completes.
|
|
7506
|
+
if (
|
|
7507
|
+
this.hasUnseenAnnouncement &&
|
|
7508
|
+
!this.viewedBannerTimestamps.has(timestamp)
|
|
7509
|
+
) {
|
|
7510
|
+
this.viewedBannerTimestamps.add(timestamp);
|
|
7511
|
+
this.pendingBannerViewed = {
|
|
7512
|
+
banner_id: timestamp,
|
|
7513
|
+
cta_label: ctaLabel ?? undefined,
|
|
7514
|
+
};
|
|
7515
|
+
this.flushPendingBannerViewed();
|
|
7516
|
+
}
|
|
7517
|
+
|
|
7367
7518
|
this.requestUpdate();
|
|
7368
7519
|
} catch (error) {
|
|
7369
7520
|
// Swallowing here would hide non-network failures (malformed JSON, the
|
|
@@ -7422,6 +7573,10 @@ ${prettyEvent}</pre
|
|
|
7422
7573
|
const target = event.target instanceof HTMLElement ? event.target : null;
|
|
7423
7574
|
const button = target?.closest(".announcement-code__copy");
|
|
7424
7575
|
if (!(button instanceof HTMLButtonElement)) {
|
|
7576
|
+
// banner_clicked fires once per banner per cta-type per mount. Dedup
|
|
7577
|
+
// prevents accidental multi-clicks from inflating funnel counts beyond
|
|
7578
|
+
// one "body" signal and one "dismiss" signal per banner.
|
|
7579
|
+
this.trackBannerClickedOnce({ cta: "body" });
|
|
7425
7580
|
return;
|
|
7426
7581
|
}
|
|
7427
7582
|
event.preventDefault();
|
|
@@ -7466,6 +7621,20 @@ ${prettyEvent}</pre
|
|
|
7466
7621
|
if (!url.searchParams.has("ref")) {
|
|
7467
7622
|
url.searchParams.append("ref", "cpk-inspector");
|
|
7468
7623
|
}
|
|
7624
|
+
// Propagate the inspector's anonymous distinct-ID so the website /
|
|
7625
|
+
// Ops API can call posthog.alias(...) on signup-flow landing and
|
|
7626
|
+
// close the banner_viewed → banner_clicked → signup_attributed
|
|
7627
|
+
// funnel. Returns null when the user has opted out, so opt-out
|
|
7628
|
+
// suppresses cross-domain ID leaks too.
|
|
7629
|
+
if (
|
|
7630
|
+
!url.searchParams.has("posthog_distinct_id") &&
|
|
7631
|
+
!this.core?.telemetryDisabled
|
|
7632
|
+
) {
|
|
7633
|
+
const distinctId = getTelemetryDistinctIdForUrl();
|
|
7634
|
+
if (distinctId) {
|
|
7635
|
+
url.searchParams.append("posthog_distinct_id", distinctId);
|
|
7636
|
+
}
|
|
7637
|
+
}
|
|
7469
7638
|
return url.toString();
|
|
7470
7639
|
} catch {
|
|
7471
7640
|
return href;
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { MockInstance } from "vitest";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
TELEMETRY_DOCS_URL,
|
|
6
|
+
TELEMETRY_EVENTS,
|
|
7
|
+
TELEMETRY_INGEST_URL,
|
|
8
|
+
getTelemetryDistinctIdForUrl,
|
|
9
|
+
maybeShowDisclosure,
|
|
10
|
+
track,
|
|
11
|
+
trackBannerClicked,
|
|
12
|
+
trackBannerViewed,
|
|
13
|
+
trackThreadsTabClicked,
|
|
14
|
+
} from "../telemetry";
|
|
15
|
+
import {
|
|
16
|
+
_resetTelemetryPersistenceForTesting,
|
|
17
|
+
getOrCreateTelemetryDistinctId,
|
|
18
|
+
hasTelemetryDisclosureBeenShown,
|
|
19
|
+
isTelemetryOptedOut,
|
|
20
|
+
markTelemetryDisclosureShown,
|
|
21
|
+
setTelemetryOptOut,
|
|
22
|
+
} from "../persistence";
|
|
23
|
+
|
|
24
|
+
// The wrapper short-circuits before any network call when opted out, but
|
|
25
|
+
// for the network-touching cases we mock fetch globally so we can read
|
|
26
|
+
// what would have been sent without making real HTTP requests.
|
|
27
|
+
let fetchMock: MockInstance<typeof fetch>;
|
|
28
|
+
let consoleInfoSpy: MockInstance<typeof console.info>;
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
// Each test starts from a clean localStorage so distinct-ID + opt-out
|
|
32
|
+
// + disclosure-shown flags don't leak across cases.
|
|
33
|
+
window.localStorage.clear();
|
|
34
|
+
_resetTelemetryPersistenceForTesting();
|
|
35
|
+
|
|
36
|
+
// The wrapper POSTs via globalThis.fetch with a 3s AbortController
|
|
37
|
+
// timeout. Stub it with a resolving Response so happy-path sends
|
|
38
|
+
// complete synchronously (the wrapper does `void` on the promise).
|
|
39
|
+
fetchMock = vi
|
|
40
|
+
.spyOn(globalThis, "fetch")
|
|
41
|
+
.mockResolvedValue(new Response(null, { status: 204 }));
|
|
42
|
+
|
|
43
|
+
consoleInfoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
vi.restoreAllMocks();
|
|
48
|
+
vi.unstubAllGlobals();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// ─── Wire body shape ────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
describe("track()", () => {
|
|
54
|
+
it("posts to telemetry.copilotkit.ai/ingest with confirmed IngestPayload shape", async () => {
|
|
55
|
+
track(TELEMETRY_EVENTS.bannerViewed, {
|
|
56
|
+
banner_id: "2025-05-01T00:00:00Z",
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await Promise.resolve();
|
|
60
|
+
|
|
61
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
62
|
+
const [url, init] = fetchMock.mock.calls[0]!;
|
|
63
|
+
expect(url).toBe(TELEMETRY_INGEST_URL);
|
|
64
|
+
expect(init?.method).toBe("POST");
|
|
65
|
+
expect((init?.headers as Record<string, string>)["Content-Type"]).toBe(
|
|
66
|
+
"application/json",
|
|
67
|
+
);
|
|
68
|
+
expect(
|
|
69
|
+
(init?.headers as Record<string, string>)["X-CopilotKit-Telemetry-Id"],
|
|
70
|
+
).toMatch(/^[0-9a-f-]{36}$/);
|
|
71
|
+
|
|
72
|
+
// Ben confirmed shape (telemetry-sink-ingest/index.ts:127-134):
|
|
73
|
+
// package is a top-level object { name, version? }, NOT inside properties.
|
|
74
|
+
const body = JSON.parse((init?.body as string) ?? "{}") as {
|
|
75
|
+
event: string;
|
|
76
|
+
properties: Record<string, unknown>;
|
|
77
|
+
package: { name: string; version?: string };
|
|
78
|
+
ts: number;
|
|
79
|
+
};
|
|
80
|
+
expect(body.event).toBe("oss.inspector.banner_viewed");
|
|
81
|
+
expect(body.properties.banner_id).toBe("2025-05-01T00:00:00Z");
|
|
82
|
+
expect(typeof body.properties.distinct_id).toBe("string");
|
|
83
|
+
// package is top-level object, not a string inside properties
|
|
84
|
+
expect(body.package).toEqual({ name: "@copilotkit/web-inspector" });
|
|
85
|
+
expect(body.properties).not.toHaveProperty("package");
|
|
86
|
+
expect(typeof body.ts).toBe("number");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("sends regardless of localStorage opt-out — callers gate on core.telemetryDisabled", async () => {
|
|
90
|
+
setTelemetryOptOut(true);
|
|
91
|
+
expect(isTelemetryOptedOut()).toBe(true);
|
|
92
|
+
|
|
93
|
+
track(TELEMETRY_EVENTS.bannerClicked, {
|
|
94
|
+
banner_id: "x",
|
|
95
|
+
cta: "body",
|
|
96
|
+
});
|
|
97
|
+
await Promise.resolve();
|
|
98
|
+
|
|
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);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("swallows fetch failures (telemetry is best-effort)", async () => {
|
|
105
|
+
fetchMock.mockRejectedValueOnce(new Error("network down"));
|
|
106
|
+
|
|
107
|
+
expect(() => track(TELEMETRY_EVENTS.threadsTabClicked)).not.toThrow();
|
|
108
|
+
|
|
109
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("does not send when fetch is unavailable (SSR / pre-fetch environment)", async () => {
|
|
113
|
+
vi.stubGlobal("fetch", undefined);
|
|
114
|
+
|
|
115
|
+
expect(() =>
|
|
116
|
+
track(TELEMETRY_EVENTS.bannerViewed, { banner_id: "abc" }),
|
|
117
|
+
).not.toThrow();
|
|
118
|
+
|
|
119
|
+
// No fetch call possible — restore happens in afterEach via unstubAllGlobals
|
|
120
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("never includes message content or agent state in the payload", async () => {
|
|
124
|
+
track(TELEMETRY_EVENTS.bannerViewed, { banner_id: "abc" });
|
|
125
|
+
await Promise.resolve();
|
|
126
|
+
|
|
127
|
+
const [, init] = fetchMock.mock.calls[0]!;
|
|
128
|
+
const raw = (init?.body as string) ?? "{}";
|
|
129
|
+
// Forbidden content keys (privacy invariant — never send inspector content)
|
|
130
|
+
expect(raw).not.toMatch(
|
|
131
|
+
/messages|completion|prompt|state_snapshot|agent_state|content|user_id/i,
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ─── Typed per-event helpers ─────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
describe("typed helpers", () => {
|
|
139
|
+
it("trackBannerViewed sends banner_id and optional cta_label", async () => {
|
|
140
|
+
trackBannerViewed({ banner_id: "ts-2025", cta_label: "Try threads" });
|
|
141
|
+
await Promise.resolve();
|
|
142
|
+
const [, init] = fetchMock.mock.calls[0]!;
|
|
143
|
+
const body = JSON.parse((init?.body as string) ?? "{}") as {
|
|
144
|
+
event: string;
|
|
145
|
+
properties: Record<string, unknown>;
|
|
146
|
+
};
|
|
147
|
+
expect(body.event).toBe(TELEMETRY_EVENTS.bannerViewed);
|
|
148
|
+
expect(body.properties.banner_id).toBe("ts-2025");
|
|
149
|
+
expect(body.properties.cta_label).toBe("Try threads");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("trackBannerViewed omits cta_label when undefined (JSON.stringify drops it)", async () => {
|
|
153
|
+
trackBannerViewed({ banner_id: "ts-2025" });
|
|
154
|
+
await Promise.resolve();
|
|
155
|
+
const [, init] = fetchMock.mock.calls[0]!;
|
|
156
|
+
const raw = (init?.body as string) ?? "{}";
|
|
157
|
+
expect(raw).not.toContain("cta_label");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("trackBannerClicked sends banner_id, cta, and optional cta_label", async () => {
|
|
161
|
+
trackBannerClicked({ banner_id: "ts-2025", cta: "body" });
|
|
162
|
+
await Promise.resolve();
|
|
163
|
+
const [, init] = fetchMock.mock.calls[0]!;
|
|
164
|
+
const body = JSON.parse((init?.body as string) ?? "{}") as {
|
|
165
|
+
event: string;
|
|
166
|
+
properties: Record<string, unknown>;
|
|
167
|
+
};
|
|
168
|
+
expect(body.event).toBe(TELEMETRY_EVENTS.bannerClicked);
|
|
169
|
+
expect(body.properties.banner_id).toBe("ts-2025");
|
|
170
|
+
expect(body.properties.cta).toBe("body");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("trackThreadsTabClicked sends no caller-supplied properties", async () => {
|
|
174
|
+
trackThreadsTabClicked();
|
|
175
|
+
await Promise.resolve();
|
|
176
|
+
const [, init] = fetchMock.mock.calls[0]!;
|
|
177
|
+
const body = JSON.parse((init?.body as string) ?? "{}") as {
|
|
178
|
+
event: string;
|
|
179
|
+
properties: Record<string, unknown>;
|
|
180
|
+
};
|
|
181
|
+
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"]);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// ─── Distinct ID lifecycle ───────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
describe("distinct ID lifecycle", () => {
|
|
190
|
+
it("persists across calls within the same session", () => {
|
|
191
|
+
const first = getOrCreateTelemetryDistinctId();
|
|
192
|
+
const second = getOrCreateTelemetryDistinctId();
|
|
193
|
+
expect(first).toBe(second);
|
|
194
|
+
expect(
|
|
195
|
+
window.localStorage.getItem("cpk:inspector:telemetry:distinct_id"),
|
|
196
|
+
).toBe(first);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("generates a UUID-v4-shaped value", () => {
|
|
200
|
+
const id = getOrCreateTelemetryDistinctId();
|
|
201
|
+
expect(id).toMatch(
|
|
202
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("returns a UUID without throwing in SSR (window undefined)", () => {
|
|
207
|
+
vi.stubGlobal("window", undefined);
|
|
208
|
+
expect(() => getOrCreateTelemetryDistinctId()).not.toThrow();
|
|
209
|
+
const id = getOrCreateTelemetryDistinctId();
|
|
210
|
+
expect(id).toMatch(
|
|
211
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
|
212
|
+
);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("returns a UUID without throwing when localStorage.getItem throws", () => {
|
|
216
|
+
vi.spyOn(window.localStorage, "getItem").mockImplementation(() => {
|
|
217
|
+
throw new DOMException("QuotaExceededError");
|
|
218
|
+
});
|
|
219
|
+
expect(() => getOrCreateTelemetryDistinctId()).not.toThrow();
|
|
220
|
+
const id = getOrCreateTelemetryDistinctId();
|
|
221
|
+
expect(id).toMatch(/^[0-9a-f]{8}-/);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("returns the same UUID across calls when localStorage throws (funnel coherence)", () => {
|
|
225
|
+
vi.spyOn(window.localStorage, "getItem").mockImplementation(() => {
|
|
226
|
+
throw new DOMException("QuotaExceededError");
|
|
227
|
+
});
|
|
228
|
+
vi.spyOn(window.localStorage, "setItem").mockImplementation(() => {
|
|
229
|
+
throw new DOMException("QuotaExceededError");
|
|
230
|
+
});
|
|
231
|
+
const first = getOrCreateTelemetryDistinctId();
|
|
232
|
+
const second = getOrCreateTelemetryDistinctId();
|
|
233
|
+
expect(first).toBe(second);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// ─── Persistence error-resilience ───────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
describe("persistence error-resilience", () => {
|
|
240
|
+
it("isTelemetryOptedOut returns false (not disabled) when localStorage throws", () => {
|
|
241
|
+
vi.spyOn(window.localStorage, "getItem").mockImplementation(() => {
|
|
242
|
+
throw new DOMException("SecurityError");
|
|
243
|
+
});
|
|
244
|
+
// Must fail to "not opted out" — if it returned true, all users in
|
|
245
|
+
// restricted-storage contexts would have telemetry silently disabled.
|
|
246
|
+
expect(isTelemetryOptedOut()).toBe(false);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("setTelemetryOptOut does not throw when localStorage.setItem throws", () => {
|
|
250
|
+
vi.spyOn(window.localStorage, "setItem").mockImplementation(() => {
|
|
251
|
+
throw new DOMException("QuotaExceededError");
|
|
252
|
+
});
|
|
253
|
+
expect(() => setTelemetryOptOut(true)).not.toThrow();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("markTelemetryDisclosureShown does not throw when localStorage.setItem throws", () => {
|
|
257
|
+
vi.spyOn(window.localStorage, "setItem").mockImplementation(() => {
|
|
258
|
+
throw new DOMException("QuotaExceededError");
|
|
259
|
+
});
|
|
260
|
+
// Failure means the disclosure fires on every mount instead of once —
|
|
261
|
+
// a UX regression, not a data leak. The important invariant is no throw.
|
|
262
|
+
expect(() => markTelemetryDisclosureShown()).not.toThrow();
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// ─── maybeShowDisclosure() ───────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
describe("maybeShowDisclosure()", () => {
|
|
269
|
+
it("logs once and sets the disclosure-shown flag", () => {
|
|
270
|
+
maybeShowDisclosure();
|
|
271
|
+
expect(consoleInfoSpy).toHaveBeenCalledTimes(1);
|
|
272
|
+
const [message] = consoleInfoSpy.mock.calls[0]!;
|
|
273
|
+
expect(message).toContain(TELEMETRY_DOCS_URL);
|
|
274
|
+
expect(hasTelemetryDisclosureBeenShown()).toBe(true);
|
|
275
|
+
|
|
276
|
+
maybeShowDisclosure();
|
|
277
|
+
// No second log — flag short-circuits.
|
|
278
|
+
expect(consoleInfoSpy).toHaveBeenCalledTimes(1);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("does not log when the user is already opted out", () => {
|
|
282
|
+
setTelemetryOptOut(true);
|
|
283
|
+
|
|
284
|
+
maybeShowDisclosure();
|
|
285
|
+
|
|
286
|
+
expect(consoleInfoSpy).not.toHaveBeenCalled();
|
|
287
|
+
// The flag stays unset so a future opt-in flips back to "first run"
|
|
288
|
+
// behavior — see the wrapper's design comment.
|
|
289
|
+
expect(hasTelemetryDisclosureBeenShown()).toBe(false);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// ─── getTelemetryDistinctIdForUrl() ─────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
describe("getTelemetryDistinctIdForUrl()", () => {
|
|
296
|
+
it("returns the persisted distinct-ID when not opted out", () => {
|
|
297
|
+
const id = getTelemetryDistinctIdForUrl();
|
|
298
|
+
expect(id).not.toBeNull();
|
|
299
|
+
expect(id).toMatch(
|
|
300
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
|
301
|
+
);
|
|
302
|
+
// Same ID across calls — ensures URL propagation matches the ID
|
|
303
|
+
// that goes on event sends.
|
|
304
|
+
expect(getTelemetryDistinctIdForUrl()).toBe(id);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("returns null when the user is opted out (no cross-domain leak)", () => {
|
|
308
|
+
setTelemetryOptOut(true);
|
|
309
|
+
expect(getTelemetryDistinctIdForUrl()).toBeNull();
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// ─── Opt-out round-trip ──────────────────────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
describe("opt-out round-trip", () => {
|
|
316
|
+
it("setTelemetryOptOut(true) → isTelemetryOptedOut() is true", () => {
|
|
317
|
+
expect(isTelemetryOptedOut()).toBe(false);
|
|
318
|
+
setTelemetryOptOut(true);
|
|
319
|
+
expect(isTelemetryOptedOut()).toBe(true);
|
|
320
|
+
setTelemetryOptOut(false);
|
|
321
|
+
expect(isTelemetryOptedOut()).toBe(false);
|
|
322
|
+
});
|
|
323
|
+
});
|