mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
Every first-party Python caller and test now names the mount the API actually answers on. Mechanical: URL literals only, and only where the /api/ was ours -- a preceding word character means it was a module path like insights/api/ or a third-party host like slack.com/api/, and those are untouched. Left saying /api/, each for a reason rather than for safety: - products/warehouse_sources/.../sources/** is a catalogue of vendor endpoints - services/llm-gateway deploys separately and is under a code freeze, so moving its two calls would break it during the skew - the mcp_store and Slack/Teams redirect_uris are registered with third parties and must byte-match what was registered - the Wizard and Raycast client-metadata paths are OAuth client_ids - /api/surveys and /api/feature_flag/local_evaluation are SDK aliases the ingress routes to the Rust fleets, held by released SDK versions Test URLconfs moved with the requests that call them, and the Datastore user-selection fixtures now carry the leading slash that request.path has.
623 lines
19 KiB
TypeScript
623 lines
19 KiB
TypeScript
import { ApiClient } from '@/api/client'
|
|
import { MemoryCache } from '@/lib/cache/MemoryCache'
|
|
import { SessionManager } from '@/lib/SessionManager'
|
|
import { StateManager } from '@/lib/StateManager'
|
|
import type { InsightQuery } from '@/schema/query'
|
|
import { GENERATED_TOOL_MAP } from '@/tools/generated'
|
|
import { TOOL_MAP } from '@/tools/index'
|
|
import type { Context, ToolBase, ZodObjectAny } from '@/tools/types'
|
|
|
|
export const API_BASE_URL = process.env.TEST_INSIGHTS_API_BASE_URL || 'http://localhost:8010'
|
|
export const API_TOKEN = process.env.TEST_INSIGHTS_PERSONAL_API_KEY
|
|
export const TEST_ORG_ID = process.env.TEST_ORG_ID
|
|
export const TEST_PROJECT_ID = process.env.TEST_PROJECT_ID
|
|
|
|
export interface CreatedResources {
|
|
featureFlags: number[]
|
|
insights: number[]
|
|
dashboards: number[]
|
|
surveys: string[]
|
|
actions: number[]
|
|
cohorts: number[]
|
|
}
|
|
|
|
export function validateEnvironmentVariables(): void {
|
|
if (!API_TOKEN) {
|
|
throw new Error('TEST_INSIGHTS_PERSONAL_API_KEY environment variable is required')
|
|
}
|
|
|
|
if (!TEST_ORG_ID) {
|
|
throw new Error('TEST_ORG_ID environment variable is required')
|
|
}
|
|
|
|
if (!TEST_PROJECT_ID) {
|
|
throw new Error('TEST_PROJECT_ID environment variable is required')
|
|
}
|
|
}
|
|
|
|
export function createTestClient(): ApiClient {
|
|
return new ApiClient({
|
|
apiToken: API_TOKEN!,
|
|
baseUrl: API_BASE_URL,
|
|
})
|
|
}
|
|
|
|
export function createTestContext(client: ApiClient): Context {
|
|
const cache = new MemoryCache<any>('test-user')
|
|
const stateManager = new StateManager(cache, client)
|
|
|
|
const context: Context = {
|
|
api: client,
|
|
cache,
|
|
env: {} as any,
|
|
stateManager,
|
|
sessionManager: new SessionManager(cache),
|
|
getDistinctId: async () => 'test-distinct-id',
|
|
trackEvent: async () => {},
|
|
}
|
|
|
|
return context
|
|
}
|
|
|
|
export async function setActiveProjectAndOrg(context: Context, projectId: string, orgId: string): Promise<void> {
|
|
const cache = context.cache
|
|
await cache.set('projectId', projectId)
|
|
await cache.set('orgId', orgId)
|
|
}
|
|
|
|
export async function cleanupResources(
|
|
client: ApiClient,
|
|
projectId: string,
|
|
resources: CreatedResources
|
|
): Promise<void> {
|
|
for (const flagId of resources.featureFlags) {
|
|
try {
|
|
await client.request({
|
|
method: 'PATCH',
|
|
path: `/v1/projects/${projectId}/feature_flags/${flagId}/`,
|
|
body: { deleted: true },
|
|
})
|
|
} catch (error) {
|
|
console.warn(`Failed to cleanup feature flag ${flagId}:`, error)
|
|
}
|
|
}
|
|
resources.featureFlags = []
|
|
|
|
for (const insightId of resources.insights) {
|
|
try {
|
|
await client.insights({ projectId }).delete({ insightId })
|
|
} catch (error) {
|
|
console.warn(`Failed to cleanup insight ${insightId}:`, error)
|
|
}
|
|
}
|
|
resources.insights = []
|
|
|
|
for (const dashboardId of resources.dashboards) {
|
|
try {
|
|
await client.request({
|
|
method: 'PATCH',
|
|
path: `/v1/projects/${projectId}/dashboards/${dashboardId}/`,
|
|
body: { deleted: true },
|
|
})
|
|
} catch (error) {
|
|
console.warn(`Failed to cleanup dashboard ${dashboardId}:`, error)
|
|
}
|
|
}
|
|
resources.dashboards = []
|
|
|
|
for (const surveyId of resources.surveys) {
|
|
try {
|
|
await client.request({
|
|
method: 'DELETE',
|
|
path: `/v1/projects/${projectId}/surveys/${surveyId}/`,
|
|
})
|
|
} catch (error) {
|
|
console.warn(`Failed to cleanup survey ${surveyId}:`, error)
|
|
}
|
|
}
|
|
resources.surveys = []
|
|
|
|
for (const actionId of resources.actions) {
|
|
try {
|
|
await client.request({
|
|
method: 'PATCH',
|
|
path: `/v1/projects/${projectId}/actions/${actionId}/`,
|
|
body: { deleted: true },
|
|
})
|
|
} catch (error) {
|
|
console.warn(`Failed to cleanup action ${actionId}:`, error)
|
|
}
|
|
}
|
|
resources.actions = []
|
|
|
|
for (const cohortId of resources.cohorts) {
|
|
try {
|
|
await client.request({
|
|
method: 'PATCH',
|
|
path: `/v1/projects/${projectId}/cohorts/${cohortId}/`,
|
|
body: { deleted: true },
|
|
})
|
|
} catch (error) {
|
|
console.warn(`Failed to cleanup cohort ${cohortId}:`, error)
|
|
}
|
|
}
|
|
resources.cohorts = []
|
|
}
|
|
|
|
export function parseToolResponse(result: any): any {
|
|
// Tool handlers now return plain JSON objects directly
|
|
// The MCP server wraps them with formatResponse, but tests call handlers directly
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Look up a tool by name. When called with a single argument, searches the
|
|
* combined hand-written and generated tool maps. When called with a tool map
|
|
* as the first argument, searches only that map.
|
|
*/
|
|
export function getToolByName(name: string): ToolBase<ZodObjectAny>
|
|
export function getToolByName(
|
|
toolMap: Record<string, () => ToolBase<ZodObjectAny>>,
|
|
name: string
|
|
): ToolBase<ZodObjectAny>
|
|
export function getToolByName(
|
|
nameOrMap: string | Record<string, () => ToolBase<ZodObjectAny>>,
|
|
maybeName?: string
|
|
): ToolBase<ZodObjectAny> {
|
|
let toolMap: Record<string, () => ToolBase<ZodObjectAny>>
|
|
let name: string
|
|
|
|
if (typeof nameOrMap === 'string') {
|
|
toolMap = { ...TOOL_MAP, ...GENERATED_TOOL_MAP }
|
|
name = nameOrMap
|
|
} else {
|
|
toolMap = nameOrMap
|
|
name = maybeName!
|
|
}
|
|
|
|
const factory = toolMap[name]
|
|
if (!factory) {
|
|
throw new Error(`Tool "${name}" not found`)
|
|
}
|
|
return factory()
|
|
}
|
|
|
|
export function generateUniqueKey(prefix: string): string {
|
|
return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(7)}`
|
|
}
|
|
|
|
type SampleInsightsQLQuery = 'pageviews' | 'topEvents'
|
|
|
|
export const SAMPLE_INSIGHTSQL_QUERIES: Record<SampleInsightsQLQuery, InsightQuery> = {
|
|
pageviews: {
|
|
kind: 'DataVisualizationNode',
|
|
source: {
|
|
kind: 'InsightsQLQuery',
|
|
query: "SELECT event, count() AS event_count FROM events WHERE timestamp >= now() - INTERVAL 7 DAY AND event = '$pageview' GROUP BY event ORDER BY event_count DESC LIMIT 10",
|
|
filters: {
|
|
dateRange: {
|
|
date_from: '-7d',
|
|
date_to: '-1d',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
topEvents: {
|
|
kind: 'DataVisualizationNode',
|
|
source: {
|
|
kind: 'InsightsQLQuery',
|
|
query: 'SELECT event, count() AS event_count FROM events WHERE timestamp >= now() - INTERVAL 7 DAY GROUP BY event ORDER BY event_count DESC LIMIT 10',
|
|
filters: {
|
|
dateRange: {
|
|
date_from: '-7d',
|
|
date_to: '-1d',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
type SampleTrendQuery = 'basicPageviews' | 'uniqueUsers' | 'multipleEvents' | 'withBreakdown' | 'withPropertyFilter'
|
|
|
|
export const SAMPLE_TREND_QUERIES = {
|
|
basicPageviews: {
|
|
kind: 'InsightVizNode',
|
|
source: {
|
|
kind: 'TrendsQuery',
|
|
series: [
|
|
{
|
|
kind: 'EventsNode',
|
|
event: '$pageview',
|
|
custom_name: 'Page Views',
|
|
math: 'total',
|
|
},
|
|
],
|
|
dateRange: {
|
|
date_from: '-7d',
|
|
date_to: null,
|
|
},
|
|
interval: 'day',
|
|
properties: [],
|
|
filterTestAccounts: false,
|
|
},
|
|
},
|
|
uniqueUsers: {
|
|
kind: 'InsightVizNode',
|
|
source: {
|
|
kind: 'TrendsQuery',
|
|
series: [
|
|
{
|
|
kind: 'EventsNode',
|
|
event: '$pageview',
|
|
custom_name: 'Unique Users',
|
|
math: 'dau',
|
|
},
|
|
],
|
|
dateRange: {
|
|
date_from: '-30d',
|
|
date_to: null,
|
|
},
|
|
interval: 'day',
|
|
properties: [],
|
|
filterTestAccounts: true,
|
|
},
|
|
},
|
|
multipleEvents: {
|
|
kind: 'InsightVizNode',
|
|
source: {
|
|
kind: 'TrendsQuery',
|
|
series: [
|
|
{
|
|
kind: 'EventsNode',
|
|
event: '$pageview',
|
|
custom_name: 'Page Views',
|
|
math: 'total',
|
|
},
|
|
{
|
|
kind: 'EventsNode',
|
|
event: 'button_clicked',
|
|
custom_name: 'Button Clicks',
|
|
math: 'total',
|
|
},
|
|
],
|
|
dateRange: {
|
|
date_from: '-14d',
|
|
date_to: null,
|
|
},
|
|
interval: 'day',
|
|
properties: [],
|
|
filterTestAccounts: false,
|
|
},
|
|
},
|
|
withBreakdown: {
|
|
kind: 'InsightVizNode',
|
|
source: {
|
|
kind: 'TrendsQuery',
|
|
series: [
|
|
{
|
|
kind: 'EventsNode',
|
|
event: '$pageview',
|
|
custom_name: 'Page Views by Browser',
|
|
math: 'total',
|
|
},
|
|
],
|
|
breakdownFilter: {
|
|
breakdown_type: 'event',
|
|
breakdown: '$browser',
|
|
breakdown_limit: 10,
|
|
},
|
|
dateRange: {
|
|
date_from: '-7d',
|
|
date_to: null,
|
|
},
|
|
interval: 'day',
|
|
properties: [],
|
|
filterTestAccounts: false,
|
|
},
|
|
},
|
|
withPropertyFilter: {
|
|
kind: 'InsightVizNode',
|
|
source: {
|
|
kind: 'TrendsQuery',
|
|
series: [
|
|
{
|
|
kind: 'EventsNode',
|
|
event: '$pageview',
|
|
custom_name: 'Chrome/Safari Page Views',
|
|
math: 'total',
|
|
properties: [
|
|
{
|
|
key: '$browser',
|
|
value: ['Chrome', 'Safari'],
|
|
operator: 'exact',
|
|
type: 'event',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
dateRange: {
|
|
date_from: '-7d',
|
|
date_to: null,
|
|
},
|
|
interval: 'day',
|
|
properties: [],
|
|
filterTestAccounts: false,
|
|
},
|
|
},
|
|
} as const satisfies Record<SampleTrendQuery, InsightQuery>
|
|
|
|
type SampleFunnelQuery =
|
|
| 'basicFunnel'
|
|
| 'strictOrderFunnel'
|
|
| 'funnelWithBreakdown'
|
|
| 'conversionWindow'
|
|
| 'onboardingFunnel'
|
|
|
|
export const SAMPLE_FUNNEL_QUERIES = {
|
|
basicFunnel: {
|
|
kind: 'InsightVizNode',
|
|
source: {
|
|
kind: 'FunnelsQuery',
|
|
series: [
|
|
{
|
|
kind: 'EventsNode',
|
|
event: '$pageview',
|
|
custom_name: 'Page View',
|
|
},
|
|
{
|
|
kind: 'EventsNode',
|
|
event: 'button_clicked',
|
|
custom_name: 'Button Clicked',
|
|
},
|
|
],
|
|
dateRange: {
|
|
date_from: '-7d',
|
|
date_to: null,
|
|
},
|
|
properties: [],
|
|
filterTestAccounts: false,
|
|
},
|
|
},
|
|
strictOrderFunnel: {
|
|
kind: 'InsightVizNode',
|
|
source: {
|
|
kind: 'FunnelsQuery',
|
|
series: [
|
|
{
|
|
kind: 'EventsNode',
|
|
event: '$pageview',
|
|
custom_name: 'Landing Page View',
|
|
},
|
|
{
|
|
kind: 'EventsNode',
|
|
event: 'sign_up_started',
|
|
custom_name: 'Sign Up Started',
|
|
},
|
|
{
|
|
kind: 'EventsNode',
|
|
event: 'sign_up_completed',
|
|
custom_name: 'Sign Up Completed',
|
|
},
|
|
],
|
|
funnelsFilter: {
|
|
layout: 'vertical',
|
|
breakdownAttributionType: 'first_touch',
|
|
funnelOrderType: 'strict',
|
|
funnelVizType: 'steps',
|
|
funnelWindowInterval: 7,
|
|
funnelWindowIntervalUnit: 'day',
|
|
funnelStepReference: 'total',
|
|
},
|
|
dateRange: {
|
|
date_from: '-30d',
|
|
date_to: null,
|
|
},
|
|
properties: [],
|
|
filterTestAccounts: false,
|
|
},
|
|
},
|
|
funnelWithBreakdown: {
|
|
kind: 'InsightVizNode',
|
|
source: {
|
|
kind: 'FunnelsQuery',
|
|
series: [
|
|
{
|
|
kind: 'EventsNode',
|
|
event: '$pageview',
|
|
custom_name: 'Product Page View',
|
|
},
|
|
{
|
|
kind: 'EventsNode',
|
|
event: 'purchase',
|
|
custom_name: 'Purchase',
|
|
},
|
|
],
|
|
breakdownFilter: {
|
|
breakdown_type: 'event',
|
|
breakdown: '$browser',
|
|
breakdown_limit: 5,
|
|
},
|
|
funnelsFilter: {
|
|
layout: 'vertical',
|
|
breakdownAttributionType: 'first_touch',
|
|
funnelOrderType: 'ordered',
|
|
funnelVizType: 'steps',
|
|
funnelWindowInterval: 14,
|
|
funnelWindowIntervalUnit: 'day',
|
|
funnelStepReference: 'total',
|
|
},
|
|
dateRange: {
|
|
date_from: '-14d',
|
|
date_to: null,
|
|
},
|
|
properties: [],
|
|
filterTestAccounts: false,
|
|
},
|
|
},
|
|
conversionWindow: {
|
|
kind: 'InsightVizNode',
|
|
source: {
|
|
kind: 'FunnelsQuery',
|
|
series: [
|
|
{
|
|
kind: 'EventsNode',
|
|
event: '$pageview',
|
|
custom_name: 'Page View',
|
|
},
|
|
{
|
|
kind: 'EventsNode',
|
|
event: 'add_to_cart',
|
|
custom_name: 'Add to Cart',
|
|
},
|
|
{
|
|
kind: 'EventsNode',
|
|
event: 'purchase',
|
|
custom_name: 'Purchase',
|
|
},
|
|
],
|
|
funnelsFilter: {
|
|
layout: 'vertical',
|
|
breakdownAttributionType: 'first_touch',
|
|
funnelOrderType: 'ordered',
|
|
funnelVizType: 'steps',
|
|
funnelWindowInterval: 1,
|
|
funnelWindowIntervalUnit: 'hour',
|
|
funnelStepReference: 'total',
|
|
},
|
|
dateRange: {
|
|
date_from: '-7d',
|
|
date_to: null,
|
|
},
|
|
properties: [],
|
|
filterTestAccounts: false,
|
|
},
|
|
},
|
|
onboardingFunnel: {
|
|
kind: 'InsightVizNode',
|
|
source: {
|
|
kind: 'FunnelsQuery',
|
|
series: [
|
|
{
|
|
kind: 'EventsNode',
|
|
event: 'Signed In',
|
|
custom_name: 'User Signs In',
|
|
},
|
|
{
|
|
kind: 'EventsNode',
|
|
event: '$pageview',
|
|
properties: [
|
|
{
|
|
key: '$pathname',
|
|
type: 'event',
|
|
value: 'get-started',
|
|
operator: 'icontains',
|
|
},
|
|
],
|
|
custom_name: 'Views Get-Started Page',
|
|
},
|
|
{
|
|
kind: 'EventsNode',
|
|
event: 'Integration Connected',
|
|
custom_name: 'Connects Integration',
|
|
},
|
|
{
|
|
kind: 'EventsNode',
|
|
event: 'Onboarding Completed',
|
|
custom_name: 'Completes Onboarding',
|
|
},
|
|
],
|
|
dateRange: {
|
|
date_to: 'today',
|
|
date_from: '-30d',
|
|
},
|
|
funnelsFilter: {
|
|
layout: 'vertical',
|
|
breakdownAttributionType: 'first_touch',
|
|
funnelOrderType: 'ordered',
|
|
funnelVizType: 'steps',
|
|
funnelWindowInterval: 24,
|
|
funnelWindowIntervalUnit: 'hour',
|
|
funnelStepReference: 'total',
|
|
},
|
|
properties: [],
|
|
filterTestAccounts: true,
|
|
},
|
|
},
|
|
} as const satisfies Record<SampleFunnelQuery, InsightQuery>
|
|
|
|
type SamplePathsQuery = 'basicPageviewPaths' | 'customEventPaths' | 'pathsWithStartPoint' | 'pathsWithInsightsQL'
|
|
|
|
export const SAMPLE_PATHS_QUERIES = {
|
|
basicPageviewPaths: {
|
|
kind: 'InsightVizNode',
|
|
source: {
|
|
kind: 'PathsQuery',
|
|
pathsFilter: {
|
|
includeEventTypes: ['$pageview'],
|
|
stepLimit: 5,
|
|
edgeLimit: 50,
|
|
},
|
|
dateRange: {
|
|
date_from: '-7d',
|
|
date_to: null,
|
|
},
|
|
properties: [],
|
|
filterTestAccounts: false,
|
|
},
|
|
},
|
|
customEventPaths: {
|
|
kind: 'InsightVizNode',
|
|
source: {
|
|
kind: 'PathsQuery',
|
|
pathsFilter: {
|
|
includeEventTypes: ['custom_event'],
|
|
stepLimit: 3,
|
|
edgeLimit: 30,
|
|
excludeEvents: ['$feature_flag_called'],
|
|
},
|
|
dateRange: {
|
|
date_from: '-14d',
|
|
date_to: null,
|
|
},
|
|
properties: [],
|
|
filterTestAccounts: false,
|
|
},
|
|
},
|
|
pathsWithStartPoint: {
|
|
kind: 'InsightVizNode',
|
|
source: {
|
|
kind: 'PathsQuery',
|
|
pathsFilter: {
|
|
includeEventTypes: ['$pageview'],
|
|
startPoint: '/',
|
|
stepLimit: 5,
|
|
edgeLimit: 50,
|
|
},
|
|
dateRange: {
|
|
date_from: '-7d',
|
|
date_to: null,
|
|
},
|
|
properties: [],
|
|
filterTestAccounts: false,
|
|
},
|
|
},
|
|
pathsWithInsightsQL: {
|
|
kind: 'InsightVizNode',
|
|
source: {
|
|
kind: 'PathsQuery',
|
|
pathsFilter: {
|
|
includeEventTypes: ['insightsql'],
|
|
pathsInsightsQLExpression: 'event',
|
|
stepLimit: 5,
|
|
edgeLimit: 50,
|
|
},
|
|
dateRange: {
|
|
date_from: '-7d',
|
|
date_to: null,
|
|
},
|
|
properties: [],
|
|
filterTestAccounts: false,
|
|
},
|
|
},
|
|
} as const satisfies Record<SamplePathsQuery, InsightQuery>
|