feat(otel): added otel, persist settings to db, present user with the telemetry preferences & add privacy tab to settings (#318)

* feat(otel): added otel, persist settings to db, present user with the telemetry preferences & add privacy tab to settings

* updated telemetry endpoint

* add protected subdomains for chat deploy

* removed unused dependencies

* add execution telemetry logs for workflow-level and block-level logs, acknowledged PR comments
This commit is contained in:
Waleed Latif
2025-04-30 23:49:53 -07:00
committed by GitHub
parent 209f9ad6ae
commit 03cde7ed37
26 changed files with 5619 additions and 323 deletions
+9 -1
View File
@@ -65,4 +65,12 @@ docs/.contentlayer
docs/.content-collections
# database instantiation
**/postgres_data/
**/postgres_data/
# file uploads
uploads/
# collector configuration
collector-config.yaml
docker-compose.collector.yml
start-collector.sh
@@ -162,6 +162,29 @@ describe('Subdomain Validation API Route', () => {
subdomain: 'available-subdomain',
})
})
it('should return available=false when subdomain is reserved', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' },
}),
}))
const req = new NextRequest('http://localhost:3000/api/chat/subdomains/validate?subdomain=telemetry')
const { GET } = await import('./route')
const response = await GET(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data).toHaveProperty('available', false)
expect(data).toHaveProperty('error', 'This subdomain is reserved')
expect(mockNextResponseJson).toHaveBeenCalledWith(
{ available: false, error: 'This subdomain is reserved' },
{ status: 400 }
)
})
it('should return available=false when subdomain is already in use', async () => {
vi.doMock('@/lib/auth', () => ({
@@ -34,6 +34,18 @@ export async function GET(request: Request) {
{ status: 400 }
)
}
// Protect reserved subdomains
const reservedSubdomains = ['telemetry', 'docs', 'api', 'admin', 'www', 'app', 'auth', 'blog', 'help', 'support'];
if (reservedSubdomains.includes(subdomain)) {
return NextResponse.json(
{
available: false,
error: 'This subdomain is reserved'
},
{ status: 400 }
)
}
// Query database to see if subdomain already exists
const existingDeployment = await db
+209
View File
@@ -0,0 +1,209 @@
import { NextRequest, NextResponse } from 'next/server'
import { createLogger } from '@/lib/logs/console-logger'
const logger = createLogger('TelemetryAPI')
const ALLOWED_CATEGORIES = [
'page_view',
'feature_usage',
'performance',
'error',
'workflow',
'consent',
]
const DEFAULT_TIMEOUT = 5000 // 5 seconds timeout
/**
* Validates telemetry data to ensure it doesn't contain sensitive information
*/
function validateTelemetryData(data: any): boolean {
if (!data || typeof data !== 'object') {
return false
}
if (!data.category || !data.action) {
return false
}
if (!ALLOWED_CATEGORIES.includes(data.category)) {
return false
}
const jsonStr = JSON.stringify(data).toLowerCase()
const sensitivePatterns = [
/password/,
/token/,
/secret/,
/key/,
/auth/,
/credential/,
/private/,
]
return !sensitivePatterns.some(pattern => pattern.test(jsonStr))
}
/**
* Safely converts a value to string, handling undefined and null values
*/
function safeStringValue(value: any): string {
if (value === undefined || value === null) {
return ''
}
try {
return String(value)
} catch (e) {
return ''
}
}
/**
* Creates a safe attribute object for OpenTelemetry
*/
function createSafeAttributes(data: Record<string, any>): Array<{key: string, value: {stringValue: string}}> {
if (!data || typeof data !== 'object') {
return []
}
const attributes: Array<{key: string, value: {stringValue: string}}> = []
Object.entries(data).forEach(([key, value]) => {
if (value !== undefined && value !== null && key) {
attributes.push({
key,
value: { stringValue: safeStringValue(value) }
})
}
})
return attributes
}
/**
* Forwards telemetry data to OpenTelemetry collector
*/
async function forwardToCollector(data: any): Promise<boolean> {
if (!data || typeof data !== 'object') {
logger.error('Invalid telemetry data format')
return false
}
const endpoint = process.env.TELEMETRY_ENDPOINT || 'https://telemetry.simstudio.ai/v1/traces'
const timeout = parseInt(process.env.TELEMETRY_TIMEOUT || '') || DEFAULT_TIMEOUT
try {
const timestamp = Date.now() * 1000000
const safeAttrs = createSafeAttributes(data)
const serviceAttrs = [
{ key: 'service.name', value: { stringValue: 'sim-studio' } },
{ key: 'service.version', value: { stringValue: process.env.NEXT_PUBLIC_APP_VERSION || '0.1.0' } },
{ key: 'deployment.environment', value: { stringValue: process.env.NODE_ENV || 'production' } }
]
const spanName = data.category && data.action ? `${data.category}.${data.action}` : 'telemetry.event'
const payload = {
resourceSpans: [{
resource: {
attributes: serviceAttrs
},
instrumentationLibrarySpans: [{
spans: [{
name: spanName,
kind: 1,
startTimeUnixNano: timestamp,
endTimeUnixNano: timestamp + 1000000,
attributes: safeAttrs
}]
}]
}]
}
// Safe debug log of the payload structure without sensitive data
logger.debug('Preparing to send telemetry payload', {
endpoint,
hasAttributes: safeAttrs.length > 0,
attributeCount: safeAttrs.length
})
// Create explicit AbortController for timeout
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
try {
const options = {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload),
signal: controller.signal
}
const response = await fetch(endpoint, options)
clearTimeout(timeoutId)
if (!response.ok) {
logger.error('Telemetry collector returned error', {
status: response.status,
statusText: response.statusText
})
return false
}
return true
} catch (fetchError) {
clearTimeout(timeoutId)
if (fetchError instanceof Error && fetchError.name === 'AbortError') {
logger.error('Telemetry request timed out', { endpoint })
} else {
logger.error('Failed to send telemetry to collector', fetchError)
}
return false
}
} catch (error) {
logger.error('Error preparing telemetry payload', error)
return false
}
}
/**
* Endpoint that receives telemetry events and forwards them to OpenTelemetry collector
*/
export async function POST(req: NextRequest) {
try {
let eventData
try {
eventData = await req.json()
} catch (parseError) {
return NextResponse.json(
{ error: 'Invalid JSON in request body' },
{ status: 400 }
)
}
if (!validateTelemetryData(eventData)) {
return NextResponse.json(
{ error: 'Invalid telemetry data format or contains sensitive information' },
{ status: 400 }
)
}
const forwarded = await forwardToCollector(eventData)
return NextResponse.json({
success: true,
forwarded
})
} catch (error) {
logger.error('Error processing telemetry event', error)
return NextResponse.json(
{ error: 'Failed to process telemetry event' },
{ status: 500 }
)
}
}
@@ -5,22 +5,87 @@ import { z } from 'zod'
import { createLogger } from '@/lib/logs/console-logger'
import { db } from '@/db'
import { settings } from '@/db/schema'
import { getSession } from '@/lib/auth'
const logger = createLogger('SettingsAPI')
const logger = createLogger('UserSettingsAPI')
const SettingsSchema = z.object({
userId: z.string(),
isAutoConnectEnabled: z.boolean().default(true),
theme: z.enum(['system', 'light', 'dark']).optional(),
debugMode: z.boolean().optional(),
autoConnect: z.boolean().optional(),
autoFillEnvVars: z.boolean().optional(),
telemetryEnabled: z.boolean().optional(),
telemetryNotifiedUser: z.boolean().optional(),
})
export async function POST(request: Request) {
export async function GET() {
const requestId = crypto.randomUUID().slice(0, 8)
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized settings access attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const result = await db.select().from(settings).where(eq(settings.userId, userId)).limit(1)
if (!result.length) {
return NextResponse.json(
{
data: {
// Return default values
theme: 'system',
debugMode: false,
autoConnect: true,
autoFillEnvVars: true,
telemetryEnabled: true,
telemetryNotifiedUser: false,
},
},
{ status: 200 }
)
}
const userSettings = result[0]
return NextResponse.json(
{
data: {
theme: userSettings.theme,
debugMode: userSettings.debugMode,
autoConnect: userSettings.autoConnect,
autoFillEnvVars: userSettings.autoFillEnvVars,
telemetryEnabled: userSettings.telemetryEnabled,
telemetryNotifiedUser: userSettings.telemetryNotifiedUser,
},
},
{ status: 200 }
)
} catch (error: any) {
logger.error(`[${requestId}] Settings fetch error`, error)
return NextResponse.json({ error: error.message }, { status: 500 })
}
}
export async function PATCH(request: Request) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized settings update attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const body = await request.json()
try {
const { userId, isAutoConnectEnabled } = SettingsSchema.parse(body)
const validatedData = SettingsSchema.parse(body)
// Store the settings
await db
@@ -28,13 +93,13 @@ export async function POST(request: Request) {
.values({
id: nanoid(),
userId,
general: { isAutoConnectEnabled },
...validatedData,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [settings.userId],
set: {
general: { isAutoConnectEnabled },
...validatedData,
updatedAt: new Date(),
},
})
@@ -56,46 +121,4 @@ export async function POST(request: Request) {
logger.error(`[${requestId}] Settings update error`, error)
return NextResponse.json({ error: error.message }, { status: 500 })
}
}
export async function GET(request: Request) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
const { searchParams } = new URL(request.url)
const userId = searchParams.get('userId')
if (!userId) {
logger.warn(`[${requestId}] Missing userId parameter`)
return NextResponse.json({ error: 'userId is required' }, { status: 400 })
}
const result = await db.select().from(settings).where(eq(settings.userId, userId)).limit(1)
if (!result.length) {
return NextResponse.json(
{
data: {
isAutoConnectEnabled: true, // Return default values
},
},
{ status: 200 }
)
}
const generalSettings = result[0].general as {
isAutoConnectEnabled: boolean
}
return NextResponse.json(
{
data: {
isAutoConnectEnabled: generalSettings.isAutoConnectEnabled,
},
},
{ status: 200 }
)
} catch (error: any) {
logger.error(`[${requestId}] Settings fetch error`, error)
return NextResponse.json({ error: error.message }, { status: 500 })
}
}
}
+2 -4
View File
@@ -1,8 +1,8 @@
import type { Metadata, Viewport } from 'next'
import { SpeedInsights } from '@vercel/speed-insights/next'
import { createLogger } from '@/lib/logs/console-logger'
import './globals.css'
import { ZoomPrevention } from './zoom-prevention'
import { TelemetryConsentDialog } from '@/app/telemetry-consent-dialog'
const logger = createLogger('RootLayout')
@@ -20,9 +20,7 @@ const BROWSER_EXTENSION_ATTRIBUTES = [
if (typeof window !== 'undefined') {
const originalError = console.error
console.error = (...args) => {
// Check if it's a hydration error
if (args[0].includes('Hydration')) {
// Check if the error is related to browser extensions
const isExtensionError = BROWSER_EXTENSION_ATTRIBUTES.some((attr) =>
args.some((arg) => typeof arg === 'string' && arg.includes(attr))
)
@@ -151,7 +149,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
</head>
<body suppressHydrationWarning>
<ZoomPrevention />
<SpeedInsights />
<TelemetryConsentDialog />
{children}
</body>
</html>
+167
View File
@@ -0,0 +1,167 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { useGeneralStore } from '@/stores/settings/general/store'
import { createLogger } from '@/lib/logs/console-logger'
declare global {
interface Window {
__SIM_TELEMETRY_ENABLED?: boolean
__SIM_TRACK_EVENT?: (eventName: string, properties?: Record<string, any>) => void
}
}
const logger = createLogger('TelemetryConsentDialog')
const trackEvent = (eventName: string, properties?: Record<string, any>) => {
if (typeof window !== 'undefined' && window.__SIM_TELEMETRY_ENABLED) {
try {
if (window.__SIM_TRACK_EVENT) {
window.__SIM_TRACK_EVENT(eventName, properties)
}
} catch (error) {
logger.error(`Failed to track event ${eventName}:`, error)
}
}
}
export function TelemetryConsentDialog() {
const [open, setOpen] = useState(false)
const [settingsLoaded, setSettingsLoaded] = useState(false)
const telemetryEnabled = useGeneralStore(state => state.telemetryEnabled)
const telemetryNotifiedUser = useGeneralStore(state => state.telemetryNotifiedUser)
const setTelemetryEnabled = useGeneralStore(state => state.setTelemetryEnabled)
const setTelemetryNotifiedUser = useGeneralStore(state => state.setTelemetryNotifiedUser)
const loadSettings = useGeneralStore(state => state.loadSettings)
const hasShownDialogThisSession = useRef(false)
useEffect(() => {
let isMounted = true
const fetchSettings = async () => {
try {
await loadSettings(true)
if (isMounted) {
setSettingsLoaded(true)
}
} catch (error) {
logger.error('Failed to load settings:', error)
if (isMounted) {
setSettingsLoaded(true)
}
}
}
fetchSettings()
return () => {
isMounted = false
}
}, [loadSettings])
useEffect(() => {
if (!settingsLoaded) return
logger.debug('Settings loaded state:', {
telemetryNotifiedUser,
telemetryEnabled,
hasShownInSession: hasShownDialogThisSession.current
})
// Only show dialog if:
// 1. Settings are fully loaded from the database
// 2. User has not been notified yet (according to database)
// 3. Telemetry is currently enabled (default)
// 4. Dialog hasn't been shown in this session already (extra protection)
if (settingsLoaded && !telemetryNotifiedUser && telemetryEnabled && !hasShownDialogThisSession.current) {
setOpen(true)
hasShownDialogThisSession.current = true
}
}, [settingsLoaded, telemetryNotifiedUser, telemetryEnabled])
const handleAccept = () => {
trackEvent('telemetry_consent_accepted', {
source: 'consent_dialog',
defaultEnabled: true
})
setTelemetryNotifiedUser(true)
setOpen(false)
}
const handleDecline = () => {
trackEvent('telemetry_consent_declined', {
source: 'consent_dialog',
defaultEnabled: false
})
setTelemetryEnabled(false)
setTelemetryNotifiedUser(true)
setOpen(false)
}
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogContent className="max-w-md">
<AlertDialogHeader>
<AlertDialogTitle className="text-2xl font-bold mb-2">Telemetry</AlertDialogTitle>
</AlertDialogHeader>
<div className="space-y-4 text-base text-muted-foreground">
<div>
To help us improve Sim Studio, we collect anonymous usage
data by default. This helps us understand which features are
most useful and identify areas for improvement.
</div>
<div className="py-2">
<div className="font-semibold text-foreground mb-2">We only collect:</div>
<ul className="list-disc pl-6 space-y-1">
<li>Feature usage statistics</li>
<li>Error reports (without personal info)</li>
<li>Performance metrics</li>
</ul>
</div>
<div className="py-2">
<div className="font-semibold text-foreground mb-2">We never collect:</div>
<ul className="list-disc pl-6 space-y-1">
<li>Personal information</li>
<li>Workflow content or outputs</li>
<li>API keys or tokens</li>
<li>IP addresses or location data</li>
</ul>
</div>
<div className="text-sm text-muted-foreground pt-2">
You can change this setting anytime in{' '}
<span className="font-medium">Settings → Privacy</span>.
</div>
</div>
<AlertDialogFooter className="flex flex-col sm:flex-row gap-3 mt-4">
<AlertDialogCancel asChild onClick={handleDecline}>
<Button variant="outline" className="flex-1">
Disable telemetry
</Button>
</AlertDialogCancel>
<AlertDialogAction asChild onClick={handleAccept}>
<Button className="flex-1">
Continue with telemetry
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
@@ -1,5 +1,6 @@
import { useRouter } from 'next/navigation'
import { Info } from 'lucide-react'
import { Info, AlertTriangle } from 'lucide-react'
import { useEffect, useState } from 'react'
import {
AlertDialog,
AlertDialogAction,
@@ -20,10 +21,12 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { Switch } from '@/components/ui/switch'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useGeneralStore } from '@/stores/settings/general/store'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { resetAllStores } from '@/stores'
import { useGeneralStore } from '@/stores/settings/general/store'
const TOOLTIPS = {
debugMode: 'Enable visual debugging information during execution.',
@@ -34,123 +37,200 @@ const TOOLTIPS = {
export function General() {
const router = useRouter()
const isAutoConnectEnabled = useGeneralStore((state) => state.isAutoConnectEnabled)
const toggleAutoConnect = useGeneralStore((state) => state.toggleAutoConnect)
const isDebugModeEnabled = useGeneralStore((state) => state.isDebugModeEnabled)
const toggleDebugMode = useGeneralStore((state) => state.toggleDebugMode)
const isAutoFillEnvVarsEnabled = useGeneralStore((state) => state.isAutoFillEnvVarsEnabled)
const toggleAutoFillEnvVars = useGeneralStore((state) => state.toggleAutoFillEnvVars)
const theme = useGeneralStore((state) => state.theme)
const setTheme = useGeneralStore((state) => state.setTheme)
const [retryCount, setRetryCount] = useState(0)
const isLoading = useGeneralStore(state => state.isLoading)
const error = useGeneralStore(state => state.error)
const theme = useGeneralStore(state => state.theme)
const isAutoConnectEnabled = useGeneralStore(state => state.isAutoConnectEnabled)
const isDebugModeEnabled = useGeneralStore(state => state.isDebugModeEnabled)
const isAutoFillEnvVarsEnabled = useGeneralStore(state => state.isAutoFillEnvVarsEnabled)
const setTheme = useGeneralStore(state => state.setTheme)
const toggleAutoConnect = useGeneralStore(state => state.toggleAutoConnect)
const toggleDebugMode = useGeneralStore(state => state.toggleDebugMode)
const toggleAutoFillEnvVars = useGeneralStore(state => state.toggleAutoFillEnvVars)
const loadSettings = useGeneralStore(state => state.loadSettings)
useEffect(() => {
const loadData = async () => {
await loadSettings(retryCount > 0)
}
loadData()
}, [loadSettings, retryCount])
const handleThemeChange = (value: 'system' | 'light' | 'dark') => {
setTheme(value)
}
const handleDebugModeChange = (checked: boolean) => {
if (checked !== isDebugModeEnabled) {
toggleDebugMode()
}
}
const handleAutoConnectChange = (checked: boolean) => {
if (checked !== isAutoConnectEnabled) {
toggleAutoConnect()
}
}
const handleAutoFillEnvVarsChange = (checked: boolean) => {
if (checked !== isAutoFillEnvVarsEnabled) {
toggleAutoFillEnvVars()
}
}
const handleResetData = () => {
resetAllStores()
router.push('/w/1') // Redirect to home page after reset
}
const handleRetry = () => {
setRetryCount(prev => prev + 1)
}
return (
<div className="p-6 space-y-6">
{error && (
<Alert variant="destructive" className="mb-4">
<AlertTriangle className="h-4 w-4" />
<AlertDescription className="flex justify-between items-center">
<span>Failed to load settings: {error}</span>
<Button
variant="outline"
size="sm"
onClick={handleRetry}
disabled={isLoading}
>
Retry
</Button>
</AlertDescription>
</Alert>
)}
<div>
<h2 className="text-lg font-medium mb-[22px]">General Settings</h2>
<div className="space-y-4">
<div className="flex items-center justify-between py-1">
<div className="flex items-center gap-2">
<Label htmlFor="theme-select" className="font-medium">
Theme
</Label>
</div>
<Select value={theme} onValueChange={setTheme}>
<SelectTrigger id="theme-select" className="w-[180px]">
<SelectValue placeholder="Select theme" />
</SelectTrigger>
<SelectContent>
<SelectItem value="system">System</SelectItem>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between py-1">
<div className="flex items-center gap-2">
<Label htmlFor="debug-mode" className="font-medium">
Debug mode
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-gray-500 p-1 h-7"
aria-label="Learn more about debug mode"
>
<Info className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[300px] p-3">
<p className="text-sm">{TOOLTIPS.debugMode}</p>
</TooltipContent>
</Tooltip>
</div>
<Switch
id="debug-mode"
checked={isDebugModeEnabled}
onCheckedChange={toggleDebugMode}
/>
</div>
<div className="flex items-center justify-between py-1">
<div className="flex items-center gap-2">
<Label htmlFor="auto-connect" className="font-medium">
Auto-connect on drop
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-gray-500 p-1 h-7"
aria-label="Learn more about auto-connect feature"
>
<Info className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[300px] p-3">
<p className="text-sm">{TOOLTIPS.autoConnect}</p>
</TooltipContent>
</Tooltip>
</div>
<Switch
id="auto-connect"
checked={isAutoConnectEnabled}
onCheckedChange={toggleAutoConnect}
/>
</div>
<div className="flex items-center justify-between py-1">
<div className="flex items-center gap-2">
<Label htmlFor="auto-fill-env-vars" className="font-medium">
Auto-fill environment variables
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-gray-500 p-1 h-7"
aria-label="Learn more about auto-fill environment variables"
>
<Info className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[300px] p-3">
<p className="text-sm">{TOOLTIPS.autoFillEnvVars}</p>
</TooltipContent>
</Tooltip>
</div>
<Switch
id="auto-fill-env-vars"
checked={isAutoFillEnvVarsEnabled}
onCheckedChange={toggleAutoFillEnvVars}
/>
</div>
{isLoading ? (
<>
<SettingRowSkeleton />
<SettingRowSkeleton />
<SettingRowSkeleton />
<SettingRowSkeleton />
</>
) : (
<>
<div className="flex items-center justify-between py-1">
<div className="flex items-center gap-2">
<Label htmlFor="theme-select" className="font-medium">
Theme
</Label>
</div>
<Select
value={theme}
onValueChange={handleThemeChange}
disabled={isLoading}
>
<SelectTrigger id="theme-select" className="w-[180px]">
<SelectValue placeholder="Select theme" />
</SelectTrigger>
<SelectContent>
<SelectItem value="system">System</SelectItem>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between py-1">
<div className="flex items-center gap-2">
<Label htmlFor="debug-mode" className="font-medium">
Debug mode
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-gray-500 p-1 h-7"
aria-label="Learn more about debug mode"
disabled={isLoading}
>
<Info className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[300px] p-3">
<p className="text-sm">{TOOLTIPS.debugMode}</p>
</TooltipContent>
</Tooltip>
</div>
<Switch
id="debug-mode"
checked={isDebugModeEnabled}
onCheckedChange={handleDebugModeChange}
disabled={isLoading}
/>
</div>
<div className="flex items-center justify-between py-1">
<div className="flex items-center gap-2">
<Label htmlFor="auto-connect" className="font-medium">
Auto-connect on drop
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-gray-500 p-1 h-7"
aria-label="Learn more about auto-connect feature"
disabled={isLoading}
>
<Info className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[300px] p-3">
<p className="text-sm">{TOOLTIPS.autoConnect}</p>
</TooltipContent>
</Tooltip>
</div>
<Switch
id="auto-connect"
checked={isAutoConnectEnabled}
onCheckedChange={handleAutoConnectChange}
disabled={isLoading}
/>
</div>
<div className="flex items-center justify-between py-1">
<div className="flex items-center gap-2">
<Label htmlFor="auto-fill-env-vars" className="font-medium">
Auto-fill environment variables
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-gray-500 p-1 h-7"
aria-label="Learn more about auto-fill environment variables"
disabled={isLoading}
>
<Info className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[300px] p-3">
<p className="text-sm">{TOOLTIPS.autoFillEnvVars}</p>
</TooltipContent>
</Tooltip>
</div>
<Switch
id="auto-fill-env-vars"
checked={isAutoFillEnvVarsEnabled}
onCheckedChange={handleAutoFillEnvVarsChange}
disabled={isLoading}
/>
</div>
</>
)}
</div>
</div>
@@ -166,6 +246,7 @@ export function General() {
size="sm"
className="text-gray-500 p-1 h-7"
aria-label="Learn more about resetting all data"
disabled={isLoading}
>
<Info className="h-5 w-5" />
</Button>
@@ -177,7 +258,7 @@ export function General() {
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm">
<Button variant="destructive" size="sm" disabled={isLoading}>
Reset Data
</Button>
</AlertDialogTrigger>
@@ -205,3 +286,12 @@ export function General() {
</div>
)
}
const SettingRowSkeleton = () => (
<div className="flex items-center justify-between py-1">
<div className="flex items-center gap-2">
<Skeleton className="h-5 w-32" />
</div>
<Skeleton className="h-6 w-12" />
</div>
)
@@ -0,0 +1,107 @@
'use client'
import { useEffect } from 'react'
import { Info } from 'lucide-react'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useGeneralStore } from '@/stores/settings/general/store'
const TOOLTIPS = {
telemetry: 'We collect anonymous data about feature usage, performance, and errors to improve the application.',
}
export function Privacy() {
const isLoading = useGeneralStore(state => state.isLoading)
const telemetryEnabled = useGeneralStore(state => state.telemetryEnabled)
const setTelemetryEnabled = useGeneralStore(state => state.setTelemetryEnabled)
const setTelemetryNotifiedUser = useGeneralStore(state => state.setTelemetryNotifiedUser)
const loadSettings = useGeneralStore(state => state.loadSettings)
useEffect(() => {
loadSettings()
}, [loadSettings])
const handleTelemetryToggle = (checked: boolean) => {
setTelemetryEnabled(checked)
if (checked) {
setTelemetryNotifiedUser(true)
if (typeof window !== 'undefined') {
fetch('/api/telemetry', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
category: 'consent',
action: 'enable_from_settings',
timestamp: new Date().toISOString(),
}),
}).catch(() => {
// Silently fail - this is just telemetry
})
}
}
}
return (
<div className="p-6 space-y-6">
<div>
<h2 className="text-lg font-medium mb-[22px]">Privacy Settings</h2>
<div className="space-y-4">
{isLoading ? (
<SettingRowSkeleton />
) : (
<div className="flex items-center justify-between py-1">
<div className="flex items-center gap-2">
<Label htmlFor="telemetry" className="font-medium">
Allow anonymous telemetry
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-gray-500 p-1 h-7"
aria-label="Learn more about telemetry data collection"
>
<Info className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[300px] p-3">
<p className="text-sm">{TOOLTIPS.telemetry}</p>
</TooltipContent>
</Tooltip>
</div>
<Switch
id="telemetry"
checked={telemetryEnabled}
onCheckedChange={handleTelemetryToggle}
disabled={isLoading}
/>
</div>
)}
</div>
</div>
<div className="border-t pt-4">
<p className="text-xs text-muted-foreground">
We use OpenTelemetry to collect anonymous usage data to improve Sim Studio.
All data is collected in accordance with our privacy policy, and you can opt-out at any time.
This setting applies to your account on all devices.
</p>
</div>
</div>
)
}
const SettingRowSkeleton = () => (
<div className="flex items-center justify-between py-1">
<div className="flex items-center gap-2">
<Skeleton className="h-5 w-48" />
</div>
<Skeleton className="h-6 w-12" />
</div>
)
@@ -1,17 +1,17 @@
import { Key, KeyRound, KeySquare, Settings, UserCircle, CreditCard, Users } from 'lucide-react'
import { Key, KeyRound, KeySquare, Settings, UserCircle, CreditCard, Users, Shield } from 'lucide-react'
import { cn } from '@/lib/utils'
import { isDev } from '@/lib/environment'
interface SettingsNavigationProps {
activeSection: string
onSectionChange: (
section: 'general' | 'environment' | 'account' | 'credentials' | 'apikeys' | 'subscription' | 'team'
section: 'general' | 'environment' | 'account' | 'credentials' | 'apikeys' | 'subscription' | 'team' | 'privacy'
) => void
isTeam?: boolean
}
type NavigationItem = {
id: 'general' | 'environment' | 'account' | 'credentials' | 'apikeys' | 'subscription' | 'team'
id: 'general' | 'environment' | 'account' | 'credentials' | 'apikeys' | 'subscription' | 'team' | 'privacy'
label: string
icon: React.ComponentType<{ className?: string }>
hideInDev?: boolean
@@ -44,6 +44,11 @@ const allNavigationItems: NavigationItem[] = [
label: 'API Keys',
icon: KeySquare,
},
{
id: 'privacy',
label: 'Privacy',
icon: Shield,
},
{
id: 'subscription',
label: 'Subscription',
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { client, useSession, useActiveOrganization } from '@/lib/auth-client'
import { useState, useEffect, useMemo } from 'react'
import { client, useSession, useActiveOrganization, useSubscription } from '@/lib/auth-client'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { AlertCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
@@ -27,29 +27,58 @@ const logger = createLogger('Subscription')
interface SubscriptionProps {
onOpenChange: (open: boolean) => void
cachedIsPro?: boolean
cachedIsTeam?: boolean
cachedUsageData?: any
cachedSubscriptionData?: any
isLoading?: boolean
}
const useSubscriptionData = (userId: string | null | undefined, activeOrgId: string | null | undefined) => {
const [isPro, setIsPro] = useState<boolean>(false)
const [isTeam, setIsTeam] = useState<boolean>(false)
const useSubscriptionData = (
userId: string | null | undefined,
activeOrgId: string | null | undefined,
cachedIsPro?: boolean,
cachedIsTeam?: boolean,
cachedUsageData?: any,
cachedSubscriptionData?: any,
isParentLoading?: boolean
) => {
const [isPro, setIsPro] = useState<boolean>(cachedIsPro || false)
const [isTeam, setIsTeam] = useState<boolean>(cachedIsTeam || false)
const [usageData, setUsageData] = useState<{
percentUsed: number;
isWarning: boolean;
isExceeded: boolean;
currentUsage: number;
limit: number;
}>({
percentUsed: number
isWarning: boolean
isExceeded: boolean
currentUsage: number
limit: number
}>(cachedUsageData || {
percentUsed: 0,
isWarning: false,
isExceeded: false,
currentUsage: 0,
limit: 0
})
const [subscriptionData, setSubscriptionData] = useState<any>(null)
const [loading, setLoading] = useState<boolean>(true)
const [subscriptionData, setSubscriptionData] = useState<any>(cachedSubscriptionData || null)
const [loading, setLoading] = useState<boolean>(isParentLoading !== undefined ? isParentLoading : true)
const [error, setError] = useState<string | null>(null)
const subscription = useSubscription()
useEffect(() => {
if (
isParentLoading !== undefined ||
(cachedIsPro !== undefined &&
cachedIsTeam !== undefined &&
cachedUsageData &&
cachedSubscriptionData)
) {
if (cachedIsPro !== undefined) setIsPro(cachedIsPro)
if (cachedIsTeam !== undefined) setIsTeam(cachedIsTeam)
if (cachedUsageData) setUsageData(cachedUsageData)
if (cachedSubscriptionData) setSubscriptionData(cachedSubscriptionData)
if (isParentLoading !== undefined) setLoading(isParentLoading)
return
}
async function loadSubscriptionData() {
if (!userId) return
@@ -92,15 +121,18 @@ const useSubscriptionData = (userId: string | null | undefined, activeOrgId: str
logger.info('Checking organization subscription first', { orgId: activeOrgId })
// Get the organization's subscription
const { data: orgSubscriptions, error: orgSubError } = await client.subscription.list({
const result = await subscription.list({
query: { referenceId: activeOrgId }
})
const orgSubscriptions = result.data
const orgSubError = 'error' in result ? result.error : null
if (orgSubError) {
logger.error('Error fetching organization subscription details', orgSubError)
} else {
} else if (orgSubscriptions) {
// Find active team subscription for the organization
activeSubscription = orgSubscriptions?.find(
activeSubscription = orgSubscriptions.find(
sub => sub.status === 'active' && sub.plan === 'team'
)
@@ -116,13 +148,16 @@ const useSubscriptionData = (userId: string | null | undefined, activeOrgId: str
// If no org team subscription was found, check for personal subscription
if (!activeSubscription) {
// Fetch detailed subscription data for the user
const { data: userSubscriptions, error: userSubError } = await client.subscription.list()
const result = await subscription.list()
const userSubscriptions = result.data
const userSubError = 'error' in result ? result.error : null
if (userSubError) {
logger.error('Error fetching user subscription details', userSubError)
} else {
} else if (userSubscriptions) {
// Find active subscription for the user
activeSubscription = userSubscriptions?.find(
activeSubscription = userSubscriptions.find(
sub => sub.status === 'active'
)
}
@@ -148,14 +183,22 @@ const useSubscriptionData = (userId: string | null | undefined, activeOrgId: str
}
loadSubscriptionData()
}, [userId, activeOrgId])
}, [userId, activeOrgId, subscription, cachedIsPro, cachedIsTeam, cachedUsageData, cachedSubscriptionData, isParentLoading])
return { isPro, isTeam, usageData, subscriptionData, loading, error }
}
export function Subscription({ onOpenChange }: SubscriptionProps) {
export function Subscription({
onOpenChange,
cachedIsPro,
cachedIsTeam,
cachedUsageData,
cachedSubscriptionData,
isLoading
}: SubscriptionProps) {
const { data: session } = useSession()
const { data: activeOrg } = useActiveOrganization()
const subscription = useSubscription()
const {
isPro,
@@ -164,7 +207,15 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
subscriptionData,
loading,
error: subscriptionError
} = useSubscriptionData(session?.user?.id, activeOrg?.id)
} = useSubscriptionData(
session?.user?.id,
activeOrg?.id,
cachedIsPro,
cachedIsTeam,
cachedUsageData,
cachedSubscriptionData,
isLoading
)
const [isCanceling, setIsCanceling] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
@@ -190,15 +241,15 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
setError(null)
try {
const { error } = await client.subscription.upgrade({
const result = await subscription.upgrade({
plan: plan,
successUrl: window.location.href,
cancelUrl: window.location.href,
})
if (error) {
setError(error.message || `There was an error upgrading to the ${plan} plan`)
logger.error('Subscription upgrade error:', error)
if ('error' in result && result.error) {
setError(result.error.message || `There was an error upgrading to the ${plan} plan`)
logger.error('Subscription upgrade error:', result.error)
}
} catch (error: any) {
logger.error('Subscription upgrade exception:', error)
@@ -218,13 +269,13 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
setError(null)
try {
const { error } = await client.subscription.cancel({
const result = await subscription.cancel({
returnUrl: window.location.href,
})
if (error) {
setError(error.message || 'There was an error canceling your subscription')
logger.error('Subscription cancellation error:', error)
if ('error' in result && result.error) {
setError(result.error.message || 'There was an error canceling your subscription')
logger.error('Subscription cancellation error:', result.error)
}
} catch (error: any) {
logger.error('Subscription cancellation exception:', error)
@@ -248,17 +299,18 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
setError(null)
try {
const { error } = await client.subscription.upgrade({
const result = await subscription.upgrade({
plan: 'team',
seats,
successUrl: window.location.href,
cancelUrl: window.location.href,
seats: seats
})
if (error) {
setError(error.message || 'There was an error upgrading to the team plan')
logger.error('Team subscription upgrade error:', error)
if ('error' in result && result.error) {
setError(result.error.message || 'There was an error upgrading to the team plan')
logger.error('Team subscription upgrade error:', result.error)
} else {
// Close the dialog after successful upgrade
setIsTeamDialogOpen(false)
}
} catch (error: any) {
@@ -1,16 +1,18 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect, useState, useMemo, useRef } from 'react'
import { X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { cn } from '@/lib/utils'
import { client } from '@/lib/auth-client'
import { client, useSubscription } from '@/lib/auth-client'
import { useGeneralStore } from '@/stores/settings/general/store'
import { Account } from './components/account/account'
import { ApiKeys } from './components/api-keys/api-keys'
import { Credentials } from './components/credentials/credentials'
import { EnvironmentVariables } from './components/environment/environment'
import { General } from './components/general/general'
import { Privacy } from './components/privacy/privacy'
import { Subscription } from './components/subscription/subscription'
import { SettingsNavigation } from './components/settings-navigation/settings-navigation'
import { TeamManagement } from './components/team-management/team-management'
@@ -23,52 +25,92 @@ interface SettingsModalProps {
onOpenChange: (open: boolean) => void
}
type SettingsSection = 'general' | 'environment' | 'account' | 'credentials' | 'apikeys' | 'subscription' | 'team'
type SettingsSection = 'general' | 'environment' | 'account' | 'credentials' | 'apikeys' | 'subscription' | 'team' | 'privacy'
export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const [activeSection, setActiveSection] = useState<SettingsSection>('general')
const [isPro, setIsPro] = useState(false)
const [isTeam, setIsTeam] = useState(false)
const [subscriptionData, setSubscriptionData] = useState<any>(null)
const [usageData, setUsageData] = useState<any>(null)
const [isLoading, setIsLoading] = useState(true)
const loadSettings = useGeneralStore(state => state.loadSettings)
const subscription = useMemo(() => useSubscription(), [])
const hasLoadedInitialData = useRef(false)
useEffect(() => {
async function loadAllSettings() {
if (!open) return
if (hasLoadedInitialData.current) return
setIsLoading(true)
try {
await loadSettings()
const proStatusResponse = await fetch('/api/user/subscription')
if (proStatusResponse.ok) {
const subData = await proStatusResponse.json()
setIsPro(subData.isPro)
setIsTeam(subData.isTeam)
if (!subData.isTeam && activeSection === 'team') {
setActiveSection('general')
}
}
const usageResponse = await fetch('/api/user/usage')
if (usageResponse.ok) {
const usageData = await usageResponse.json()
setUsageData(usageData)
}
try {
const result = await subscription.list()
if (result.data && result.data.length > 0) {
const activeSubscription = result.data.find(
sub => sub.status === 'active' && (sub.plan === 'team' || sub.plan === 'pro')
)
if (activeSubscription) {
setSubscriptionData(activeSubscription)
}
}
} catch (error) {
logger.error('Error fetching subscription information', error)
}
hasLoadedInitialData.current = true
} catch (error) {
logger.error('Error loading settings data:', error)
} finally {
setIsLoading(false)
}
}
if (open) {
loadAllSettings()
} else {
hasLoadedInitialData.current = false
}
}, [open, loadSettings, subscription, activeSection])
// Listen for the custom event to open the settings modal with a specific tab
useEffect(() => {
const handleOpenSettings = (event: CustomEvent<{ tab: SettingsSection }>) => {
setActiveSection(event.detail.tab)
onOpenChange(true)
}
// Add event listener
window.addEventListener('open-settings', handleOpenSettings as EventListener)
// Clean up
return () => {
window.removeEventListener('open-settings', handleOpenSettings as EventListener)
}
}, [onOpenChange])
// Check if user is on team plan
useEffect(() => {
async function checkTeamPlan() {
try {
const response = await fetch('/api/user/subscription')
if (response.ok) {
const data = await response.json()
setIsTeam(data.isTeam)
if (!data.isTeam && activeSection === 'team') {
setActiveSection('general')
}
}
} catch (error) {
logger.error('Error checking team plan:', error)
}
}
if (open) {
checkTeamPlan()
}
}, [open, activeSection])
// Check if subscriptions are enabled
const isSubscriptionEnabled = !!client.subscription
return (
@@ -116,9 +158,19 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
<div className={cn('h-full', activeSection === 'apikeys' ? 'block' : 'hidden')}>
<ApiKeys onOpenChange={onOpenChange} />
</div>
<div className={cn('h-full', activeSection === 'privacy' ? 'block' : 'hidden')}>
<Privacy />
</div>
{isSubscriptionEnabled && (
<div className={cn('h-full', activeSection === 'subscription' ? 'block' : 'hidden')}>
<Subscription onOpenChange={onOpenChange} />
<Subscription
onOpenChange={onOpenChange}
cachedIsPro={isPro}
cachedIsTeam={isTeam}
cachedUsageData={usageData}
cachedSubscriptionData={subscriptionData}
isLoading={isLoading}
/>
</div>
)}
{isTeam && (
@@ -0,0 +1,7 @@
ALTER TABLE "settings" ALTER COLUMN "general" SET DEFAULT '{}';--> statement-breakpoint
ALTER TABLE "settings" ADD COLUMN "theme" text DEFAULT 'system' NOT NULL;--> statement-breakpoint
ALTER TABLE "settings" ADD COLUMN "debug_mode" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "settings" ADD COLUMN "auto_connect" boolean DEFAULT true NOT NULL;--> statement-breakpoint
ALTER TABLE "settings" ADD COLUMN "auto_fill_env_vars" boolean DEFAULT true NOT NULL;--> statement-breakpoint
ALTER TABLE "settings" ADD COLUMN "telemetry_enabled" boolean DEFAULT true NOT NULL;--> statement-breakpoint
ALTER TABLE "settings" ADD COLUMN "telemetry_notified_user" boolean DEFAULT false NOT NULL;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -232,6 +232,13 @@
"when": 1745973220228,
"tag": "0032_rare_nico_minoru",
"breakpoints": true
},
{
"idx": 33,
"version": "7",
"when": 1746037443324,
"tag": "0033_solid_stellaris",
"breakpoints": true
}
]
}
+19 -6
View File
@@ -19,7 +19,7 @@ export const user = pgTable('user', {
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
stripeCustomerId: text('stripe_customer_id')
});
})
export const session = pgTable('session', {
id: text('id').primaryKey(),
@@ -126,7 +126,20 @@ export const settings = pgTable('settings', {
.notNull()
.references(() => user.id, { onDelete: 'cascade' })
.unique(), // One settings record per user
general: json('general').notNull(),
// General settings
theme: text('theme').notNull().default('system'),
debugMode: boolean('debug_mode').notNull().default(false),
autoConnect: boolean('auto_connect').notNull().default(true),
autoFillEnvVars: boolean('auto_fill_env_vars').notNull().default(true),
// Privacy settings
telemetryEnabled: boolean('telemetry_enabled').notNull().default(true),
telemetryNotifiedUser: boolean('telemetry_notified_user').notNull().default(false),
// Keep general for future flexible settings
general: json('general').notNull().default('{}'),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
})
@@ -238,7 +251,7 @@ export const subscription = pgTable("subscription", {
seats: integer('seats'),
trialStart: timestamp('trial_start'),
trialEnd: timestamp('trial_end')
});
})
export const chat = pgTable('chat', {
id: text('id').primaryKey(),
@@ -281,7 +294,7 @@ export const organization = pgTable("organization", {
metadata: jsonb('metadata'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
})
export const member = pgTable("member", {
id: text('id').primaryKey(),
@@ -289,7 +302,7 @@ export const member = pgTable("member", {
organizationId: text('organization_id').notNull().references(() => organization.id, { onDelete: 'cascade' }),
role: text('role').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull()
});
})
export const invitation = pgTable("invitation", {
id: text('id').primaryKey(),
@@ -300,4 +313,4 @@ export const invitation = pgTable("invitation", {
status: text('status').notNull(),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull()
});
})
+77 -1
View File
@@ -26,6 +26,25 @@ import {
const logger = createLogger('Executor')
/**
* Tracks telemetry events for workflow execution if telemetry is enabled
*/
function trackWorkflowTelemetry(eventName: string, data: Record<string, any>) {
if (typeof window !== 'undefined' && window.__SIM_TRACK_EVENT) {
// Add timestamp and sanitize the data to avoid circular references
const safeData = {
...data,
timestamp: Date.now()
}
// Track the event through the global telemetry function
window.__SIM_TRACK_EVENT(eventName, {
category: 'workflow',
...safeData
})
}
}
/**
* Core execution engine that runs workflow blocks in topological order.
*
@@ -89,6 +108,14 @@ export class Executor {
const startTime = new Date()
let finalOutput: NormalizedBlockOutput = { response: {} }
// Track workflow execution start
trackWorkflowTelemetry('workflow_execution_started', {
workflowId,
blockCount: this.workflow.blocks.length,
connectionCount: this.workflow.connections.length,
startTime: startTime.toISOString()
})
this.validateWorkflow()
const context = this.createExecutionContext(workflowId, startTime)
@@ -162,12 +189,23 @@ export class Executor {
const endTime = new Date()
context.metadata.endTime = endTime.toISOString()
const duration = endTime.getTime() - startTime.getTime()
trackWorkflowTelemetry('workflow_execution_completed', {
workflowId,
duration,
blockCount: this.workflow.blocks.length,
executedBlockCount: context.executedBlocks.size,
startTime: startTime.toISOString(),
endTime: endTime.toISOString(),
success: true
})
return {
success: true,
output: finalOutput,
metadata: {
duration: endTime.getTime() - startTime.getTime(),
duration: duration,
startTime: context.metadata.startTime!,
endTime: context.metadata.endTime!,
workflowConnections: this.workflow.connections.map((conn) => ({
@@ -180,6 +218,15 @@ export class Executor {
} catch (error: any) {
logger.error('Workflow execution failed:', this.sanitizeError(error))
// Track workflow execution failure
trackWorkflowTelemetry('workflow_execution_failed', {
workflowId,
duration: new Date().getTime() - startTime.getTime(),
error: this.extractErrorMessage(error),
executedBlockCount: context.executedBlocks.size,
blockLogs: context.blockLogs.length
})
return {
success: false,
output: finalOutput,
@@ -732,6 +779,16 @@ export class Executor {
// Resolve inputs (which will look up references to other blocks including starter)
const inputs = this.resolver.resolveInputs(block, context)
// Track block execution start
trackWorkflowTelemetry('block_execution_start', {
workflowId: context.workflowId,
blockId: block.id,
blockType: block.metadata?.id || 'unknown',
blockName: block.metadata?.name || 'Unnamed Block',
inputSize: Object.keys(inputs).length,
startTime: new Date().toISOString()
})
// Find the appropriate handler
const handler = this.blockHandlers.find((h) => h.canHandle(block))
if (!handler) {
@@ -780,6 +837,15 @@ export class Executor {
blockType: block.metadata?.id || 'unknown',
})
trackWorkflowTelemetry('block_execution', {
workflowId: context.workflowId,
blockId: block.id,
blockType: block.metadata?.id || 'unknown',
blockName: block.metadata?.name || 'Unnamed Block',
durationMs: Math.round(executionTime),
success: true
})
return output
} catch (error: any) {
// Remove this block from active blocks if there's an error
@@ -859,6 +925,16 @@ export class Executor {
}
}
trackWorkflowTelemetry('block_execution_error', {
workflowId: context.workflowId,
blockId: block.id,
blockType: block.metadata?.id || 'unknown',
blockName: block.metadata?.name || 'Unnamed Block',
durationMs: blockLog.durationMs,
errorType: error.name || 'Error',
errorMessage: this.extractErrorMessage(error)
})
throw new Error(errorMessage)
}
}
+238
View File
@@ -0,0 +1,238 @@
/**
* Sim Studio Telemetry - Client-side Instrumentation
*
* This file initializes client-side telemetry when the app loads in the browser.
* It respects the user's telemetry preferences stored in localStorage.
*
*/
if (typeof window !== 'undefined') {
const TELEMETRY_STATUS_KEY = 'simstudio-telemetry-status'
let telemetryEnabled = true
try {
if (process.env.NEXT_TELEMETRY_DISABLED === '1') {
telemetryEnabled = false
} else {
const storedPreference = localStorage.getItem(TELEMETRY_STATUS_KEY)
if (storedPreference) {
const status = JSON.parse(storedPreference)
telemetryEnabled = status.enabled
}
}
} catch (e) {
telemetryEnabled = false
}
/**
* Safe serialize function to ensure we don't include circular references or invalid data
*/
function safeSerialize(obj: any): any {
if (obj === null || obj === undefined) return null
if (typeof obj !== 'object') return obj
if (Array.isArray(obj)) {
return obj.map(item => safeSerialize(item))
}
const result: Record<string, any> = {}
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
const value = obj[key]
if (value === undefined || value === null || typeof value === 'function' || typeof value === 'symbol') {
continue
}
try {
result[key] = safeSerialize(value)
} catch (e) {
try {
result[key] = String(value)
} catch (e2) {
}
}
}
}
return result
}
(window as any).__SIM_TELEMETRY_ENABLED = telemetryEnabled;
(window as any).__SIM_TRACK_EVENT = (eventName: string, properties?: any) => {
if (!telemetryEnabled) return
const safeProps = properties || {}
const payload = {
category: 'feature_usage',
action: eventName || 'unknown_event',
timestamp: Date.now(),
...safeSerialize(safeProps)
}
fetch('/api/telemetry', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).catch(() => {
// Silently fail if sending metrics fails
})
}
if (telemetryEnabled) {
performance.mark('sim-studio-init')
let telemetryConfig
try {
telemetryConfig = (window as any).__SIM_STUDIO_TELEMETRY_CONFIG || {
clientSide: { enabled: true },
}
} catch (e) {
telemetryConfig = { clientSide: { enabled: true } }
}
window.addEventListener('load', () => {
performance.mark('sim-studio-loaded')
performance.measure('page-load', 'sim-studio-init', 'sim-studio-loaded')
if (typeof PerformanceObserver !== 'undefined') {
const lcpObserver = new PerformanceObserver((list) => {
const entries = list.getEntries()
entries.forEach(entry => {
const value = entry.entryType === 'largest-contentful-paint'
? (entry as any).startTime
: (entry as any).value || 0
// Ensure we have non-null values for all fields
const metric = {
name: entry.name || 'unknown',
value: value || 0,
entryType: entry.entryType || 'unknown'
}
if (telemetryEnabled && telemetryConfig?.clientSide?.enabled) {
const safePayload = {
category: 'performance',
action: 'web_vital',
label: metric.name,
value: metric.value,
entryType: metric.entryType,
timestamp: Date.now()
}
fetch('/api/telemetry', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(safePayload),
}).catch(() => {
// Silently fail if sending metrics fails
})
}
})
lcpObserver.disconnect()
})
const clsObserver = new PerformanceObserver((list) => {
const entries = list.getEntries()
let clsValue = 0
entries.forEach(entry => {
clsValue += (entry as any).value || 0
})
if (telemetryEnabled && telemetryConfig?.clientSide?.enabled) {
const safePayload = {
category: 'performance',
action: 'web_vital',
label: 'CLS',
value: clsValue || 0,
entryType: 'layout-shift',
timestamp: Date.now()
}
fetch('/api/telemetry', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(safePayload),
}).catch(() => {
// Silently fail if sending metrics fails
})
}
clsObserver.disconnect()
})
const fidObserver = new PerformanceObserver((list) => {
const entries = list.getEntries()
entries.forEach(entry => {
const processingStart = (entry as any).processingStart || 0
const startTime = (entry as any).startTime || 0
const metric = {
name: entry.name || 'unknown',
value: processingStart - startTime,
entryType: entry.entryType || 'unknown'
}
if (telemetryEnabled && telemetryConfig?.clientSide?.enabled) {
const safePayload = {
category: 'performance',
action: 'web_vital',
label: 'FID',
value: metric.value,
entryType: metric.entryType,
timestamp: Date.now()
}
fetch('/api/telemetry', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(safePayload),
}).catch(() => {
// Silently fail if sending metrics fails
})
}
})
fidObserver.disconnect()
})
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true })
clsObserver.observe({ type: 'layout-shift', buffered: true })
fidObserver.observe({ type: 'first-input', buffered: true })
}
})
window.addEventListener('error', (event) => {
if (telemetryEnabled && telemetryConfig?.clientSide?.enabled) {
const errorDetails = {
message: event.error?.message || 'Unknown error',
stack: event.error?.stack?.split('\n')[0] || '',
url: window.location.pathname,
timestamp: Date.now()
}
const safePayload = {
category: 'error',
action: 'client_error',
label: errorDetails.message,
stack: errorDetails.stack,
url: errorDetails.url,
timestamp: errorDetails.timestamp
}
fetch('/api/telemetry', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(safePayload),
}).catch(() => {
// Silently fail if sending error fails
})
}
})
}
}
+96
View File
@@ -0,0 +1,96 @@
/**
* Sim Studio Telemetry - Server-side Instrumentation
*
* This file can be customized in forked repositories:
* - Set TELEMETRY_ENDPOINT env var to your collector
* - Modify exporter configuration as needed
*
* Please maintain ethical telemetry practices if modified.
*/
// This file enables OpenTelemetry instrumentation for Next.js
// See: https://nextjs.org/docs/app/building-your-application/optimizing/open-telemetry
// Set experimental.instrumentationHook = true in next.config.ts to enable this
import { createLogger } from '@/lib/logs/console-logger'
const logger = createLogger('otel-instrumentation')
const DEFAULT_TELEMETRY_CONFIG = {
endpoint: process.env.TELEMETRY_ENDPOINT || 'https://telemetry.simstudio.ai/v1/traces',
serviceName: 'sim-studio',
serviceVersion: process.env.NEXT_PUBLIC_APP_VERSION || '0.1.0',
serverSide: { enabled: true },
batchSettings: {
maxQueueSize: 100,
maxExportBatchSize: 10,
scheduledDelayMillis: 5000,
exportTimeoutMillis: 30000,
},
}
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
try {
if (process.env.NEXT_TELEMETRY_DISABLED === '1') {
logger.info('OpenTelemetry telemetry disabled via environment variable')
return
}
let telemetryConfig
try {
telemetryConfig = require('./telemetry.config.js')
} catch (e) {
telemetryConfig = DEFAULT_TELEMETRY_CONFIG
}
if (telemetryConfig.serverSide?.enabled === false) {
logger.info('Server-side OpenTelemetry instrumentation is disabled in config')
return
}
const { NodeSDK } = await import('@opentelemetry/sdk-node')
const { resourceFromAttributes } = await import('@opentelemetry/resources')
const { SemanticResourceAttributes } = await import('@opentelemetry/semantic-conventions')
const { BatchSpanProcessor } = await import('@opentelemetry/sdk-trace-node')
const { OTLPTraceExporter } = await import('@opentelemetry/exporter-trace-otlp-http')
const exporter = new OTLPTraceExporter({
url: telemetryConfig.endpoint,
})
const spanProcessor = new BatchSpanProcessor(exporter, {
maxQueueSize: telemetryConfig.batchSettings?.maxQueueSize || DEFAULT_TELEMETRY_CONFIG.batchSettings.maxQueueSize,
maxExportBatchSize: telemetryConfig.batchSettings?.maxExportBatchSize || DEFAULT_TELEMETRY_CONFIG.batchSettings.maxExportBatchSize,
scheduledDelayMillis: telemetryConfig.batchSettings?.scheduledDelayMillis || DEFAULT_TELEMETRY_CONFIG.batchSettings.scheduledDelayMillis,
exportTimeoutMillis: telemetryConfig.batchSettings?.exportTimeoutMillis || DEFAULT_TELEMETRY_CONFIG.batchSettings.exportTimeoutMillis,
})
const configResource = resourceFromAttributes({
[SemanticResourceAttributes.SERVICE_NAME]: telemetryConfig.serviceName,
[SemanticResourceAttributes.SERVICE_VERSION]: telemetryConfig.serviceVersion,
[SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]: process.env.NODE_ENV,
})
const sdk = new NodeSDK({
resource: configResource,
spanProcessors: [spanProcessor],
})
sdk.start()
const shutdownHandler = async () => {
await sdk.shutdown()
.then(() => logger.info('OpenTelemetry SDK shut down successfully'))
.catch(err => logger.error('Error shutting down OpenTelemetry SDK', err))
}
process.on('SIGTERM', shutdownHandler)
process.on('SIGINT', shutdownHandler)
logger.info('OpenTelemetry instrumentation initialized for server-side telemetry')
} catch (error) {
logger.error('Failed to initialize OpenTelemetry instrumentation', error)
}
}
}
-67
View File
@@ -21,7 +21,6 @@ export interface LogEntry {
metadata?: ToolCallMetadata | Record<string, any>
}
// Define types for tool call tracking
export interface ToolCallMetadata {
toolCalls?: ToolCall[]
cost?: {
@@ -101,7 +100,6 @@ export async function persistExecutionLogs(
// Check for agent block and tool calls
let metadata: ToolCallMetadata | undefined = undefined
logger.debug('Block type:', log.blockType)
// If this is an agent block
if (log.blockType === 'agent' && log.output) {
logger.debug('Processing agent block output for tool calls', {
@@ -177,10 +175,6 @@ export async function persistExecutionLogs(
// Case 1: Direct toolCalls array
if (Array.isArray(log.output.toolCalls)) {
logger.debug('Found direct toolCalls array', {
count: log.output.toolCalls.length,
})
// Log raw timing data for debugging
log.output.toolCalls.forEach((tc: any, idx: number) => {
logger.debug(`Tool call ${idx} raw timing data:`, {
@@ -202,14 +196,6 @@ export async function persistExecutionLogs(
blockEndTime ? new Date(blockEndTime) : undefined
)
// Log what we extracted
logger.debug(`Tool call timing extracted:`, {
name: toolCall.name,
extracted_duration: duration,
extracted_startTime: timing.startTime,
extracted_endTime: timing.endTime,
})
return {
name: toolCall.name,
duration: duration,
@@ -224,10 +210,6 @@ export async function persistExecutionLogs(
}
// Case 2: toolCalls with a list array (as seen in the screenshot)
else if (log.output.toolCalls && Array.isArray(log.output.toolCalls.list)) {
logger.debug('Found toolCalls with list array', {
count: log.output.toolCalls.list.length,
})
// Log raw timing data for debugging
log.output.toolCalls.list.forEach((tc: any, idx: number) => {
logger.debug(`Tool call list ${idx} raw timing data:`, {
@@ -300,14 +282,6 @@ export async function persistExecutionLogs(
blockEndTime ? new Date(blockEndTime) : undefined
)
// Log what we extracted
logger.debug(`Response tool call timing extracted:`, {
name: toolCall.name,
extracted_duration: duration,
extracted_startTime: timing.startTime,
extracted_endTime: timing.endTime,
})
return {
name: toolCall.name,
duration: duration,
@@ -696,47 +670,23 @@ function getToolCallTimings(
// Force a minimum duration of 1000ms if none exists
if (!enhancedToolCall.duration || enhancedToolCall.duration === 0) {
enhancedToolCall.duration = Math.max(1000, toolDuration)
logger.debug(`Setting minimum duration for tool ${toolCall.name}`, {
duration: enhancedToolCall.duration,
})
}
// Force reasonable startTime and endTime if missing
if (!enhancedToolCall.startTime) {
const startTimestamp = new Date(blockStart).getTime() + toolStartOffset
enhancedToolCall.startTime = new Date(startTimestamp).toISOString()
logger.debug(`Setting startTime for tool ${toolCall.name}`, {
startTime: enhancedToolCall.startTime,
})
}
if (!enhancedToolCall.endTime) {
const endTimestamp =
new Date(enhancedToolCall.startTime).getTime() + enhancedToolCall.duration
enhancedToolCall.endTime = new Date(endTimestamp).toISOString()
logger.debug(`Setting endTime for tool ${toolCall.name}`, {
endTime: enhancedToolCall.endTime,
})
}
return enhancedToolCall
})
logger.debug('Finished estimating tool call timings', {
originalTools: toolCalls.map((t) => ({
name: t.name,
hadDuration: !!t.duration,
hadStartTime: !!t.startTime,
hadEndTime: !!t.endTime,
})),
enhancedTools: result.map((t) => ({
name: t.name,
duration: t.duration,
startTime: t.startTime,
endTime: t.endTime,
})),
})
return result
}
@@ -816,15 +766,6 @@ function extractTimingInfo(
blockStartTime?: Date,
blockEndTime?: Date
): { startTime?: Date; endTime?: Date } {
logger.debug('Extracting timing info from tool call', {
tool: toolCall.name,
hasStartTime: !!toolCall.startTime,
hasEndTime: !!toolCall.endTime,
hasTiming: !!toolCall.timing,
blockStartRef: blockStartTime?.toISOString(),
blockEndRef: blockEndTime?.toISOString(),
})
let startTime: Date | undefined = undefined
let endTime: Date | undefined = undefined
@@ -849,21 +790,13 @@ function extractTimingInfo(
endTime = new Date(toolCall.completedAt)
}
// If we have start time but no end time, calculate end time from duration
if (startTime && !endTime) {
const duration = extractDuration(toolCall)
if (duration > 0) {
endTime = new Date(startTime.getTime() + duration)
logger.debug('Calculated end time from start time and duration', {
tool: toolCall.name,
startTime: startTime.toISOString(),
duration,
calculatedEndTime: endTime.toISOString(),
})
}
}
// Log the final timing information
logger.debug('Final extracted timing info', {
tool: toolCall.name,
startTime: startTime?.toISOString(),
+259
View File
@@ -0,0 +1,259 @@
/**
* Sim Studio Telemetry
*
* This file can be customized in forked repositories:
* - Set TELEMETRY_ENDPOINT in telemetry.config.js to your collector
* - Modify allowed event categories as needed
* - Edit disclosure text to match your privacy policy
*
* Please maintain ethical telemetry practices if modified.
*/
import { createLogger } from '@/lib/logs/console-logger'
import { diag, DiagConsoleLogger, DiagLogLevel } from '@opentelemetry/api'
diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.ERROR)
const logger = createLogger('telemetry')
export type TelemetryEvent = {
name: string
properties?: Record<string, any>
}
export type TelemetryStatus = {
enabled: boolean
notifiedUser: boolean
}
const TELEMETRY_STATUS_KEY = 'simstudio-telemetry-status'
let telemetryConfig = {
endpoint: process.env.TELEMETRY_ENDPOINT || 'https://telemetry.simstudio.ai/v1/traces',
serviceName: 'sim-studio',
serviceVersion: process.env.NEXT_PUBLIC_APP_VERSION || '0.1.0',
}
if (typeof window !== 'undefined' && (window as any).__SIM_STUDIO_TELEMETRY_CONFIG) {
telemetryConfig = { ...telemetryConfig, ...(window as any).__SIM_STUDIO_TELEMETRY_CONFIG }
}
let telemetryInitialized = false
/**
* Gets the current telemetry status from localStorage
*/
export function getTelemetryStatus(): TelemetryStatus {
if (typeof window === 'undefined') {
return { enabled: true, notifiedUser: false }
}
try {
if (process.env.NEXT_TELEMETRY_DISABLED === '1') {
return { enabled: false, notifiedUser: true }
}
const stored = localStorage.getItem(TELEMETRY_STATUS_KEY)
return stored ? JSON.parse(stored) : { enabled: true, notifiedUser: false }
} catch (error) {
logger.error('Failed to get telemetry status from localStorage', error)
return { enabled: true, notifiedUser: false }
}
}
/**
* Sets the telemetry status in localStorage
*/
export function setTelemetryStatus(status: TelemetryStatus): void {
if (typeof window === 'undefined') {
return
}
try {
localStorage.setItem(TELEMETRY_STATUS_KEY, JSON.stringify(status))
if (status.enabled && !telemetryInitialized) {
initializeClientTelemetry()
}
} catch (error) {
logger.error('Failed to set telemetry status in localStorage', error)
}
}
/**
* Mark that the user has been notified about telemetry
*/
export function markUserNotified(): void {
const status = getTelemetryStatus()
setTelemetryStatus({ ...status, notifiedUser: true })
}
/**
* Disables telemetry
*/
export function disableTelemetry(): void {
const currentStatus = getTelemetryStatus()
if (currentStatus.enabled) {
trackEvent('consent', 'opt_out')
}
setTelemetryStatus({ enabled: false, notifiedUser: true })
logger.info('Telemetry disabled')
}
/**
* Enables telemetry
*/
export function enableTelemetry(): void {
if (process.env.NEXT_TELEMETRY_DISABLED === '1') {
logger.info('Telemetry disabled by environment variable, cannot enable')
return
}
const currentStatus = getTelemetryStatus()
if (!currentStatus.enabled) {
trackEvent('consent', 'opt_in')
}
setTelemetryStatus({ enabled: true, notifiedUser: true })
logger.info('Telemetry enabled')
if (!telemetryInitialized) {
initializeClientTelemetry()
}
}
/**
* Initialize client-side telemetry without OpenTelemetry SDK
* This approach uses direct event tracking instead of the OTel SDK
* to avoid TypeScript compatibility issues while still collecting useful data
*/
function initializeClientTelemetry(): void {
if (typeof window === 'undefined' || telemetryInitialized) {
return
}
try {
const clientSideEnabled = (window as any).__SIM_STUDIO_TELEMETRY_CONFIG?.clientSide?.enabled !== false
if (!clientSideEnabled) {
logger.info('Client-side telemetry disabled in configuration')
return
}
if (process.env.NODE_ENV === 'production') {
trackEvent('page_view', window.location.pathname)
if (typeof window.history !== 'undefined') {
const originalPushState = window.history.pushState
window.history.pushState = function(...args) {
const result = originalPushState.apply(this, args)
trackEvent('page_view', window.location.pathname)
return result
}
}
if (typeof window.performance !== 'undefined') {
window.addEventListener('load', () => {
setTimeout(() => {
if (performance.getEntriesByType) {
const navigationTiming = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming
if (navigationTiming) {
trackEvent('performance', 'page_load', window.location.pathname,
navigationTiming.loadEventEnd - navigationTiming.startTime)
}
const lcpEntries = performance.getEntriesByType('paint')
.filter(entry => entry.name === 'largest-contentful-paint')
if (lcpEntries.length > 0) {
trackEvent('performance', 'largest_contentful_paint',
window.location.pathname, lcpEntries[0].startTime)
}
}
}, 0)
})
}
document.addEventListener('click', (e) => {
let target = e.target as HTMLElement | null
let telemetryAction = null
while (target && !telemetryAction) {
telemetryAction = target.getAttribute('data-telemetry')
if (!telemetryAction) {
target = target.parentElement
}
}
if (telemetryAction) {
trackEvent('feature_usage', telemetryAction)
}
}, { passive: true })
document.addEventListener('submit', (e) => {
const form = e.target as HTMLFormElement
const telemetryAction = form.getAttribute('data-telemetry')
if (telemetryAction) {
trackEvent('feature_usage', telemetryAction)
}
}, { passive: true })
window.addEventListener('error', (event) => {
const errorDetails = {
message: event.error?.message || 'Unknown error',
stack: event.error?.stack?.split('\n')[0] || '',
url: window.location.pathname,
}
trackEvent('error', 'client_error', errorDetails.message)
}, { passive: true })
window.addEventListener('unhandledrejection', (event) => {
const errorDetails = {
message: event.reason?.message || String(event.reason) || 'Unhandled promise rejection',
url: window.location.pathname,
}
trackEvent('error', 'unhandled_rejection', errorDetails.message)
}, { passive: true })
logger.info('Enhanced client-side telemetry initialized')
telemetryInitialized = true
}
} catch (error) {
logger.error('Failed to initialize client-side telemetry', error)
}
}
/**
* Track a telemetry event
*/
export async function trackEvent(category: string, action: string, label?: string, value?: number): Promise<void> {
const status = getTelemetryStatus()
if (!status.enabled) return
try {
if (process.env.NODE_ENV === 'production') {
await fetch('/api/telemetry', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
category,
action,
label,
value,
timestamp: new Date().toISOString(),
service: telemetryConfig.serviceName,
version: telemetryConfig.serviceVersion,
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined,
url: typeof window !== 'undefined' ? window.location.pathname : undefined,
}),
})
} else {
if (category === 'consent') {
logger.debug('Telemetry consent change', { action })
}
}
} catch (error) {
logger.error('Failed to track telemetry event', error)
}
}
+1917 -2
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -34,6 +34,17 @@
"@browserbasehq/stagehand": "^2.0.0",
"@cerebras/cerebras_cloud_sdk": "^1.23.0",
"@hookform/resolvers": "^4.1.3",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-collector": "^0.25.0",
"@opentelemetry/exporter-jaeger": "^2.0.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.200.0",
"@opentelemetry/instrumentation-document-load": "^0.45.0",
"@opentelemetry/instrumentation-user-interaction": "^0.45.0",
"@opentelemetry/resources": "^2.0.0",
"@opentelemetry/sdk-node": "^0.200.0",
"@opentelemetry/sdk-trace-web": "^2.0.0",
"@opentelemetry/semantic-conventions": "^1.32.0",
"@opentelemetry/web": "^0.24.0",
"@radix-ui/react-alert-dialog": "^1.1.5",
"@radix-ui/react-checkbox": "^1.1.3",
"@radix-ui/react-collapsible": "^1.1.3",
+126 -14
View File
@@ -1,24 +1,136 @@
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { createLogger } from '@/lib/logs/console-logger'
import { GeneralStore } from './types'
const logger = createLogger('GeneralStore')
const CACHE_TIMEOUT = 5000
const MAX_ERROR_RETRIES = 2
export const useGeneralStore = create<GeneralStore>()(
devtools(
persist(
(set) => ({
isAutoConnectEnabled: true,
isDebugModeEnabled: false,
isAutoFillEnvVarsEnabled: true,
theme: 'system',
toggleAutoConnect: () =>
set((state) => ({
isAutoConnectEnabled: !state.isAutoConnectEnabled,
})),
toggleDebugMode: () => set((state) => ({ isDebugModeEnabled: !state.isDebugModeEnabled })),
toggleAutoFillEnvVars: () =>
set((state) => ({ isAutoFillEnvVarsEnabled: !state.isAutoFillEnvVarsEnabled })),
setTheme: (theme: 'system' | 'light' | 'dark') => set({ theme }),
}),
(set, get) => {
let lastLoadTime = 0
let errorRetryCount = 0
return {
isAutoConnectEnabled: true,
isDebugModeEnabled: false,
isAutoFillEnvVarsEnabled: true,
theme: 'system',
telemetryEnabled: true,
telemetryNotifiedUser: false,
isLoading: false,
error: null,
// Basic Actions
toggleAutoConnect: () => {
const newValue = !get().isAutoConnectEnabled
set({ isAutoConnectEnabled: newValue })
get().updateSetting('autoConnect', newValue)
},
toggleDebugMode: () => {
const newValue = !get().isDebugModeEnabled
set({ isDebugModeEnabled: newValue })
get().updateSetting('debugMode', newValue)
},
toggleAutoFillEnvVars: () => {
const newValue = !get().isAutoFillEnvVarsEnabled
set({ isAutoFillEnvVarsEnabled: newValue })
get().updateSetting('autoFillEnvVars', newValue)
},
setTheme: (theme) => {
set({ theme })
get().updateSetting('theme', theme)
},
setTelemetryEnabled: (enabled) => {
set({ telemetryEnabled: enabled })
get().updateSetting('telemetryEnabled', enabled)
},
setTelemetryNotifiedUser: (notified) => {
set({ telemetryNotifiedUser: notified })
get().updateSetting('telemetryNotifiedUser', notified)
},
// API Actions
loadSettings: async (force = false) => {
// Skip loading if settings were recently loaded (within 5 seconds)
const now = Date.now()
if (!force && now - lastLoadTime < CACHE_TIMEOUT) {
logger.debug('Skipping settings load - recently loaded')
return
}
try {
set({ isLoading: true, error: null })
const response = await fetch('/api/user/settings')
if (!response.ok) {
throw new Error('Failed to fetch settings')
}
const { data } = await response.json()
set({
isAutoConnectEnabled: data.autoConnect,
isDebugModeEnabled: data.debugMode,
isAutoFillEnvVarsEnabled: data.autoFillEnvVars,
theme: data.theme,
telemetryEnabled: data.telemetryEnabled,
telemetryNotifiedUser: data.telemetryNotifiedUser,
isLoading: false
})
lastLoadTime = now
errorRetryCount = 0
} catch (error) {
logger.error('Error loading settings:', error)
set({
error: error instanceof Error ? error.message : 'Unknown error',
isLoading: false
})
}
},
updateSetting: async (key, value) => {
try {
const response = await fetch('/api/user/settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [key]: value }),
})
if (!response.ok) {
throw new Error(`Failed to update setting: ${key}`)
}
set({ error: null })
lastLoadTime = Date.now()
errorRetryCount = 0
} catch (error) {
logger.error(`Error updating setting ${key}:`, error)
set({ error: error instanceof Error ? error.message : 'Unknown error' })
if (errorRetryCount < MAX_ERROR_RETRIES) {
errorRetryCount++
logger.debug(`Retry attempt ${errorRetryCount} after error`)
get().loadSettings(true)
} else {
logger.warn(`Max retries (${MAX_ERROR_RETRIES}) exceeded, skipping automatic loadSettings`)
}
}
}
}
},
{
name: 'general-settings',
}
+17
View File
@@ -3,6 +3,10 @@ export interface General {
isDebugModeEnabled: boolean
isAutoFillEnvVarsEnabled: boolean
theme: 'system' | 'light' | 'dark'
telemetryEnabled: boolean
telemetryNotifiedUser: boolean
isLoading: boolean
error: string | null
}
export interface GeneralActions {
@@ -10,6 +14,19 @@ export interface GeneralActions {
toggleDebugMode: () => void
toggleAutoFillEnvVars: () => void
setTheme: (theme: 'system' | 'light' | 'dark') => void
setTelemetryEnabled: (enabled: boolean) => void
setTelemetryNotifiedUser: (notified: boolean) => void
loadSettings: (force?: boolean) => Promise<void>
updateSetting: <K extends keyof UserSettings>(key: K, value: UserSettings[K]) => Promise<void>
}
export type GeneralStore = General & GeneralActions
export type UserSettings = {
theme: 'system' | 'light' | 'dark'
debugMode: boolean
autoConnect: boolean
autoFillEnvVars: boolean
telemetryEnabled: boolean
telemetryNotifiedUser: boolean
}
+86
View File
@@ -0,0 +1,86 @@
/**
* Sim Studio Telemetry Configuration
*
* PRIVACY NOTICE:
* - Telemetry is enabled by default to help us improve the product
* - You can disable telemetry via:
* 1. Settings UI > Privacy tab > Toggle off "Allow anonymous telemetry"
* 2. Setting NEXT_TELEMETRY_DISABLED=1 environment variable
*
* This file allows you to configure telemetry collection for your
* Sim Studio instance. If you've forked the repository, you can modify
* this file to send telemetry to your own collector.
*
* We only collect anonymous usage data to improve the product:
* - Feature usage statistics
* - Error rates
* - Performance metrics
*
* We NEVER collect:
* - Personal information
* - Workflow content or outputs
* - API keys or tokens
* - IP addresses or geolocation data
*/
module.exports = {
/**
* Endpoint URL where telemetry data is sent
* Change this if you want to send telemetry to your own collector
*/
endpoint: process.env.TELEMETRY_ENDPOINT || 'https://telemetry.simstudio.ai/v1/traces',
/**
* Service name used to identify this instance
* You can change this
*/
serviceName: 'sim-studio',
/**
* Version of the service, defaults to the app version
*/
serviceVersion: process.env.NEXT_PUBLIC_APP_VERSION || '0.1.0',
/**
* Batch settings for sending telemetry
* - maxQueueSize: Max number of spans to buffer
* - maxExportBatchSize: Max number of spans to send in a single batch
* - scheduledDelayMillis: Delay between batches (ms)
* - exportTimeoutMillis: Timeout for exporting data (ms)
*/
batchSettings: {
maxQueueSize: 100,
maxExportBatchSize: 10,
scheduledDelayMillis: 5000,
exportTimeoutMillis: 30000,
},
/**
* Categories of events that can be collected
* This is used for validation when events are sent
*/
allowedCategories: [
'page_view',
'feature_usage',
'performance',
'error',
'workflow',
'consent',
],
/**
* Client-side instrumentation settings
* Set enabled: false to disable client-side telemetry entirely
*/
clientSide: {
enabled: true,
},
/**
* Server-side instrumentation settings
* Set enabled: false to disable server-side telemetry entirely
*/
serverSide: {
enabled: true,
},
};