Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 61 additions & 1 deletion packages/docs/src/analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,11 @@ describe("analytics", () => {
afterEach(() => {
vi.restoreAllMocks();
delete process.env.NEXT_PUBLIC_DOCS_CLOUD_ANALYTICS_ENABLED;
delete process.env.NEXT_PUBLIC_DOCS_CLOUD_ANALYTICS_ENDPOINT;
delete process.env.NEXT_PUBLIC_DOCS_CLOUD_PROJECT_ID;
delete process.env.NEXT_PUBLIC_DOCS_CLOUD_ANALYTICS_KEY;
delete process.env.DOCS_CLOUD_ANALYTICS_ENABLED;
delete process.env.DOCS_CLOUD_ANALYTICS_ENDPOINT;
delete process.env.DOCS_CLOUD_PROJECT_ID;
delete process.env.DOCS_CLOUD_ANALYTICS_KEY;
});
Expand Down Expand Up @@ -311,7 +313,65 @@ describe("analytics", () => {
});
});

it("no-ops Docs Cloud analytics events when endpoint or project id is missing", async () => {
it("posts Docs Cloud analytics events to the public endpoint env when provided", async () => {
process.env.NEXT_PUBLIC_DOCS_CLOUD_ANALYTICS_ENDPOINT =
"https://docs-cloud.example.com/api/analytics/events";

const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise<Response>>(
async () => new Response(null, { status: 202 }),
);
vi.stubGlobal("fetch", fetchMock);

await emitDocsAnalyticsEvent(
createDocsCloudAnalytics({
projectId: "project_endpoint",
console: false,
}),
{
type: "page_view",
source: "client",
},
);

expect(fetchMock).toHaveBeenCalledWith(
"https://docs-cloud.example.com/api/analytics/events",
expect.objectContaining({
method: "POST",
}),
);
});

it("falls back to public env when explicit Docs Cloud options were stripped in the client", async () => {
process.env.NEXT_PUBLIC_DOCS_CLOUD_PROJECT_ID = "project_public";
process.env.NEXT_PUBLIC_DOCS_CLOUD_ANALYTICS_ENDPOINT =
"https://docs-cloud.example.com/api/analytics/events";

const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise<Response>>(
async () => new Response(null, { status: 202 }),
);
vi.stubGlobal("fetch", fetchMock);

await emitDocsAnalyticsEvent(
createDocsCloudAnalytics({
console: false,
projectId: undefined,
}),
{
type: "page_view",
source: "client",
},
);

expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"https://docs-cloud.example.com/api/analytics/events",
expect.objectContaining({
body: expect.stringContaining('"projectId":"project_public"'),
}),
);
});

it("no-ops Docs Cloud analytics events when project id is missing", async () => {
const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise<Response>>(
async () => new Response(null, { status: 202 }),
);
Expand Down
76 changes: 59 additions & 17 deletions packages/docs/src/cloud-analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,50 @@ import type { DocsAnalyticsConfig, DocsAnalyticsEvent } from "./types.js";
export interface DocsCloudAnalyticsOptions {
enabled?: boolean;
console?: DocsAnalyticsConfig["console"];
endpoint?: string;
includeInputs?: boolean;
projectId?: string;
apiKey?: string;
}

const DOCS_CLOUD_ANALYTICS_OPTIONS = Symbol.for("@farming-labs/docs/cloud-analytics");
const DOCS_CLOUD_ANALYTICS_ENDPOINT = "https://docs-app.farming-labs.dev/api/analytics/events";
const DEFAULT_DOCS_CLOUD_ANALYTICS_ENDPOINT =
"https://docs-app.farming-labs.dev/api/analytics/events";

type DocsAnalyticsConfigWithCloud = DocsAnalyticsConfig & {
[DOCS_CLOUD_ANALYTICS_OPTIONS]?: DocsCloudAnalyticsOptions;
};

function normalizeRuntimeEnvValue(value: string | undefined): string | undefined {
const normalized = value?.trim();
return normalized ? normalized : undefined;
}

function readRuntimeEnv(name: string): string | undefined {
if (
typeof process !== "undefined" &&
process.env &&
typeof process.env[name] === "string" &&
process.env[name]!.trim().length > 0
) {
return process.env[name]!.trim();
if (typeof process === "undefined" || !process.env) {
return undefined;
}

return undefined;
switch (name) {
case "NEXT_PUBLIC_DOCS_CLOUD_PROJECT_ID":
return normalizeRuntimeEnvValue(process.env.NEXT_PUBLIC_DOCS_CLOUD_PROJECT_ID);
case "DOCS_CLOUD_PROJECT_ID":
return normalizeRuntimeEnvValue(process.env.DOCS_CLOUD_PROJECT_ID);
case "NEXT_PUBLIC_DOCS_CLOUD_ANALYTICS_KEY":
return normalizeRuntimeEnvValue(process.env.NEXT_PUBLIC_DOCS_CLOUD_ANALYTICS_KEY);
case "DOCS_CLOUD_ANALYTICS_KEY":
return normalizeRuntimeEnvValue(process.env.DOCS_CLOUD_ANALYTICS_KEY);
case "NEXT_PUBLIC_DOCS_CLOUD_ANALYTICS_ENABLED":
return normalizeRuntimeEnvValue(process.env.NEXT_PUBLIC_DOCS_CLOUD_ANALYTICS_ENABLED);
case "DOCS_CLOUD_ANALYTICS_ENABLED":
return normalizeRuntimeEnvValue(process.env.DOCS_CLOUD_ANALYTICS_ENABLED);
case "NEXT_PUBLIC_DOCS_CLOUD_ANALYTICS_ENDPOINT":
return normalizeRuntimeEnvValue(process.env.NEXT_PUBLIC_DOCS_CLOUD_ANALYTICS_ENDPOINT);
case "DOCS_CLOUD_ANALYTICS_ENDPOINT":
return normalizeRuntimeEnvValue(process.env.DOCS_CLOUD_ANALYTICS_ENDPOINT);
default:
return undefined;
}
}

function isFalsyEnv(value: string | undefined): boolean {
Expand All @@ -39,13 +60,6 @@ function isFalsyEnv(value: string | undefined): boolean {
export function resolveDocsCloudAnalyticsOptions(
analytics?: boolean | DocsAnalyticsConfig,
): DocsCloudAnalyticsOptions | null {
if (analytics && typeof analytics === "object") {
const explicit = (analytics as DocsAnalyticsConfigWithCloud)[DOCS_CLOUD_ANALYTICS_OPTIONS];
if (explicit) {
return explicit;
}
}

const projectId =
readRuntimeEnv("NEXT_PUBLIC_DOCS_CLOUD_PROJECT_ID") ?? readRuntimeEnv("DOCS_CLOUD_PROJECT_ID");
const apiKey =
Expand All @@ -54,12 +68,40 @@ export function resolveDocsCloudAnalyticsOptions(
const enabled =
readRuntimeEnv("NEXT_PUBLIC_DOCS_CLOUD_ANALYTICS_ENABLED") ??
readRuntimeEnv("DOCS_CLOUD_ANALYTICS_ENABLED");
const endpoint =
readRuntimeEnv("NEXT_PUBLIC_DOCS_CLOUD_ANALYTICS_ENDPOINT") ??
readRuntimeEnv("DOCS_CLOUD_ANALYTICS_ENDPOINT") ??
DEFAULT_DOCS_CLOUD_ANALYTICS_ENDPOINT;

if (isFalsyEnv(enabled)) {
return null;
}

if (analytics && typeof analytics === "object") {
const explicit = (analytics as DocsAnalyticsConfigWithCloud)[DOCS_CLOUD_ANALYTICS_OPTIONS];

if (explicit) {
const resolvedProjectId = normalizeRuntimeEnvValue(explicit.projectId) ?? projectId;

if (!resolvedProjectId || explicit.enabled === false) {
return null;
}

return {
...explicit,
apiKey: normalizeRuntimeEnvValue(explicit.apiKey) ?? apiKey,
endpoint: normalizeRuntimeEnvValue(explicit.endpoint) ?? endpoint,
projectId: resolvedProjectId,
};
}
}

if (!projectId || isFalsyEnv(enabled)) {
return null;
}

return {
endpoint,
projectId,
apiKey,
};
Expand All @@ -73,7 +115,7 @@ export async function sendDocsCloudAnalyticsEvent(
return;
}

const endpoint = DOCS_CLOUD_ANALYTICS_ENDPOINT;
const endpoint = options.endpoint?.trim() || DEFAULT_DOCS_CLOUD_ANALYTICS_ENDPOINT;
const projectId = options.projectId?.trim();
if (!endpoint || !projectId) {
return;
Expand Down
62 changes: 61 additions & 1 deletion packages/fumadocs/src/client-analytics.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { DocsAnalyticsEvent, DocsAnalyticsEventType } from "@farming-labs/docs";
import { emitClientAnalyticsEvent } from "./client-analytics.js";
import { emitClientAnalyticsEvent, getDocsClientAnalyticsIdentity } from "./client-analytics.js";

const CLIENT_ANALYTICS_EVENTS = [
"page_view",
Expand Down Expand Up @@ -28,6 +28,8 @@ const CLIENT_ANALYTICS_EVENTS = [
interface TestWindow extends Partial<Window> {
__fdAnalytics__?: (event: DocsAnalyticsEvent) => void | Promise<void>;
__fdAnalyticsQueue__?: DocsAnalyticsEvent[];
__fdAnalyticsSessionId__?: string;
__fdAnalyticsVisitorId__?: string;
}

function installClientGlobals(onEvent?: (event: DocsAnalyticsEvent) => void) {
Expand Down Expand Up @@ -103,6 +105,64 @@ describe("client analytics", () => {
});
});

it("adds stable anonymous visitor and session ids to client events", () => {
const events: DocsAnalyticsEvent[] = [];
installClientGlobals((event) => events.push(event));

emitClientAnalyticsEvent({ type: "page_view" });
emitClientAnalyticsEvent({ type: "search_open" });

const firstProperties = events[0]?.properties;
const secondProperties = events[1]?.properties;

expect(firstProperties?.visitorId).toEqual(expect.stringMatching(/^visitor_/));
expect(firstProperties?.sessionId).toEqual(expect.stringMatching(/^session_/));
expect(firstProperties?.anonymousId).toBe(firstProperties?.visitorId);
expect(firstProperties?.visitor).toMatchObject({ id: firstProperties?.visitorId });
expect(firstProperties?.session).toMatchObject({ id: firstProperties?.sessionId });
expect(secondProperties?.visitorId).toBe(firstProperties?.visitorId);
expect(secondProperties?.sessionId).toBe(firstProperties?.sessionId);
});

it("keeps caller-provided visitor identity when supplied", () => {
const events: DocsAnalyticsEvent[] = [];
installClientGlobals((event) => events.push(event));

emitClientAnalyticsEvent({
type: "feedback_select",
properties: {
userId: "user_123",
visitorId: "visitor_custom",
sessionId: "session_custom",
},
});

expect(events[0]?.properties).toMatchObject({
userId: "user_123",
anonymousId: "visitor_custom",
visitorId: "visitor_custom",
sessionId: "session_custom",
visitor: {
id: "visitor_custom",
},
session: {
id: "session_custom",
},
});
});

it("exposes the current browser analytics identity", () => {
installClientGlobals();

const identity = getDocsClientAnalyticsIdentity();

expect(identity).toMatchObject({
anonymousId: expect.stringMatching(/^visitor_/),
visitorId: expect.stringMatching(/^visitor_/),
sessionId: expect.stringMatching(/^session_/),
});
});

it("does not throw when analytics hooks reject or DOM listeners throw", () => {
const { target } = installClientGlobals(() => Promise.reject(new Error("analytics failed")));
target.dispatchEvent = () => {
Expand Down
Loading
Loading