diff --git a/.changeset/curly-news-do.md b/.changeset/curly-news-do.md new file mode 100644 index 000000000..5b986c50a --- /dev/null +++ b/.changeset/curly-news-do.md @@ -0,0 +1,5 @@ +--- +'@gitbook/react-openapi': minor +--- + +Add an optional client context to get a callback called when the Scalar client is opened for a block. diff --git a/.changeset/twelve-doors-film.md b/.changeset/twelve-doors-film.md new file mode 100644 index 000000000..eddf6c140 --- /dev/null +++ b/.changeset/twelve-doors-film.md @@ -0,0 +1,5 @@ +--- +'gitbook': minor +--- + +Track an event into site insights when visitor is opening the Scalar API client. diff --git a/bun.lockb b/bun.lockb index 246d1c0c9..e5dab3c6f 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json index 478e3fe59..7bd6bab3c 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -16,7 +16,7 @@ "clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static" }, "dependencies": { - "@gitbook/api": "^0.84.0", + "@gitbook/api": "^0.85.0", "@gitbook/cache-do": "workspace:*", "@gitbook/emoji-codepoints": "workspace:*", "@gitbook/icons": "workspace:*", diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/OpenAPI.tsx b/packages/gitbook/src/components/DocumentView/OpenAPI/OpenAPI.tsx index e1a387d58..60bd5624d 100644 --- a/packages/gitbook/src/components/DocumentView/OpenAPI/OpenAPI.tsx +++ b/packages/gitbook/src/components/DocumentView/OpenAPI/OpenAPI.tsx @@ -1,4 +1,4 @@ -import { DocumentBlockSwagger } from '@gitbook/api'; +import { DocumentBlockOpenAPI } from '@gitbook/api'; import { Icon } from '@gitbook/icons'; import { OpenAPIOperation } from '@gitbook/react-openapi'; import React from 'react'; @@ -16,7 +16,7 @@ import './scalar.css'; /** * Render an OpenAPI block. */ -export async function OpenAPI(props: BlockProps) { +export async function OpenAPI(props: BlockProps) { const { block, style } = props; return (
@@ -27,7 +27,7 @@ export async function OpenAPI(props: BlockProps) { ); } -async function OpenAPIBody(props: BlockProps) { +async function OpenAPIBody(props: BlockProps) { const { block, context } = props; const { data, specUrl, error } = await fetchOpenAPIBlock(block, context.resolveContentRef); diff --git a/packages/gitbook/src/components/Insights/InsightsProvider.tsx b/packages/gitbook/src/components/Insights/InsightsProvider.tsx index a38a87067..6231934d3 100644 --- a/packages/gitbook/src/components/Insights/InsightsProvider.tsx +++ b/packages/gitbook/src/components/Insights/InsightsProvider.tsx @@ -1,6 +1,7 @@ 'use client'; import type * as api from '@gitbook/api'; +import { OpenAPIOperationContextProvider } from '@gitbook/react-openapi'; import cookies from 'js-cookie'; import * as React from 'react'; import { useEventCallback, useDebounceCallback } from 'usehooks-ts'; @@ -8,6 +9,11 @@ import { useEventCallback, useDebounceCallback } from 'usehooks-ts'; import { getSession } from './sessions'; import { getVisitorId } from './visitorId'; +type SiteEventName = api.SiteInsightsEvent['type']; + +/** + * Global context for all events in the session. + */ interface InsightsEventContext { organizationId: string; siteId: string; @@ -17,21 +23,40 @@ interface InsightsEventContext { siteShareKey: string | undefined; } +/** + * Context for an event on a page. + */ interface InsightsEventPageContext { pageId: string | null; revisionId: string; } -type SiteEventName = api.SiteInsightsEvent['type']; +/** + * Options when tracking an event. + */ +interface InsightsEventOptions { + /** + * If true, the event will be sent immediately. + * Passes true for events that could cause a page unload. + */ + immediate?: boolean; +} +/** + * Input data for an event. + */ type TrackEventInput = { type: EventName } & Omit< Extract, 'location' | 'session' >; +/** + * Callback to track an event. + */ type TrackEventCallback = ( event: TrackEventInput, ctx?: InsightsEventPageContext, + options?: InsightsEventOptions, ) => void; const InsightsContext = React.createContext(null); @@ -48,6 +73,7 @@ interface InsightsProviderProps extends InsightsEventContext { export function InsightsProvider(props: InsightsProviderProps) { const { enabled, apiHost, children, ...context } = props; + const visitorIdRef = React.useRef(null); const eventsRef = React.useRef<{ [pathname: string]: | { @@ -59,9 +85,12 @@ export function InsightsProvider(props: InsightsProviderProps) { | undefined; }>({}); - const flushEvents = useDebounceCallback(async (pathname: string) => { - const visitorId = await getVisitorId(); - const session = await getSession(); + const flushEventsSync = (pathname: string) => { + const visitorId = visitorIdRef.current; + if (!visitorId) { + throw new Error('Visitor ID not set'); + } + const session = getSession(); const eventsForPathname = eventsRef.current[pathname]; if (!eventsForPathname || !eventsForPathname.pageContext) { @@ -86,19 +115,30 @@ export function InsightsProvider(props: InsightsProviderProps) { if (enabled) { console.log('Sending events', events); - await sendEvents({ + sendEvents({ apiHost, organizationId: context.organizationId, siteId: context.siteId, events, }); } else { - console.log('Events not sent', events); + console.log('Skipping sending events', events); } + }; + + const flushBatchedEvents = useDebounceCallback(async (pathname: string) => { + const visitorId = visitorIdRef.current ?? (await getVisitorId()); + visitorIdRef.current = visitorId; + + flushEventsSync(pathname); }, 500); - const trackEvent = useEventCallback( - (event: TrackEventInput, ctx?: InsightsEventPageContext) => { + const trackEvent: TrackEventCallback = useEventCallback( + ( + event: TrackEventInput, + ctx?: InsightsEventPageContext, + options?: InsightsEventOptions, + ) => { console.log('Logging event', event, ctx); const pathname = window.location.pathname; @@ -113,12 +153,26 @@ export function InsightsProvider(props: InsightsProviderProps) { if (eventsRef.current[pathname].pageContext !== undefined) { // If the pageId is set, we know that the page_view event has been tracked // and we can flush the events - flushEvents(pathname); + if (options?.immediate && visitorIdRef.current) { + flushEventsSync(pathname); + } else { + flushBatchedEvents(pathname); + } } }, ); - return {props.children}; + return ( + + { + trackEvent({ type: 'api_client_open', operation }); + }} + > + {props.children} + + + ); } /** @@ -136,7 +190,7 @@ export function useTrackEvent(): TrackEventCallback { /** * Post the events to the server. */ -async function sendEvents(args: { +function sendEvents(args: { apiHost: string; organizationId: string; siteId: string; @@ -146,11 +200,12 @@ async function sendEvents(args: { const url = new URL(apiHost); url.pathname = `/v1/orgs/${organizationId}/sites/${siteId}/insights/events`; - await fetch(url, { + fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', }, + keepalive: true, body: JSON.stringify({ events, }), diff --git a/packages/gitbook/src/lib/openapi.ts b/packages/gitbook/src/lib/openapi.ts index 26c798fbb..879a626c3 100644 --- a/packages/gitbook/src/lib/openapi.ts +++ b/packages/gitbook/src/lib/openapi.ts @@ -1,4 +1,4 @@ -import { ContentRef, DocumentBlockSwagger } from '@gitbook/api'; +import { ContentRef, DocumentBlockOpenAPI } from '@gitbook/api'; import { OpenAPIOperationData, fetchOpenAPIOperation, @@ -16,7 +16,7 @@ import { ResolvedContentRef } from './references'; * Fetch an OpenAPI specification for an operation. */ export async function fetchOpenAPIBlock( - block: DocumentBlockSwagger, + block: DocumentBlockOpenAPI, resolveContentRef: (ref: ContentRef) => Promise, ): Promise< | { data: OpenAPIOperationData | null; specUrl: string | null; error?: undefined } diff --git a/packages/react-contentkit/package.json b/packages/react-contentkit/package.json index 6044b8736..a4eb3c448 100644 --- a/packages/react-contentkit/package.json +++ b/packages/react-contentkit/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "classnames": "^2.5.1", - "@gitbook/api": "^0.84.0", + "@gitbook/api": "^0.85.0", "assert-never": "^1.2.1" }, "peerDependencies": { diff --git a/packages/react-openapi/package.json b/packages/react-openapi/package.json index 1fcd3c78d..b05bda997 100644 --- a/packages/react-openapi/package.json +++ b/packages/react-openapi/package.json @@ -15,7 +15,8 @@ "flatted": "^3.2.9", "openapi-types": "^12.1.3", "swagger2openapi": "^7.0.8", - "yaml": "1.10.2" + "yaml": "1.10.2", + "usehooks-ts": "^3.1.0" }, "devDependencies": { "@types/swagger2openapi": "^7.0.4", diff --git a/packages/react-openapi/src/OpenAPIOperationContext.tsx b/packages/react-openapi/src/OpenAPIOperationContext.tsx new file mode 100644 index 000000000..b89b20b3b --- /dev/null +++ b/packages/react-openapi/src/OpenAPIOperationContext.tsx @@ -0,0 +1,44 @@ +'use client'; +import * as React from 'react'; +import { useEventCallback } from 'usehooks-ts'; + +interface OpenAPIOperationPointer { + path: string; + method: string; +} + +interface OpenAPIOperationContextValue { + onOpenClient: (pointer: OpenAPIOperationPointer) => void; +} + +const OpenAPIOperationContext = React.createContext({ + onOpenClient: () => {}, +}); + +/** + * Provider for the OpenAPIOperationContext. + */ +export function OpenAPIOperationContextProvider( + props: React.PropsWithChildren>, +) { + const { children } = props; + + const onOpenClient = useEventCallback((pointer: OpenAPIOperationPointer) => { + props.onOpenClient?.(pointer); + }); + + const value = React.useMemo(() => ({ onOpenClient }), [onOpenClient]); + + return ( + + {children} + + ); +} + +/** + * Hook to access the OpenAPIOperationContext. + */ +export function useOpenAPIOperationContext() { + return React.useContext(OpenAPIOperationContext); +} diff --git a/packages/react-openapi/src/ScalarApiButton.tsx b/packages/react-openapi/src/ScalarApiButton.tsx index 17517b9a4..a3ee96a7f 100644 --- a/packages/react-openapi/src/ScalarApiButton.tsx +++ b/packages/react-openapi/src/ScalarApiButton.tsx @@ -3,17 +3,22 @@ import { useApiClientModal } from '@scalar/api-client-react'; import React from 'react'; +import { useOpenAPIOperationContext } from './OpenAPIOperationContext'; + /** * Button which launches the Scalar API Client */ export function ScalarApiButton({ method, path }: { method: string; path: string }) { const client = useApiClientModal(); - + const { onOpenClient } = useOpenAPIOperationContext(); return (