chore(web): Server side search telemetry (#652)

This commit is contained in:
Brendan Kellam 2025-12-03 16:04:36 -08:00 committed by GitHub
parent 7fc068f8b2
commit 76dc2f5a12
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 119 additions and 8 deletions

View file

@ -21,7 +21,7 @@ AUTH_URL="http://localhost:3000"
DATA_CACHE_DIR=${PWD}/.sourcebot # Path to the sourcebot cache dir (ex. ~/sourcebot/.sourcebot) DATA_CACHE_DIR=${PWD}/.sourcebot # Path to the sourcebot cache dir (ex. ~/sourcebot/.sourcebot)
SOURCEBOT_PUBLIC_KEY_PATH=${PWD}/public.pem SOURCEBOT_PUBLIC_KEY_PATH=${PWD}/public.pem
# CONFIG_PATH=${PWD}/config.json # Path to the sourcebot config file (if one exists) CONFIG_PATH=${PWD}/config.json # Path to the sourcebot config file (if one exists)
# Email # Email
# EMAIL_FROM_ADDRESS="" # The from address for transactional emails. # EMAIL_FROM_ADDRESS="" # The from address for transactional emails.

View file

@ -7,9 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
<!-- @NOTE: release new MCP version on package release! -->
### Added ### Added
- Added `ALWAYS_INDEX_FILE_PATTERNS` environment variable to allow specifying a comma seperated list of glob patterns matching file paths that should always be indexed, regardless of size or # of trigrams. [#631](https://github.com/sourcebot-dev/sourcebot/pull/631) - Added `ALWAYS_INDEX_FILE_PATTERNS` environment variable to allow specifying a comma seperated list of glob patterns matching file paths that should always be indexed, regardless of size or # of trigrams. [#631](https://github.com/sourcebot-dev/sourcebot/pull/631)
- Added button to explore menu to toggle cross-repository search. [#647](https://github.com/sourcebot-dev/sourcebot/pull/647) - Added button to explore menu to toggle cross-repository search. [#647](https://github.com/sourcebot-dev/sourcebot/pull/647)
- Added server side telemetry for search metrics. [#652](https://github.com/sourcebot-dev/sourcebot/pull/652)
### Fixed ### Fixed
- Fixed issue where single quotes could not be used in search queries. [#629](https://github.com/sourcebot-dev/sourcebot/pull/629) - Fixed issue where single quotes could not be used in search queries. [#629](https://github.com/sourcebot-dev/sourcebot/pull/629)

View file

@ -8,7 +8,6 @@ export const search = async (request: SearchRequest): Promise<SearchResponse | S
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Org-Domain': '~',
...(env.SOURCEBOT_API_KEY ? { 'X-Sourcebot-Api-Key': env.SOURCEBOT_API_KEY } : {}) ...(env.SOURCEBOT_API_KEY ? { 'X-Sourcebot-Api-Key': env.SOURCEBOT_API_KEY } : {})
}, },
body: JSON.stringify(request) body: JSON.stringify(request)
@ -26,7 +25,6 @@ export const listRepos = async (): Promise<ListRepositoriesResponse | ServiceErr
method: 'GET', method: 'GET',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Org-Domain': '~',
...(env.SOURCEBOT_API_KEY ? { 'X-Sourcebot-Api-Key': env.SOURCEBOT_API_KEY } : {}) ...(env.SOURCEBOT_API_KEY ? { 'X-Sourcebot-Api-Key': env.SOURCEBOT_API_KEY } : {})
}, },
}).then(response => response.json()); }).then(response => response.json());
@ -43,7 +41,6 @@ export const getFileSource = async (request: FileSourceRequest): Promise<FileSou
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Org-Domain': '~',
...(env.SOURCEBOT_API_KEY ? { 'X-Sourcebot-Api-Key': env.SOURCEBOT_API_KEY } : {}) ...(env.SOURCEBOT_API_KEY ? { 'X-Sourcebot-Api-Key': env.SOURCEBOT_API_KEY } : {})
}, },
body: JSON.stringify(request) body: JSON.stringify(request)

View file

@ -76,6 +76,7 @@ server.tool(
contextLines: env.DEFAULT_CONTEXT_LINES, contextLines: env.DEFAULT_CONTEXT_LINES,
isRegexEnabled: true, isRegexEnabled: true,
isCaseSensitivityEnabled: caseSensitive, isCaseSensitivityEnabled: caseSensitive,
source: 'mcp'
}); });
if (isServiceError(response)) { if (isServiceError(response)) {

View file

@ -31,6 +31,7 @@ export const searchOptionsSchema = z.object({
export const searchRequestSchema = z.object({ export const searchRequestSchema = z.object({
query: z.string(), // The zoekt query to execute. query: z.string(), // The zoekt query to execute.
source: z.string().optional(), // The source of the search request.
...searchOptionsSchema.shape, ...searchOptionsSchema.shape,
}); });

View file

@ -156,6 +156,7 @@
"openai": "^4.98.0", "openai": "^4.98.0",
"parse-diff": "^0.11.1", "parse-diff": "^0.11.1",
"posthog-js": "^1.161.5", "posthog-js": "^1.161.5",
"posthog-node": "^5.15.0",
"pretty-bytes": "^6.1.1", "pretty-bytes": "^6.1.1",
"psl": "^1.15.0", "psl": "^1.15.0",
"react": "^19.2.1", "react": "^19.2.1",

View file

@ -55,6 +55,7 @@ export const useSuggestionsData = ({
query: `file:${suggestionQuery}`, query: `file:${suggestionQuery}`,
matches: 15, matches: 15,
contextLines: 1, contextLines: 1,
source: 'search-bar-file-suggestions'
}), }),
select: (data): Suggestion[] => { select: (data): Suggestion[] => {
if (isServiceError(data)) { if (isServiceError(data)) {
@ -75,6 +76,7 @@ export const useSuggestionsData = ({
query: `sym:${suggestionQuery.length > 0 ? suggestionQuery : ".*"}`, query: `sym:${suggestionQuery.length > 0 ? suggestionQuery : ".*"}`,
matches: 15, matches: 15,
contextLines: 1, contextLines: 1,
source: 'search-bar-symbol-suggestions'
}), }),
select: (data): Suggestion[] => { select: (data): Suggestion[] => {
if (isServiceError(data)) { if (isServiceError(data)) {

View file

@ -129,7 +129,8 @@ export const useStreamedSearch = ({ query, matches, contextLines, whole, isRegex
whole, whole,
isRegexEnabled, isRegexEnabled,
isCaseSensitivityEnabled, isCaseSensitivityEnabled,
}), source: 'sourcebot-web-client'
} satisfies SearchRequest),
signal: abortControllerRef.current.signal, signal: abortControllerRef.current.signal,
}); });

View file

@ -4,6 +4,7 @@ import { search, searchRequestSchema } from "@/features/search";
import { isServiceError } from "@/lib/utils"; import { isServiceError } from "@/lib/utils";
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError"; import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
import { captureEvent } from "@/lib/posthog";
export const POST = async (request: NextRequest) => { export const POST = async (request: NextRequest) => {
const body = await request.json(); const body = await request.json();
@ -16,8 +17,14 @@ export const POST = async (request: NextRequest) => {
const { const {
query, query,
source,
...options ...options
} = parsed.data; } = parsed.data;
await captureEvent('api_code_search_request', {
source: source ?? 'unknown',
type: 'blocking',
});
const response = await search({ const response = await search({
queryType: 'string', queryType: 'string',
@ -28,5 +35,6 @@ export const POST = async (request: NextRequest) => {
if (isServiceError(response)) { if (isServiceError(response)) {
return serviceErrorResponse(response); return serviceErrorResponse(response);
} }
return Response.json(response); return Response.json(response);
} }

View file

@ -1,6 +1,7 @@
'use server'; 'use server';
import { streamSearch, searchRequestSchema } from '@/features/search'; import { streamSearch, searchRequestSchema } from '@/features/search';
import { captureEvent } from '@/lib/posthog';
import { schemaValidationError, serviceErrorResponse } from '@/lib/serviceError'; import { schemaValidationError, serviceErrorResponse } from '@/lib/serviceError';
import { isServiceError } from '@/lib/utils'; import { isServiceError } from '@/lib/utils';
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
@ -15,9 +16,15 @@ export const POST = async (request: NextRequest) => {
const { const {
query, query,
source,
...options ...options
} = parsed.data; } = parsed.data;
await captureEvent('api_code_search_request', {
source: source ?? 'unknown',
type: 'streamed',
});
const stream = await streamSearch({ const stream = await streamSearch({
queryType: 'string', queryType: 'string',
query, query,

View file

@ -47,6 +47,7 @@ export const useSuggestionsData = ({
query: query.join(' '), query: query.join(' '),
matches: 10, matches: 10,
contextLines: 1, contextLines: 1,
source: 'chat-file-suggestions'
})) }))
}, },
select: (data): FileSuggestion[] => { select: (data): FileSuggestion[] => {

View file

@ -2,13 +2,12 @@ import { sew } from "@/actions";
import { getRepoPermissionFilterForUser } from "@/prisma"; import { getRepoPermissionFilterForUser } from "@/prisma";
import { withOptionalAuthV2 } from "@/withAuthV2"; import { withOptionalAuthV2 } from "@/withAuthV2";
import { PrismaClient, UserWithAccounts } from "@sourcebot/db"; import { PrismaClient, UserWithAccounts } from "@sourcebot/db";
import { createLogger, env, hasEntitlement } from "@sourcebot/shared"; import { env, hasEntitlement } from "@sourcebot/shared";
import { QueryIR } from './ir'; import { QueryIR } from './ir';
import { parseQuerySyntaxIntoIR } from './parser'; import { parseQuerySyntaxIntoIR } from './parser';
import { SearchOptions } from "./types"; import { SearchOptions } from "./types";
import { createZoektSearchRequest, zoektSearch, zoektStreamSearch } from './zoektSearcher'; import { createZoektSearchRequest, zoektSearch, zoektStreamSearch } from './zoektSearcher';
const logger = createLogger("searchApi");
type QueryStringSearchRequest = { type QueryStringSearchRequest = {
queryType: 'string'; queryType: 'string';

View file

@ -94,6 +94,7 @@ export type SearchOptions = z.infer<typeof searchOptionsSchema>;
export const searchRequestSchema = z.object({ export const searchRequestSchema = z.object({
query: z.string(), // The zoekt query to execute. query: z.string(), // The zoekt query to execute.
source: z.string().optional(), // The source of the search request.
...searchOptionsSchema.shape, ...searchOptionsSchema.shape,
}); });
export type SearchRequest = z.infer<typeof searchRequestSchema>; export type SearchRequest = z.infer<typeof searchRequestSchema>;

View file

@ -0,0 +1,65 @@
import { PostHog } from 'posthog-node'
import { env } from '@sourcebot/shared'
import { RequestCookies } from 'next/dist/compiled/@edge-runtime/cookies';
import * as Sentry from "@sentry/nextjs";
import { PosthogEvent, PosthogEventMap } from './posthogEvents';
import { cookies } from 'next/headers';
/**
* @note: This is a subset of the properties stored in the
* ph_phc_<id>_posthog cookie.
*/
export type PostHogCookie = {
distinct_id: string;
}
const isPostHogCookie = (cookie: unknown): cookie is PostHogCookie => {
return typeof cookie === 'object' &&
cookie !== null &&
'distinct_id' in cookie;
}
/**
* Attempts to retrieve the PostHog cookie from the given cookie store, returning
* undefined if the cookie is not found or is invalid.
*/
const getPostHogCookie = (cookieStore: Pick<RequestCookies, 'get'>): PostHogCookie | undefined => {
const phCookieKey = `ph_${env.POSTHOG_PAPIK}_posthog`;
const cookie = cookieStore.get(phCookieKey);
if (!cookie) {
return undefined;
}
const parsedCookie = (() => {
try {
return JSON.parse(cookie.value);
} catch (e) {
Sentry.captureException(e);
return null;
}
})();
if (isPostHogCookie(parsedCookie)) {
return parsedCookie;
}
return undefined;
}
export async function captureEvent<E extends PosthogEvent>(event: E, properties: PosthogEventMap[E]) {
const cookieStore = await cookies();
const cookie = getPostHogCookie(cookieStore);
const posthog = new PostHog(env.POSTHOG_PAPIK, {
host: 'https://us.i.posthog.com',
flushAt: 1,
flushInterval: 0
})
posthog.capture({
event,
properties,
distinctId: cookie?.distinct_id ?? '',
});
}

View file

@ -313,6 +313,11 @@ export type PosthogEventMap = {
durationMs: number, durationMs: number,
// Whether or not the user is searching all repositories. // Whether or not the user is searching all repositories.
isGlobalSearchEnabled: boolean, isGlobalSearchEnabled: boolean,
} },
//////////////////////////////////////////////////////////////////
api_code_search_request: {
source: string;
type: 'streamed' | 'blocking';
},
} }
export type PosthogEvent = keyof PosthogEventMap; export type PosthogEvent = keyof PosthogEventMap;

View file

@ -4599,6 +4599,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@posthog/core@npm:1.6.0":
version: 1.6.0
resolution: "@posthog/core@npm:1.6.0"
dependencies:
cross-spawn: "npm:^7.0.6"
checksum: 10c0/28aa907bb21b18587bc5f47c44349ebc834b37d9a4cedb1a18d7b673d4d7cdad2120dda80deceaee707b2f52333e9a08a8e591e1fc4066521ce05e820b76309f
languageName: node
linkType: hard
"@prisma/client@npm:6.2.1": "@prisma/client@npm:6.2.1":
version: 6.2.1 version: 6.2.1
resolution: "@prisma/client@npm:6.2.1" resolution: "@prisma/client@npm:6.2.1"
@ -8245,6 +8254,7 @@ __metadata:
parse-diff: "npm:^0.11.1" parse-diff: "npm:^0.11.1"
postcss: "npm:^8" postcss: "npm:^8"
posthog-js: "npm:^1.161.5" posthog-js: "npm:^1.161.5"
posthog-node: "npm:^5.15.0"
pretty-bytes: "npm:^6.1.1" pretty-bytes: "npm:^6.1.1"
psl: "npm:^1.15.0" psl: "npm:^1.15.0"
react: "npm:^19.2.1" react: "npm:^19.2.1"
@ -17235,6 +17245,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"posthog-node@npm:^5.15.0":
version: 5.15.0
resolution: "posthog-node@npm:5.15.0"
dependencies:
"@posthog/core": "npm:1.6.0"
checksum: 10c0/7db929453cefc9b2d0017e1f7c9ffe7e6ecd2a495ee9861bd5e8f3873f72fa29a318dd7bccf10eb15639e1860aab396d4be502af88afba96ed15ac8b46d57e9d
languageName: node
linkType: hard
"preact-render-to-string@npm:6.5.11": "preact-render-to-string@npm:6.5.11":
version: 6.5.11 version: 6.5.11
resolution: "preact-render-to-string@npm:6.5.11" resolution: "preact-render-to-string@npm:6.5.11"