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
51 changes: 43 additions & 8 deletions docs/analytics.md

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions src/app/(site)/ai-policy/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,9 @@ const AiPolicyPage = () => (
</li>
<li>
<strong>Internet addresses.</strong> Our servers’ request logs record the
internet address of each request, as on any website. Usage statistics do not
include it.
internet address of each request, as on any website. Usage statistics leave
it out. They record the country your request came from, and your browser’s
name and version, to tell people from automated traffic.
</li>
</ul>
<p>
Expand Down
1 change: 1 addition & 0 deletions src/app/api/events/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export const POST = async (request: Request) => {
userId: session?.user.id ?? null,
visitorId: clip(body?.visitorId, MAX_ID),
page: clip(body?.page, MAX_PAGE),
headers: request.headers,
});

return new NextResponse(null, { status: 204 });
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/feedback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export const POST = async (req: NextRequest) => {
void track(
'feedback.submit',
{ kind: message.startsWith('Studio chat feedback') ? 'chat' : 'widget' },
{ page },
{ page, headers: req.headers },
);

return json(200, true, 'Thanks for your feedback!');
Expand Down
26 changes: 21 additions & 5 deletions src/app/api/share/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { readSearchShareRequest } from '@/lib/search/searchShareRequest';
// A search result's link: dataset references only, no numbers. The search
// page is public, so an account is optional here (the share is simply
// unowned without one).
const shareSearch = async (body: any) => {
const shareSearch = async (body: any, headers: Headers) => {
const request = readSearchShareRequest(body);

if (!request) {
Expand All @@ -21,9 +21,14 @@ const shareSearch = async (body: any) => {

const user = await getUser();

let created: { id: string };

try {
return NextResponse.json(
await createSearchShare(user?.id ?? null, request.mode, request.query, request.parts),
created = await createSearchShare(
user?.id ?? null,
request.mode,
request.query,
request.parts,
);
} catch (error) {
// The platform refused the register/mint — say which, don't store a
Expand All @@ -39,6 +44,17 @@ const shareSearch = async (body: any) => {

return NextResponse.json({ error: 'platform_refused', detail: message }, { status: 502 });
}

// Recorded after the response, so the link does not wait on analytics.
after(() =>
track(
'share.create',
shareCreateProps({ id: created.id, kind: 'search', mode: request.mode }),
{ userId: user?.id ?? null, headers },
),
);

return NextResponse.json(created);
};

// POST /api/share { kind, conversation, cardId?, mode? } → { id, title }
Expand All @@ -51,7 +67,7 @@ export const POST = async (request: Request) => {
const body = await request.json().catch(() => null);

if (body?.kind === 'search') {
return shareSearch(body);
return shareSearch(body, request.headers);
}

const user = await getUser();
Expand Down Expand Up @@ -79,7 +95,7 @@ export const POST = async (request: Request) => {
track(
'share.create',
shareCreateProps({ id: created.id, kind: read.kind, mode: read.mode }),
{ userId: user.id },
{ userId: user.id, headers: request.headers },
),
);

Expand Down
12 changes: 4 additions & 8 deletions src/app/embed/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { after } from 'next/server';

import EmbedView from '@/components/organisms/EmbedView/EmbedView';
import { getShare } from '@/lib/chat/shareStore';
import { refererHost } from '@/lib/events/referer';
import { track } from '@/lib/events/track';
import { sharedViewOf } from '@/lib/studio/liveCards';

Expand All @@ -21,12 +22,6 @@ export const generateMetadata = async ({ params }: EmbedPageProps): Promise<Meta
};
};

// The host site of the page the iframe sits on, as a bare hostname: the
// Referer's path and query can carry personal data, so they never leave here.
// Null when the host page's referrer policy strips it.
const refererHost = (referer: string | null): string | null =>
URL.parse(referer ?? '')?.hostname || null;

// The iframe view of a share: just the read-only sheet, or the one card,
// edge to edge. A search share has no sheet to embed. A live share's data
// cards leave the server held and are filled in the browser (sharedViewOf).
Expand All @@ -41,8 +36,9 @@ const EmbedPage = async ({ params }: EmbedPageProps) => {
// Counted here, server-side, with no identifier and nothing stored on the
// device, so it needs no consent. Headers are read during render: inside
// after() they throw.
const host = refererHost((await headers()).get('referer'));
after(() => track('embed.view', { shareId: id, kind: row.kind, host }));
const incoming = await headers();
const host = refererHost(incoming.get('referer'));
after(() => track('embed.view', { shareId: id, kind: row.kind, host }, { headers: incoming }));

return <EmbedView shared={sharedViewOf(row)} />;
};
Expand Down
28 changes: 28 additions & 0 deletions src/app/s/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import type { Metadata } from 'next';
import { headers } from 'next/headers';
import { notFound, redirect } from 'next/navigation';
import { after } from 'next/server';

import Studio from '@/components/organisms/Studio/Studio';
import { isLiveStudioShare, searchShareMode, type ShareMode } from '@/lib/chat/shareMode';
import { getShare } from '@/lib/chat/shareStore';
import { refererHost } from '@/lib/events/referer';
import { track } from '@/lib/events/track';
import { sharedViewOf } from '@/lib/studio/liveCards';

type SharePageProps = { params: Promise<{ id: string }> };
Expand All @@ -20,6 +25,16 @@ export const generateMetadata = async ({ params }: SharePageProps): Promise<Meta
};
};

// The mode a stored share is served in: a Studio row with no stored mode is
// a snapshot, a search row with none is live (shareMode.ts).
const modeOf = (row: { kind: string; mode: string | null }): ShareMode => {
if (row.kind === 'search') {
return searchShareMode(row.mode);
}

return isLiveStudioShare(row) ? 'live' : 'snapshot';
};

// A public share: the Studio, read-only, on the stored copy. No account
// needed to look. A live share's data cards leave the server held and are
// filled in the browser on current data (sharedViewOf).
Expand All @@ -31,6 +46,19 @@ const SharePage = async ({ params }: SharePageProps) => {
notFound();
}

// Counted here, server-side, with no identifier and nothing stored on the
// device, so it needs no consent. Headers are read during render: inside
// after() they throw. Scheduled before the redirect below, which throws.
const incoming = await headers();
const host = refererHost(incoming.get('referer'));
after(() =>
track(
'share.open',
{ shareId: row.id, kind: row.kind, mode: modeOf(row), host },
{ headers: incoming },
),
);

// A search result opens on the search page itself, which loads the
// share's references and replays (or reads back) the data.
if (row.kind === 'search') {
Expand Down
9 changes: 5 additions & 4 deletions src/components/molecules/DataPrompt/DataPrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import { revealIndex } from '@/hooks/useReveal';
export type DataPromptProps = {
suggestions?: string[];
suggestionsHidden?: boolean;
onSubmit?: (query: string) => void;
// `via` says whether the text was typed or a suggestion chip was picked.
onSubmit?: (query: string, via: 'typed' | 'example') => void;
onChange?: (query: string) => void;
// Take the caret on mount. Applied from an effect rather than the DOM
// attribute, so the decision can depend on the device without the server
Expand Down Expand Up @@ -88,9 +89,9 @@ const DataPrompt = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const submit = (query: string) => {
const submit = (query: string, via: 'typed' | 'example' = 'typed') => {
if (query.trim()) {
onSubmit?.(query.trim());
onSubmit?.(query.trim(), via);
}
};

Expand Down Expand Up @@ -136,7 +137,7 @@ const DataPrompt = ({
{suggestions && suggestions.length > 0 && (
<TryChips
examples={suggestions}
onPick={submit}
onPick={(text) => submit(text, 'example')}
hidden={suggestionsHidden}
className={styles.try}
/>
Expand Down
Loading
Loading